You need to enable JavaScript to run this app. Swift音乐播放器实现AVPlayer流式播放的时间轴歌词同步
优惠活动
大模型
产品
解决方案
定价
更多

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.10.31 15:24:04