Swift音乐播放器实现AVPlayer流式播放的时间轴歌词同步
Swift AVPlayer 流式播放实现同步歌词全流程
1. 原始歌词预处理:拆分为结构化时间-文本数组
拿到的原始歌词是连续拼接的带[mm:ss:ms]时间戳的长字符串,第一步需要先把每个时间戳和对应的歌词文本拆出来,生成按播放时间排序的结构化数组,方便后续快速匹配。
- 先定义单个歌词行的模型,实现
Comparable协议方便后续排序、查找
// 结构化歌词行模型 struct LyricLine: Comparable { let startTime: TimeInterval // 该行歌词的起始播放时间,单位为秒 let text: String // 该行对应的文本内容 // 实现比较协议,方便排序和二分查找 static func < (lhs: LyricLine, rhs: LyricLine) -> Bool { lhs.startTime < rhs.startTime } }
- 为现有
MusicModel扩展歌词解析方法,用正则提取时间戳和文本、转换为秒数后排序
extension MusicModel { // 解析原始长字符串歌词为结构化数组 func parseLyrics() -> [LyricLine] { do { // 正则匹配规则:匹配所有[时间戳]歌词文本的片段 // 分组1:分钟 分组2:秒 分组3:毫秒 分组4:对应歌词文本 let regex = try NSRegularExpression( pattern: #"\[(\d{2}):(\d{2}):(\d{3})\](.*?)(?=\[|$)"#, options: .dotMatchesLineSeparators ) // 计算字符串整体的NSRange长度,适配正则的取值逻辑 let fullRange = NSRange(location: 0, length: (currentLyrics as NSString).length) var result = [LyricLine]() regex.enumerateMatches(in: currentLyrics, range: fullRange) { match, _, _ in guard let match = match else { return } // 正则返回NSRange,直接通过NSString方法取值 let lyricNSString = currentLyrics as NSString let minuteStr = lyricNSString.substring(with: match.range(at: 1)) let secondStr = lyricNSString.substring(with: match.range(at: 2)) let millisecondStr = lyricNSString.substring(with: match.range(at: 3)) let textStr = lyricNSString.substring(with: match.range(at: 4)) // 时间戳转总秒数:分钟*60 + 秒 + 毫秒/1000 let minute = Double(minuteStr) ?? 0 let second = Double(secondStr) ?? 0 let millisecond = (Double(millisecondStr) ?? 0) / 1000.0 let totalTime = minute * 60 + second + millisecond // 清理文本前后多余的分隔空格 let trimmedText = textStr.trimmingCharacters(in: .whitespaces) result.append(LyricLine(startTime: totalTime, text: trimmedText)) } // 按播放时间从小到大排序,保证匹配顺序正确 return result.sorted() } catch { print("歌词解析失败: \(error)") return [] } } }
2. AVPlayer 实时播放进度跟踪
不要用手动Timer轮询跟踪进度,会出现和播放器时钟不同步、后台运行被系统挂起的问题,直接使用AVPlayer原生的进度观察能力:
- 推荐回调精度设为0.1秒,刚好匹配歌词的毫秒级时间戳精度,又不会因回调太频繁造成性能浪费
- 必须持有时间观察器的返回引用,在页面销毁时手动移除观察器,避免内存泄漏
class PlayerViewController: UIViewController { // 持有播放器实例 private var player: AVPlayer? // 存储解析好的结构化歌词数组 private var lyricLines: [LyricLine] = [] // 记录当前高亮的歌词行索引,避免重复刷新UI private var currentLyricIndex: Int = 0 // 持有时间观察器引用,防止被系统提前释放 private var timeObserver: Any? @IBOutlet weak var lyricTextView: UITextView! // 对接MusicManager的代理回调,加载音乐数据 func setupMusicData(music: MusicModel) { // 初始化播放器 guard let fileURL = URL(string: music.currentFile) else { return } player = AVPlayer(url: fileURL) // 预解析歌词为结构化数组 lyricLines = music.parseLyrics() currentLyricIndex = -1 // 初始设为-1,保证首次匹配时会触发UI更新 // 注册播放进度观察器,0.1秒回调一次 let interval = CMTime(seconds: 0.1, preferredTimescale: CMTimeScale(NSEC_PER_SEC)) timeObserver = player?.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] currentTime in guard let self = self else { return } // 获取当前播放的秒数 let playSecond = CMTimeGetSeconds(currentTime) // 匹配并更新歌词展示 self.updateLyricDisplay(currentTime: playSecond) } // 开始播放 player?.play() } deinit { // 页面销毁时移除时间观察器,避免内存泄漏 if let observer = timeObserver { player?.removeTimeObserver(observer) } } }
3. 歌词匹配与UI同步逻辑
因为结构化歌词数组已经是按时间从小到大排序的,不需要每次全量遍历匹配,用二分查找快速定位当前播放时间对应的歌词行,减少计算开销:
- 匹配规则:找到最大的歌词行,满足「该行起始时间 ≤ 当前播放时间 < 下一行歌词的起始时间」
- 做索引变化判断:只有当匹配到的行索引和当前记录的索引不一致时,才更新UITextView的内容,避免无意义的UI刷新造成闪烁
private func updateLyricDisplay(currentTime: TimeInterval) { // 空歌词数组直接返回 guard !lyricLines.isEmpty else { return } // 边界判断:播放时间早于第一行歌词的起始时间,清空文本 guard currentTime >= lyricLines.first!.startTime else { lyricTextView.text = "" currentLyricIndex = -1 return } // 边界判断:播放时间晚于最后一行歌词的起始时间,直接显示最后一行 guard currentTime < lyricLines.last!.startTime else { let matchIndex = lyricLines.count - 1 if matchIndex != currentLyricIndex { currentLyricIndex = matchIndex // 更新文本,可附加高亮样式:加粗、主题色 lyricTextView.text = lyricLines[matchIndex].text } return } // 二分查找定位匹配的歌词索引 var matchIndex = 0 var low = 0 var high = lyricLines.count - 1 while low <= high { let mid = (low + high) / 2 if lyricLines[mid].startTime <= currentTime { // 下一行的时间已经大于当前播放时间,说明找到匹配行 if mid < lyricLines.count - 1 && lyricLines[mid + 1].startTime > currentTime { matchIndex = mid break } low = mid + 1 } else { high = mid - 1 } } // 仅当索引变化时更新UI,减少无意义渲染 if matchIndex != currentLyricIndex { currentLyricIndex = matchIndex lyricTextView.text = lyricLines[matchIndex].text // 可扩展:调整UITextView的contentOffset,将当前行滚动到可视区域居中位置 } }
常见注意点
- 时间转换注意:
[mm:ss:ms]格式的时间,毫秒值需要除以1000转换为秒单位,不要直接把毫秒当秒值计算,会出现严重的时间错位 - 内存管理:AVPlayer的时间观察器不会被ARC自动释放,必须在deinit阶段手动移除,否则会造成循环引用和内存泄漏
- 性能优化:避免每次进度回调都强制刷新UITextView,只有当匹配的歌词索引真正变化时再更新内容,可减少UI闪烁、降低CPU占用
内容的提问来源于stack exchange,提问作者Chung Seong-jun
相关产品推荐
相关产品推荐

