资讯详情

AVPlayer 构建音乐播放器实战:从选型到后台播放

📅 2026/10/16 3:54:18 | 华诺云谱 👁 阅读
AVPlayer 构建音乐播放器实战:从选型到后台播放
做音乐播放器这几年AVPlayer 几乎成了我每次技术选型时绕不开的选项。它不像 AVAudioPlayer 那样一条 API 就能出声但正因为它的能力边界足够宽才让“歌单、进度条、缓冲、切歌、锁屏控制”这一整套需求都有了统一答案。这篇文章不是 API 文档翻译我会从真实开发顺序出发讲清楚基于 AVPlayer 构建音乐播放器时最关键的设计决策为什么用 AVPlayer、如何组织播放核心、怎么做切歌、怎么把缓冲和进度条做“真”以及后台播放和线上踩坑的处理方式。无论你是刚接触 iOS 音频开发还是已经在用 AVPlayer 但经常被各种奇怪问题卡住这篇都适合当一份“从零搭到可用”的实战笔记。1. 为什么市面上的播放器引擎大多绕不开 AVPlayer1.1 从一次音频选型失误说起第一版播放器 Demo我图省事选了 AVPlayer 家族里的 AVAudioPlayer。本地文件播放确实顺手到了联调阶段换成线上 URL问题立刻暴露AVAudioPlayer 需要先把音频文件完整加载到内存才开始播放一首几十 MB 的歌曲用户可能要等十几秒期间 UI 没有任何反馈。后来我才理解AVAudioPlayer 的本质是“一次性把整段音频交给播放器”它对内存和启动时间的要求都很高适合短音频、音效、语音留言这类体量可控的场景而 AVPlayer 基于流式架构内部按需加载媒体数据可以边下边播天然适配在线音乐。一句话总结如果你的播放器只需要播放一段已经完整存在的音频AVAudioPlayer 够用只要涉及远程地址、混音、多个播放实例、精确 seek、自定义渲染音效直接上 AVPlayer。1.2 AVPlayer 和 AVAudioPlayer 的真实分工下面这个对比表我用下来觉得最直观对比项AVPlayerAVAudioPlayer播放来源本地/远程 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 后再真正调 playfunc play(url: URL) { let item AVPlayerItem(url: url) player.replaceCurrentItem(with: item) observe(item) }2.2 不观察状态等于没有播放逻辑AVPlayerItem 的 status 是我见过的、最容易被新手忽略的播放器核心逻辑。它在现实中的反馈通常是用户点了播放按钮播放器 UI 一直“假装在播放”但声音和进度纹丝不动也没报错。正确做法是同时观察这几个关键路径status判断能否播放 readyToPlay / failed / unknownplaybackBufferEmpty缓冲不够进入 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.seekseek 完成后根据 seek 前的播放状态决定是否继续 playlet 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 与控制中心事件锁屏控制中心显示的歌曲名、歌手、封面、进度条全部来自 MPNowPlayingInfoCenterMPNowPlayingInfoCenter.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 对象被提前释放。控制中心的事件监听走 MPRemoteCommandCenterlet 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% 的问题都出在这三个地方。希望这篇能帮你少走我走过的弯路让它成为你播放器工程化路上的一个稳定起点。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑