做音乐播放器这几年,AVPlayer 几乎成了我每次技术选型时绕不开的选项。它不像 AVAudioPlayer 那样一条 API 就能出声,但正因为它的能力边界足够宽,才让“歌单、进度条、缓冲、切歌、锁屏控制”这一整套需求都有了统一答案。这篇文章不是 API 文档翻译,我会从真实开发顺序出发,讲清楚基于 AVPlayer 构建音乐播放器时最关键的设计决策:为什么用 AVPlayer、如何组织播放核心、怎么做切歌、怎么把缓冲和进度条做“真”,以及后台播放和线上踩坑的处理方式。无论你是刚接触 iOS 音频开发,还是已经在用 AVPlayer 但经常被各种奇怪问题卡住,这篇都适合当一份“从零搭到可用”的实战笔记。
1. 为什么市面上的播放器引擎大多绕不开 AVPlayer
1.1 从一次音频选型失误说起
第一版播放器 Demo,我图省事选了 AVPlayer 家族里的 AVAudioPlayer。本地文件播放确实顺手,到了联调阶段换成线上 URL,问题立刻暴露:AVAudioPlayer 需要先把音频文件完整加载到内存才开始播放,一首几十 MB 的歌曲,用户可能要等十几秒,期间 UI 没有任何反馈。
后来我才理解,AVAudioPlayer 的本质是“一次性把整段音频交给播放器”,它对内存和启动时间的要求都很高,适合短音频、音效、语音留言这类体量可控的场景;而 AVPlayer 基于流式架构,内部按需加载媒体数据,可以边下边播,天然适配在线音乐。
一句话总结:如果你的播放器只需要播放一段已经完整存在的音频,AVAudioPlayer 够用;只要涉及远程地址、混音、多个播放实例、精确 seek、自定义渲染音效,直接上 AVPlayer。
1.2 AVPlayer 和 AVAudioPlayer 的真实分工
下面这个对比表我用下来觉得最直观:
| 对比项 | AVPlayer | AVAudioPlayer |
|---|---|---|
| 播放来源 | 本地/远程 URL、自定义数据源 | 本地文件、内存 data,远程需先下载 |
| 启动要求 | 边下边播,秒开 | 需完整加载到内存 |
| 精确 seek | 支持,配合 AVPlayerItem 状态 | 支持但精度相对弱 |
| 多实例并发 | 可以,各自独立 | 可以,但内存压力大 |
| 高级控制 | 倍速、音效、视频同步、队列 | 基础播放、循环、音量 |
| 状态管理 | 状态机完善,KVO/回调丰富 | 相对简单 |
如果只是做一个播放本地语音备忘录的小工具,选 AVAudioPlayer 并没错;但做音乐 App,歌单、切歌、在线流媒体、锁屏控制每一项都需要 AVPlayer 的能力。选型这件事,早做比晚做强得多,做完第一版再换框架,迁移成本高得让人肉疼。
顺便说一句,AVPlayer 虽然是 AVFoundation 框架的一部分,但它不仅处理音频,也处理视频。做播放器时注意把 playerLayer 排除在外,只拿音频功能用,不会产生额外负担。
2. 最小可用的播放核心:从初始化到状态观察
2.1 三行代码背后的隐藏流程
AVPlayer 最简单的用法是这几行:
let player = AVPlayer() let item = AVPlayerItem(url: mediaURL) player.replaceCurrentItem(with: item) player.play()但如果你真把这段代码丢到线上,会出现一个经典问题:调用 play() 之后没声音,尤其是网络不稳定时。
原因在于 AVPlayerItem 异步准备播放。它不是一个“设置完立刻生效”的对象,而是要经历 loading -> readyToPlay -> failed 的状态流转。你在状态还没就绪时就调用 play,系统可能直接忽略这次播放请求。所以实际项目中,至少应该加一个 isReadyToPlay 标志,在 item 状态变成 readyToPlay 后再真正调 play:
func play(url: URL) { let item = AVPlayerItem(url: url) player.replaceCurrentItem(with: item) observe(item) }2.2 不观察状态,等于没有播放逻辑
AVPlayerItem 的 status 是我见过的、最容易被新手忽略的播放器核心逻辑。它在现实中的反馈通常是:用户点了播放按钮,播放器 UI 一直“假装在播放”,但声音和进度纹丝不动,也没报错。
正确做法是同时观察这几个关键路径:
- status:判断能否播放 readyToPlay / failed / unknown
- playbackBufferEmpty:缓冲不够,进入 loading 状态
- playbackLikelyToKeepUp:缓冲足够,可以继续播放
- timeControlStatus:播放、暂停、等待
一个典型的观察逻辑可以这样写:
private var readyObservation: NSKeyValueObservation? private var bufferEmptyObservation: NSKeyValueObservation? private var keepUpObservation: NSKeyValueObservation? func observe(_ item: AVPlayerItem) { readyObservation = item.observe(\.status, options: [.new, .initial]) { [weak self] item, _ in DispatchQueue.main.async { self?.handleStatusChange(item.status) } } bufferEmptyObservation = item.observe(\.isPlaybackBufferEmpty, options: [.new]) { _, _ in // 进入缓冲,UI 层面可以显示 loading } keepUpObservation = item.observe(\.isPlaybackLikelyToKeepUp, options: [.new]) { _, _ in // 缓冲足够,隐藏 loading } } private func handleStatusChange(_ status: AVPlayerItem.Status) { switch status { case .readyToPlay: isReadyToPlay = true player.play() case .failed: handlePlayFailed() default: break } }这里有两个细节要特别注意。
第一,KVO 回调不一定在主线程,所以我用DispatchQueue.main.async包了一层,避免在后台线程更新 UI 导致闪烁或崩溃。很多人在这一步翻车,表现是锁屏封面偶尔花屏、进度条卡顿。
第二,options 里我加了.initial,这样注册观察者的当下就会回调一次 status 的初始值,避免“状态已经在 readyToPlay 了,观察者才注册上,导致漏掉一次回调”的边界情况。如果用传统observeValue(forKeyPath:)写法也可以,但要记得在 deinit 里手动removeObserver,否则 iOS 11 之后对象释放时会崩溃。NSKeyValueObservation 的好处是自动管理,省去这套样板代码。
等 player 创建、item 替换、状态观察齐了,一个能出声的最小播放器才算真正立起来。
3. 歌单与切歌:replaceCurrentItem 的正确打开方式
3.1 为什么我不推荐 AVQueuePlayer 做音乐列表
AVPlayer 有个专门做队列的子类 AVQueuePlayer,可以一次塞多个 AVPlayerItem 进去,顺序播放。听起来很适合歌单对吧?但实际用下来,我对它是有保留的。
AVQueuePlayer 的定位更接近“播完自动播下一个”的播放列表,它把队列管理写死在框架里。你很难在歌曲之间插入“随机播放”“单曲循环”“跨队列跳转”这类逻辑;另外它维护的 item 集合在动态修改时很容易出状态问题,尤其是正在播放第一个 item 时修改整个列表,行为比较难预测。
我的做法是:仍然用普通 AVPlayer,维护自己的歌曲数组和 currentIndex,用 replaceCurrentItem 实现切歌。这种方案可控性远高于 AVQueuePlayer,而且语义清晰,遇到问题容易排查。
struct Track { let title: String let artist: String let url: URL } final class PlayerManager { private let player = AVPlayer() private(set) var tracks: [Track] = [] private(set) var currentIndex: Int = 0 func load(tracks: [Track], startAt index: Int) { self.tracks = tracks currentIndex = index play(at: index) } func play(at index: Int) { guard tracks.indices.contains(index) else { return } let track = tracks[index] let item = AVPlayerItem(url: track.url) currentIndex = index player.replaceCurrentItem(with: item) // 等 status readyToPlay 后再 play } }3.2 切歌时最容易被忽略的三个细节
第一个细节:AVPlayerItem 不能复用。同一个 AVPlayerItem 对象,一旦被 replaceCurrentItem 替换出去,就处于“用过一次不能再绑”的状态。如果你做的是“同一首歌再播一次”,必须重新创建一个 item,而不是拿旧 item 再 replace 回来。这个坑我踩过,表现是第二次播放直接没声音、状态停留在 unknown。
第二个细节:切歌瞬间调用 play() 不可靠。上一首还在播放时,你 replace 到下一首,下一首的 item 可能是 unknown 状态,此时 play() 大概率无效。解决办法是统一走“readyToPlay 之后播放”的路径,把 play 请求记录下来:
private var shouldPlayWhenReady = false private func enqueuePlay() { shouldPlayWhenReady = true } // 在 status == .readyToPlay 的回调里 if shouldPlayWhenReady { player.play() shouldPlayWhenReady = false }第三个细节:快速连点“下一首”时,可能并发发出多次 replaceCurrentItem。这样会导致旧 item 还没就绪就被踢出,新 item 的状态观察时机也有点乱。我的习惯是加一个简单的切歌锁:在 isSwitchingTrack 期间忽略重复操作,等 seek 或状态回调结束再解锁。
除了这些,还有一个我常用的优化:提前创建下一首的 AVURLAsset。AVPlayerItem 的创建本身并不立刻下载数据,换成 AVURLAsset 去构造 item,可以提前把资源对象建立好,到真正播放时少一层等待。配合 URLSession 的分片预下载,切歌体验可以做到接近秒开,这个后面缓存策略部分再展开。
4. 进度、缓冲与播放状态机:把进度条做“真”了
4.1 PeriodicTimeObserver 的正确姿势
进度条是播放器的门面。AVPlayer 里最常用的是 addPeriodicTimeObserver,它按固定间隔回调当前播放时间:
timeObserverToken = player.addPeriodicTimeObserver( forInterval: CMTime(seconds: 1, preferredTimescale: 600), queue: .main ) { [weak self] time in self?.updateProgress(time: time) }需要注意 preferredTimescale:我习惯用 600,它是 24/25/30fps 视频帧率的公倍数,做音频时也能很好地兼容 seek 精度。如果你的 UI 只需要每秒更新一次,间隔设成 1 秒就够了;做歌词逐字滚动的话,我一般把间隔压到 0.1 秒。
还有一点:periodic time observer 的回调是从 player 当前时间派生的,seek 过程中它也会触发。所以代码里要有一个 isSeekInProgress 标志,在拖动进度条期间直接忽略时间回调,否则会出现“进度条往回跳一下”的抖动。
判断拖动是否结束,可以监听 AVPlayerItem 的 seek(to:toleranceBefore:toleranceAfter:completionHandler:) 这个 API 的 completion 回调。要注意这里 completion 里的 finished 只代表 seek 过程结束,不代表新增缓冲完成。
官方要求这个 observer 在不再需要时通过player.removeTimeObserver(token)移除,否则会持续回调。在切歌、播放器销毁的场景里尤其要注意,token 要持有好,避免泄漏。
4.2 缓冲进度怎么算:loadedTimeRanges 的合并逻辑
很多播放器的缓冲条是用一个粗略百分比画出来的,实际上顶层代码并不依赖什么神秘 API。AVPlayerItem 有一个 loadedTimeRanges 属性,表示已经加载到本地的媒体时间区间。它是一个数组,里面可能有多段 range,取最后一个 end 就是当前缓冲到的位置:
var bufferSeconds: Double { guard let range = item.loadedTimeRanges.last?.timeRangeValue else { return 0 } let start = CMTimeGetSeconds(range.start) let duration = CMTimeGetSeconds(range.duration) return start + duration }实测经验:网络流媒体下,loadedTimeRanges 通常会先出现一小段缓冲,然后随着播放推进持续扩展。缓冲条和播放进度条如果都基于同一计算方式,展现出来才是真正匹配的。有些播放器把缓冲条画到 100% 但实际 seek 到末尾还是卡住,多半是直接把 bufferTime 除以 duration 却没考虑 range 合并的问题。
另外,观察加载范围变化时,不要用时间回调里的当前时间判断,要监听 loadedTimeRanges 的 KVO 变化。否则缓冲条只会在每个整秒刷新一次,视觉上很钝。
4.3 timeControlStatus 三态与 loading 判断
AVPlayer 从 iOS 10 开始提供了 timeControlStatus,它有三种值:
| 状态 | 含义 | 常见场景 |
|---|---|---|
| .playing | 正在按正常速率播放 | 正常播放中 |
| .paused | 暂停 | 用户点了暂停、播放结束 |
| .waitingToPlayAtSpecifiedRate | 等待某个条件满足后继续播放 | 缓冲中、等待用户恢复播放 |
最需要区分的是 waitingToPlayAtSpecifiedRate,它经常和“用户暂停”混在一起。标准做法是结合 reasonForWaitingToPlay 进一步判断:
if player.timeControlStatus == .waitingToPlayAtSpecifiedRate { if player.reasonForWaitingToPlay == .toMinimizeStalls { // 正在等网络缓冲,显示 loading } else if player.reasonForWaitingToPlay == .waitingForCoordinatedAccess { // 等待其他协同播放器释放资源 } }我在实际项目里只用两种 UI 状态:播放中退出缓冲显示进度条,等待缓冲时显示菊花。判断标准就是 timeControlStatus == .waitingToPlayAtSpecifiedRate,并且 reason 为 .toMinimizeStalls。这里有个容易踩的点:切换歌曲、replaceCurrentItem 后的一小段时间,timeControlStatus 也会短暂处于 waiting 状态,如果 UI 在这个瞬间频繁闪现 loading,体验很差。我通常加一个超过 300ms 的延迟再显示 loading,小于这个时间的等待直接忽略。
4.4 seek 拖动与播放状态的联动
拖动进度条是最容易出“看似简单、实际细节一堆”的功能。标准流程是:
- 用户开始拖动,设置 isSeekInProgress = true,暂停进度刷新
- 拖动结束,计算目标时间,调用 player.seek
- seek 完成后,根据 seek 前的播放状态决定是否继续 play
let target = CMTime(seconds: targetSeconds, preferredTimescale: 600) item.seek(to: target, toleranceBefore: .zero, toleranceAfter: .zero) { [weak self] finished in guard let self = self else { return } self.isSeekInProgress = false if self.wasPlayingBeforeSeek { self.player.play() } }toleranceBefore/toleranceAfter 是什么含义?它允许系统在指定时间点前后的一定范围内找关键帧,降低 seek 成本。如果设成 .zero,表示精确 seek,虽然会更准,但开销更大。音频文件通常没那么大,设 .zero 是可以接受的;视频流媒体建议留一定容忍度。
seek 完成回调和 UI 的联动还有个细节:一些播放器在 seek 到未缓冲区域后,会立刻进入 waitingToPlayAtSpecifiedRate,这时如果 wasPlayingBeforeSeek 就 play,反而会让 timeControlStatus 反复横跳。稳妥的做法是:seek 后不要立即 play,而是让状态机自己决定——如果 item 已经 readyToPlay 且缓冲足够,再 play;否则等缓冲完成回调再恢复播放。
5. 后台播放与锁屏控制的接入细节
5.1 音频会话和后台模式:不是配置一个开关就行
音乐播放器和普通 App 最大的差别就是:用户锁屏、切到桌面之后,音频还得继续。这需要同时做两件事。
第一,音频会话设置成 playback 分类:
let session = AVAudioSession.sharedInstance() do { try session.setCategory(.playback, mode: .default) try session.setActive(true) } catch { // 会话设置失败,说明设备音频可能被占用,需要提示用户 }.playback 分类的含义是:即使设备处于静音模式或静音开关打开,也能出声。这个分类对音乐播放器是必需的,但对很多做短视频、音效类的 App 来说需要斟酌,因为可能不符合用户对静音的预期。
第二,在 Info.plist 里配置后台模式,把 audio 勾上。注意:这个后台模式是“资格性配置”,App 只有在确实播放音频时才能占用后台时间,系统会审核,不恰当使用会被拒绝或触发审核警告。配置里如果还带 UIBackgroundModes 里的其他类型,要谨慎,有的场景反而带来不必要的权限解释成本。
配置完成后还有一个小细节:在模拟器上验证后台播放经常失灵,建议直接上真机。我调试时经常发现模拟器切后台后音频中断,那不是代码问题,是模拟器行为。
5.2 nowPlayingInfo 与控制中心事件
锁屏控制中心显示的歌曲名、歌手、封面、进度条,全部来自 MPNowPlayingInfoCenter:
MPNowPlayingInfoCenter.default().nowPlayingInfo = [ MPMediaItemPropertyTitle: track.title, MPMediaItemPropertyArtist: track.artist, MPMediaItemPropertyAlbumTitle: track.album, MPMediaItemPropertyPlaybackDuration: duration, MPNowPlayingInfoPropertyElapsedPlaybackTime: currentTime, MPNowPlayingInfoPropertyPlaybackRate: 1.0 ]这里有一个很隐蔽的坑:如果 MPNowPlayingInfoPropertyPlaybackRate 没设置或者为 0,锁屏上的进度条会表现成“时间不走,但实际在播放”,用户点击锁屏暂停键后,进度条也纹丝不动。我在项目中是每个整秒更新时间的同时,用这个字段同步播放速率。
封面图要用 MPMediaItemArtwork 包装:
if let image = track.coverImage { nowPlayingInfo[MPMediaItemPropertyArtwork] = MPMediaItemArtwork( boundsSize: image.size ) { _ in image } }注意闭包返回的 UIImage 需要被持有,不能在闭包外作用域被释放,否则锁屏封面显示空白。我踩过一次,封面在 App 内显示正常、锁屏上却是纯色背景,排查了半天,定位到是 image 对象被提前释放。
控制中心的事件监听走 MPRemoteCommandCenter:
let center = MPRemoteCommandCenter.shared() center.playCommand.addTarget { [weak self] _ in self?.resume() return .success } center.pauseCommand.addTarget { [weak self] _ in self?.pauseForRemote() return .success } center.nextTrackCommand.addTarget { [weak self] _ in self?.nextTrack() return .success } center.changePlaybackPositionCommand.addTarget { [weak self] event in guard let event = event as? MPChangePlaybackPositionCommandEvent else { return .commandFailed } self?.seek(to: event.positionTime) return .success }使用建议是:在播放器激活时添加 target,播放器销毁时移除,避免重复注册导致一次点击触发两次动作。addTarget 返回的对象需要持有,用来后续 removeTarget。
5.3 切歌、封面、倍速的同步
切歌后锁屏信息必须同步更新,不然用户看到的是上一首的歌名,听着却是下一首的音频。我的做法是定义一个 updateNowPlayingInfo(track:) 方法,在 play(at:) 内部每次都调用,保证 replaceCurrentItem 和锁屏信息始终一一对应:
func play(at index: Int) { let track = tracks[index] // 省略 replaceCurrentItem 逻辑 updateNowPlayingInfo(track: track) }倍速播放也需要同步。AVPlayer 里可以直接设 rate = 1.5,此时锁屏上的播放速率如果还写 1.0,就会出现进度条和实际时长脱节。我的经验是:用户改变速率的瞬间,立即把 MPNowPlayingInfoPropertyPlaybackRate 更新为当前速率,并把 MPMediaItemPropertyPlaybackDuration 保持不变,控制中心的进度条会自动根据速率换算显示位置。
还有一个细节:如果播放速率是 0(比如暂停后暂停键已经按过),要把写入 nowPlayingInfo 的播放速率同步设成 0,控制中心的按钮状态才正确。
6. 实测中避不开的几个坑:KVO、中断、播放失败
6.1 status 变成 failed 后的恢复策略
在线音频最常见的失败场景是:网络断开、服务器返回非 200、资源过期。这些都会让 AVPlayerItem.status 变为 .failed。
我的处理原则是:不尝试对同一个 item 做恢复,而是直接丢弃旧 item,重新从 URL 构造新的 AVPlayerItem 再 replace 一次。试过对同一个 item 重新设置 properties 或者调用 play(),都没有用,因为 AVPlayerItem 内部的 error 状态已经锁死。
private func handlePlayFailed() { // 先解绑旧 item 的观察对象 readyObservation = nil bufferEmptyObservation = nil keepUpObservation = nil // 再重新创建 item 走正常流程 let newItem = AVPlayerItem(url: currentTrack.url) replace(item: newItem) observe(newItem) }这里有个体验优化:如果失败是因为网络抖动,我会先做一次轻量重试(最多一次),等 0.5 秒后重新创建 item;如果二次失败,再展示“网络不可用,请稍后重试”的提示。重试期间播放器 UI 要停在 loading 状态,而不是直接弹窗打断用户。
6.2 来电、插拔耳机、音频会话中断的处理
后台播放期间来电、耳机拔出、其他 App 抢占音频会话,都会触发 AVAudioSession 的中断通知。我通常会监听两个通知:
- AVAudioSession.interruptionNotification:处理来电和临时中断
- AVAudioSession.routeChangeNotification:处理耳机插拔
NotificationCenter.default.addObserver( self, selector: #selector(handleInterruption), name: AVAudioSession.interruptionNotification, object: AVAudioSession.sharedInstance() )中断处理的关键是区分 interruptionType 和 interruptionReason:
- 如果是 .began,暂停播放并记录当前状态
- 如果是 .ended,且 interruptionOptions 里没有 shouldResume,说明来电结束后系统不期望自动恢复,这时候不要自动 play;反之可以恢复
耳机拔出属于 routeChange,我一般是直接暂停,并在 UI 上提示用户已切换扬声器。这里很容易踩的坑是:耳机拔出时 AVAudioSession 会自动把播放线路切走,但 AVPlayer 的 timeControlStatus 可能仍然是 .playing,如果你不主动 pause,用户会直接听到扬声器外放,符合物理预期但不符合产品预期——大多数音乐 App 会选择暂停。
还有一个关于“电话结束后自动恢复”的细节:来电结束后回到歌单页面,App 可能处于后台,若 interruptionOptions 指示 shouldResume,需要等 AVAudioSession activate 完成后,再恢复播放状态,否则可能出现恢复失败、静默继续播放的情况。
6.3 播放位置记忆与缓存策略:我从第三版才开始做
前两个版本我都没有持久化播放位置,用户切走再回来只能从头听,体验很差。后来我加了一个简单的记忆策略:
- 在拖动结束、暂停、App 退后台这几个时机,把当前播放位置写入 UserDefaults
- 启动时读取,如果歌曲 URL 和位置都有效,就恢复播放
不建议在每个 periodic time observer 回调里写 UserDefaults,一秒一次会带来无谓的 IO 开销。我的做法是只在用户手动 seek、暂停、播放器停止这三个时机写入。这个思路也适用于“记播放历史”之类的功能。
缓存策略方面,AVPlayer 本身不会把整个音频文件存到本地,它只做字节级缓存。如果想要离线播放,要么自己写 AVAssetResourceLoaderDelegate 拦截请求并落地文件,要么通过后台下载任务先下载完整文件再切换本地 URL。后者实现更简单,但预先下载体验比较笨重,适合“会员可离线下载”的场景。目前我的生产项目里使用的是资源加载器做分片缓存,录完数据后切歌时先查本地是否有对应分片,效果接近秒开。这部分代码量比较大,等后面单独写一篇展开。
AVPlayer 的开发难点从来不在“让它响起来”,而在“让它稳定地响、可预期地响”。如果你从 AVAudioPlayer 直接跳到 AVPlayer,可能一两周内都处于“为什么这里又没声音”的困惑里,这时候不要怀疑是框架问题,先检查状态观察、音频会话、后台模式这三件套,80% 的问题都出在这三个地方。希望这篇能帮你少走我走过的弯路,让它成为你播放器工程化路上的一个稳定起点。