
做iOS音频开发这些年我陆陆续续接触过不少播放器相关的需求从在线电台到本地音乐播放核心引擎绝大多数都绕不开AVPlayer。很多人第一次接触它会因为AVPlayer的“自由度过高”而感觉无从下手——它不像AVAudioPlayer那样开箱即用也没有现成的播放界面所有状态管理、界面绑定、音频会话配置都得自己来。但恰恰是这种自由度让它成为了搭建真正可定制型音乐播放器的最佳选择。这篇文章不是API文档翻译而是我基于实际项目经验整理的一份完整实践记录。内容会覆盖AVPlayer的选型理由、核心对象关系、状态监听、时间观察、后台播放、锁屏控制、远程指令处理以及我在开发中踩过的一些坑。如果你正准备用AVPlayer开发一款音乐播放器或者正在播放器项目中挣扎于各种状态同步问题这篇笔记应该能帮你省下不少排查时间。1. 整体设计思路为什么是 AVPlayer 而不是 AVAudioPlayer1.1 音乐播放器的核心需求拆解一个能被用户接受的音乐播放器起码要满足以下几点稳定播放本地文件和远程流媒体支持常见压缩格式播放过程不卡顿、不崩溃精确控制播放进度包括暂停、继续、拖动seek、上一首下一首后台播放锁屏后继续出声并能通过控制中心或耳机按键操作展示锁屏封面、标题、歌手、进度信息与系统播放界面联动网络状态变化、音频中断、耳机插拔等异常场景下不产生异常表现。你会发现除了本地文件播放流媒体支持、后台控制、锁屏联动这三项已经远超AVAudioPlayer的能力范围。AVAudioPlayer更多是为简单音频播放设计的它对资源时长的控制、对远程URL的支持、对系统级播放控制协议的集成都比较吃力。而AVPlayer直接构建在Core Media之上底层能力强一大截并且MediaPlayer框架的MPNowPlayingInfoCenter和MPRemoteCommandCenter就是为搭配AVPlayer这类底层播放器设计的。1.2 AVPlayer 与两个容易混淆的替代方案在iOS生态里做音频播放主要有三个选择AVAudioPlayer、AVPlayer、AVPlayerViewController。AVAudioPlayer适合“最小可用”场景比如播放一段音效、一个短语音开发简单但你很快会撞到它的天花板它一次只帮你管一个音频资源远程URL的缓冲状态不能精确感知你还要手动管理很多生命周期细节。如果产品需求从“播放一首歌”长成“播放一个歌单”AVAudioPlayer会拖慢你的迭代速度。AVPlayerViewController则更多用于视频播放自带UI、上下栏、控制面板打包得很完整。但对音乐播放器来说我们大部分时候不需要视频渲染层要的是自定义的播放界面和交互。用AVPlayerViewController等于带着一套重的UI框架做音乐播放改造成本极大。所以最终选型落在AVPlayer上层次清晰它只管“播放”这件事的核心能力UI完全由你控制流媒体、本地文件都能处理对HLS、渐进式下载都有不错支持内置精确的时间控制API方便实现进度条拖拽、倍速、跳转能无缝接入系统锁屏和远程控制体系。在我个人的实践里我的标准做法是项目里只面向AVPlayer和AVPlayerItem编程对外提供封装好的播放器核心类。上层SwiftUI或UIKit界面只跟这个封装类打交道不直接触碰AVPlayer API后续改需求、换引擎都容易一些。2. 核心知识拆解AVPlayer 系列对象与播放状态管理2.1 三个核心对象的关系使用AVPlayer搭建播放器时你每天都跟这几个对象打交道AVPlayer播放器控制器负责播放、暂停、进度跳转、倍速、音量等核心动作AVPlayerItem代表“一个可播放的资源”它内部负责加载track、组织播放状态管理缓冲进度AVAsset / AVURLAsset底层资源对象提供时长、元数据、轨道信息等能力。一个典型的构造流程是这样let url URL(string: https://xx.com/audio.mp3)! let asset AVURLAsset(url: url) let playerItem AVPlayerItem(asset: asset) let player AVPlayer(playerItem: playerItem)我刚开始接触这段代码时以为越简单越好直接用AVPlayer(url: url)完事。实际项目里这么做会有问题当你需要处理播放失败、缓冲进度、资源时长时没有playerItem你很难拿到精确状态。所以现在我的做法永远是先构造playerItem再用item去初始化player。2.2 加载状态监听KVO 是关键中的关键AVPlayerItem的很多属性和状态并不是通过代理回调给你的而是通过KVOKey-Value Observing机制通知。你不监听就等于瞎着开车。开发中最常监听的状态有这些监听的KeyPath作用注意点status标识这个item是否可播放只有拿到.readyToPlay才能安全调用playtimeControlStatus播放器当前是暂停还是在播放区分手动暂停和因缓冲导致的卡顿loadedTimeRanges已缓冲范围用于实现缓冲进度条playbackBufferEmpty是否处于无数据状态通常意味着开始显示loadingplaybackLikelyToKeepUp数据流速能否跟上播放可以用于隐藏loading和自动恢复播放presentationSize资源尺寸主要针对视频音频场景不关心currentItem播放器当前item切换歌曲时用上面表格里最容易让人困惑的是status和timeControlStatus的区别。一句话说清楚status管的是“资源本身能不能放”timeControlStatus管的是“播放器这个机器现在转没转”。举个例子一首歌已经加载完成status为.readyToPlay但网络拥堵导致数据不够了这时候播放器会自动暂停timeControlStatus可能短暂变为.waitingToPlayAtSpecifiedRate。如果我只监听status会错误地认为“一切正常只是用户暂停了”。所以双状态都要监听一个负责识别资源错误一个负责识别播放过程中自然产生的卡顿。实际代码层面我在封装类里会统一注册多个KVO观察者然后再集中处理状态变化playerItem.addObserver(self, forKeyPath: #keyPath(AVPlayerItem.status), options: [.new, .initial], context: nil) playerItem.addObserver(self, forKeyPath: #keyPath(AVPlayerItem.playbackBufferEmpty), options: [.new], context: nil) playerItem.addObserver(self, forKeyPath: #keyPath(AVPlayerItem.playbackLikelyToKeepUp), options: [.new], context: nil) playerItem.addObserver(self, forKeyPath: #keyPath(AVPlayerItem.loadedTimeRanges), options: [.new], context: nil) player.addObserver(self, forKeyPath: #keyPath(AVPlayer.timeControlStatus), options: [.new], context: nil)然后在observeValue(forKeyPath:of:change:context:)里统一做分发。有时代码会比较长但逻辑一定要控制在一个地方越集中越好排查。2.3 播放进度获取addPeriodicTimeObserver 的正确用法进度条是音乐播放器的标配功能想知道“当前播放到第几秒”常用的方案有两种Timer和addPeriodicTimeObserver。Timer的问题在于你无法精确知道每次回调发生时播放头到底走到了哪而且如果Timer的queue阻塞回调间隔会乱掉。addPeriodicTimeObserver是由播放器底层按照播放时间节奏回调的当播放暂停时它基本也会停止触发这对UI更新来说非常合理。一个标准的用法timeObserverToken player.addPeriodicTimeObserver(forInterval: CMTime(value: 1, timescale: 1), queue: .main) { [weak self] time in guard let duration self?.player.currentItem?.duration.seconds, duration.isFinite else { return } let current time.seconds self?.updateProgress(current: current, duration: duration) }注意两个细节CMTime(value: 1, timescale: 1)表示每秒回调一次。如果你希望进度条更顺滑可以改成value: 1, timescale: 10即每0.1秒回调一次UI表现为连续滑动闭包回调线程要和UI所在线程保持一致。常见做法是直接传.main。如果你做了转线程处理切记回调过程中所有UI赋值都要在主线程。还有一点非常容易被忽略用完后需要removeTimeObserver并且存的token引用不能丢。我早期因为漏掉这一步页面pop后闭包依然被调用导致崩溃排查了很久才发现是观察者没移除干净。2.4 时长与元数据CMTime 的处理细节AVPlayer体系下时间概念用CMTime表达它由分子、分母构成支持高精度的时间运算。你需要经常做几类转换从CMTime获取秒数time.seconds从秒数构造CMTimeCMTime(seconds: seconds, preferredTimescale: 600)判断时长是否有效duration.isFinite网络资源没加载完时duration可能不是有效值我在做播放器的时候总时长显示总在踩坑本地文件几乎秒出但远程音频一开始duration是NaN。解决思路是等status变成.readyToPlay后再读取时长并配合KVO监听duration的变化。不要在资源加载前就急于展示总时长。3. 实操构建一个可用的播放器核心3.1 建立一个播放器管理与状态容器在实际项目中我会把播放器包装成一个单例或者依赖注入的服务类PlayerService。这里以不负责任何UI逻辑的核心层为例final class PlayerService: NSObject { static let shared PlayerService() private var player: AVPlayer? private var playerItem: AVPlayerItem? private var timeObserverToken: Any? var currentItem: AVPlayerItem? { player?.currentItem } func play(url: URL) { let asset AVURLAsset(url: url) let item AVPlayerItem(asset: asset) // 先移除旧观察、旧token removeObservers() player?.replaceCurrentItem(with: item) if player nil { player AVPlayer(playerItem: item) addTimeObserver() } playerItem item addObservers(to: item) } func play() { player?.play() } func pause() { player?.pause() } func seek(to time: TimeInterval, completion: escaping (Bool) - Void) { guard let player player else { return } player.seek(to: CMTime(seconds: time, preferredTimescale: 600), toleranceBefore: .zero, toleranceAfter: .zero) { finished in completion(finished) } } }这里用了replaceCurrentItem(with:)而不是重新创建player好处是不用反复创建播放器对象观察者管理更集中切换歌曲时也不会闪断或重新释放音频会话。3.2 歌曲切换与状态同步真实播放器的复杂点不在“播放”在“切换”。当我从A歌切到B歌时需要处理这些东西移除A歌item上的KVO观察移除旧的周期性时间观察者重置正在显示的当前时间、总时长、缓冲进度isPlaying状态要和播放器真实状态对齐封面图、歌词等元数据需要根据B歌重新加载。如果状态没有集中同步用户界面就很容易出现“进度条还在走但实际已经换歌”“暂停键显示不对”等奇怪现象。我在工程里定义了一个PlayerState结构体struct PlayerState { var trackId: String? var isPlaying: Bool false var currentTime: TimeInterval 0 var duration: TimeInterval 0 var bufferProgress: Float 0 var isLoading: Bool false }所有UI层都监听这个结构体的变化而不是直接读AVPlayer状态。每次KVO回调进来我更新state并发布通知/绑定到UI这样任何一层都不会出现“半个状态”。3.3 播放器的错误处理与失败恢复网络音频最大的特点是不可控。一个远程mp3链接随时可能超时、断流、返回403。正常情况下playerItem的status会从.unknown变为.failed此时playerItem.error会有具体错误。我的处理逻辑是监听status .failed时弹提示或记录日志根据错误码判断是否需要重试重试需要重新构造playerItem不能直接在原有item上调play对网络错误做一些退避处理比如最多重试两次避免死循环。另外还要考虑一种特殊情况一首歌播放到一半网络断开播放器进入waitingToPlayAtSpecifiedRate模式此时UI应该展示loading状态而不是把用户推出去。等playbackLikelyToKeepUp返回true再自动恢复播放。4. 后台播放、锁屏信息与远程控制4.1 音频会话配置播放器通向系统音频的门票要让AVPlayer在锁屏后继续播放第一步不是改代码逻辑而是配置AVAudioSession。不配置的话App切到后台音频就会断掉。标准配置一般放在App启动时let audioSession AVAudioSession.sharedInstance() do { try audioSession.setCategory(.playback, mode: .default, options: []) try audioSession.setActive(true) } catch { print(Audio session configure failed: \(error)) }这里要特别注意setCategory的参数是有讲究的。音乐播放器应该设置为.playback表示“后台也允许播放”。如果你的App同时需要支持蓝牙耳机可能还需要考虑.allowBluetooth等选项。很多人用.playback就行但如果你做的是有声书、播客类产品可能会需要mode: .spokenAudio来适配系统倍速功能。4.2 锁屏和控制中心用远程命令让用户能切歌暂停配置完后台播放后下一步是让锁屏界面和控制中心能控制我们的播放器。这主要依赖MPRemoteCommandCenter。常用的远程命令包括播放、暂停、下一首、上一首、改变播放位置。代码示例let commandCenter MPRemoteCommandCenter.shared() commandCenter.playCommand.addTarget { [weak self] _ in self?.play() return .success } commandCenter.pauseCommand.addTarget { [weak self] _ in self?.pause() return .success } commandCenter.nextTrackCommand.addTarget { [weak self] _ in self?.playNext() return .success } commandCenter.changePlaybackPositionCommand.addTarget { [weak self] event in guard let event event as? MPChangePlaybackPositionCommandEvent else { return .commandFailed } self?.seek(to: event.positionTime) return .success }这里有一个容易被忽略的坑MPRemoteCommandCenter是会“记住”target的。如果你在多个页面或多次init时反复addTarget会出现一次点击触发多次回调的情况。解决办法是在声明时使用addTarget返回的 handler 对象并在不需要时removeTarget或者保证这个CommandCenter只有一处初始化代码。4.3 锁屏信息展示没有封面图和进度条的锁屏等于半成品锁屏界面最能体现一个播放器的完成度。封面上滑、歌曲信息、播放进度这些数据来自MPNowPlayingInfoCenter。最简单也最容易忽略的关键点你必须定期更新nowPlayingInfo里的播放进度系统才可能在锁屏上展示播放位置。很多人只更新一次结果锁屏上的进度永远卡在000。我一般是这样处理的在addPeriodicTimeObserver的回调里顺带更新锁屏信息。var nowPlayingInfo [String: Any]() nowPlayingInfo[MPMediaItemPropertyTitle] 歌曲标题 nowPlayingInfo[MPMediaItemPropertyArtist] 歌手名 nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] duration nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] currentTime nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] isPlaying ? 1.0 : 0.0 MPNowPlayingInfoCenter.default().nowPlayingInfo nowPlayingInfo还有一个关键细节播放速率一定要正确设置。如果你暂停了但rate还是1.0锁屏界面就会显示“播放中”但进度不动这种体验很糟糕。暂停时把MPNowPlayingInfoPropertyPlaybackRate设为0.0。封面图通过MPMediaItemArtwork设置if let artworkImage UIImage(named: cover) { let artwork MPMediaItemArtwork(boundsSize: artworkImage.size) { _ in return artworkImage } nowPlayingInfo[MPMediaItemPropertyArtwork] artwork }注意封面图不要太大锁屏上几百像素足够过大图片会导致更新时有轻微卡顿。5. 常见问题与排查技巧实录5.1 播放器常见的坑与对应解法我在维护播放器过程中整理了一张排查频率很高的对照表现象常见原因解决思路点击播放没声音音频会话没配置好或Category错误检查AVAudioSession的setCategory与setActive切后台就断音工程配置里未开启后台ModesCapabilities中勾选AudioAirPlay and Picture in Picture如果是纯音频选Background Modes下的Audio锁屏没有控制按钮MPRemoteCommandCenter没有注册检查远程命令的addTarget是否执行锁屏封面不显示nowPlayingInfo里没有Artwork或尺寸异常确认MPMediaItemPropertyArtwork存在且图片能正常生成进度条总在跳但是缓冲一直跟不上网络原因导致buffer不足监听timeControlStatus展示loading同时可降低码率或改用HLS歌曲切换后旧歌还在播放replaceCurrentItem(pending)之后没有调用play注意replace之后要重新触发playseek后进度条位置和自己想的不一致没有设置toleranceBefore/After建议把tolerance都设为.zero保证精确跳转耳机拔掉后外放不响扬声器被系统切换到了听筒通道在音频中断通知里重新设置audio session然后恢复播放播放静态缓冲时自动暂停不会恢复依赖AVPlayer自动恢复而没做重试监听playbackLikelyToKeepUp恢复播放锁屏界面不更新时间nowPlayingInfo只设置一次在periodic时间回调时刷新ElapsedPlaybackTime5.2 关于耳机插拔和来电中断的必读经验音乐播放器跑久了一定会遇到系统级的音频打断。比如来电、闹钟、Siri介入这些都会触发AVAudioSession的中断通知。我在项目中会注册AVAudioSession.interruptionNotification并做两层处理中断开始时记录当前播放状态和播放位置中断结束时根据用户是否希望继续播放来恢复。如果中断期间用户没有主动暂停通常都会在结束后自动恢复播放。耳机插拔也属于音频路由变化监听AVAudioSession.routeChangeNotification。我选的策略是拔掉耳机立刻暂停插上耳机根据之前的暂停状态决定是否恢复。这里具体产品怎么定取决于你们的产品逻辑但无论如何都要处理“拔耳机后有声音外放”的场景否则在公共场合很容易造成尴尬。5.3 内存管理与观察者移除的小技巧长时间运行的播放器一个很容易被忽视的点是观察者的生命周期。我的原则是谁添加观察者谁负责移除。在封装类内部我会集中注册、集中移除并且移除时用??确保不重复移除。每次替换playerItem时第一件事就是把旧item上的观察全部去掉再挂新item的观察。timeObserver的管理也类似移除时要传入当时保存的token否则会抛异常。这里我习惯用invalidate()类似的统一清理方法private func cleanUp() { if let timeObserverToken timeObserverToken { player?.removeTimeObserver(timeObserverToken) self.timeObserverToken nil } if let playerItem playerItem { playerItem.cancelPendingSeeks() playerItem.removeObserver(self, forKeyPath: #keyPath(AVPlayerItem.status)) playerItem.removeObserver(self, forKeyPath: #keyPath(AVPlayerItem.playbackBufferEmpty)) playerItem.removeObserver(self, forKeyPath: #keyPath(AVPlayerItem.playbackLikelyToKeepUp)) playerItem.removeObserver(self, forKeyPath: #keyPath(AVPlayerItem.loadedTimeRanges)) self.playerItem nil } }我还碰到过一个很隐蔽的问题如果observeValue没有对未知的keyPath做super调用系统在某些情况下会抛NSInternalInconsistencyException。所以我在统一的KVO回调方法开头一定会加上对context的判断不属于自己注册的context就交给super处理。5.4 网络资源加载彻底失败时怎么给用户一个体面的反馈远程音频的一个头疼问题就是加载失败。尤其用户网络不好时一个loading转圈卡住比直接报错更难受。我自己的经验是提前设一个加载超时判断从开始加载到status变为readyToPlay超过一定时间或者期间playbackBufferEmpty长时间为true就可以认为加载卡死给用户一个“加载失败点击重试”的操作入口。重试逻辑一定要新建一个playerItem重新走加载流程不要复用失败状态的旧item。另外403和404错误往往不是播放器本身的问题而是服务器或鉴权问题。如果你用的是带签名的播放地址签名的过期也会触发播放失败这种情况需要在url构建阶段就处理好失效时间。收尾的几点实在话做了几个播放器项目后我最大的感受是AVPlayer本身并不难难的是和系统音频生态的联动。音频会话、远程控制中心、锁屏信息、后台模式这些模块单独看都不复杂但它们像齿轮一样咬合在一起任何一环状态不对用户体感就会很怪。建议你在开发播放器时先把状态同步的框架打好再谈UI美化因为很多bug都源自“界面的状态和引擎的实际状态不一致”。最后分享一个小细节开发调试时可以给AVPlayer单独打开日志输出观察timeControlStatus的变化。你会发现所有播放卡顿、自动暂停、恢复播放的行为都清晰记录在案排查问题会轻松很多。希望这篇笔记能帮大家少走一些弯路。