多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

用AVPlayer从零设计iOS音乐播放器:从状态监听到锁屏控制

用AVPlayer从零设计iOS音乐播放器:从状态监听到锁屏控制 1. 整体设计思路解析为什么是AVPlayer做iOS音乐播放器绕不开一个选择题用AVPlayer、AVQueuePlayer还是AudioPlayer这类第三方封装我最初接触这个项目时第一反应也是直接找一个Star数比较高的开源库毕竟省事。但实际深挖之后就发现对于纯音乐播放这类场景AVPlayer是更接近底层、更可控的方案尤其是当你需要精细控制播放状态、缓存策略或者后续要加音效处理时第三方库反而会成为瓶颈。AVPlayer本身是苹果提供的流媒体播放器框架底层基于CoreMedia不像AVAudioPlayer那样一次性加载整个音频文件而是以流式方式边下边播。这个特性决定了它既能播放本地音频也能直接播放远程URL处理几百兆的音频文件也不会撑爆内存。对于音乐播放器这种需要长时间运行、频繁切换曲目的场景AVPlayer天然合适。另一个关键原因是AVPlayer对系统级功能的支持比较完整后台播放、锁屏控制、耳机线控、AirPlay投送这些能力都是通过系统框架直接对接的。用第三方库虽然起步快但很多系统级交互还是得自己写底层逻辑倒不如一开始就用AVPlayer把这条链路打通后续扩展成本反而低。我拿到这个项目时需求其实不复杂一个本地音乐播放器支持播放列表、上一曲下一曲、拖动进度、显示歌词和封面。核心痛点是播放列表切换时响应要快卡顿要少切后台不能断。基于这些诉求AVPlayer是唯一不需要引入重型依赖又能全部覆盖的选项。整篇文章我会按照从零搭建的顺序来讲从核心API认知、播放器封装、界面交互到后台播放和排查尽量把我实际操作中踩过的坑一并说清。2. 核心API认知先弄懂这几个类再动手2.1 AVPlayer、AVPlayerItem、AVURLAsset的关系刚开始用AVPlayer的人最容易搞混的就是这几个类各自负责什么。我自己第一次写的时候也绕了弯子这里用一个直白的类比来拆解AVURLAsset负责音频文件的资源解析可以理解为“拿到文件的原始身份信息”包括时长、码率、元数据这类基本信息。AVPlayerItem是对某一首歌曲播放过程的抽象负责管理“这首歌当前播到哪了、缓冲了多少、是否能播放”是整个播放流程中状态变化最密集的对象。AVPlayer本身是播放控制器负责调度播放、暂停、跳转同一时刻只能播放一个AVPlayerItem但可以反复替换Item来实现切歌。所以一条完整的数据链路是先由AVURLAsset加载音频资源得到基础信息然后基于Asset创建AVPlayerItem最后把这个Item交给AVPlayer去播放。代码如下let url URL(string: https://example.com/audio.mp3)! let asset AVURLAsset(url: url, options: [AVURLAssetPreferPreciseDurationAndTimingKey: true]) let item AVPlayerItem(asset: asset) let player AVPlayer(playerItem: item) player.play()这里有个细节options里那个key建议在需要精确时长的时候加上。不加的话某些VBR可变码率格式的音频时长会估算不准拖动进度条时会产生偏差差几秒到十几秒都遇到过。如果只是拿来试听一下不追求精确时长可以省略加载速度会稍快一些。2.2 状态监听KVO是播放器开发的命脉AVPlayer这套API最烦人的一点在于很多关键状态不是通过代理回调的而是要用KVO键值观察去监听。开发音乐播放器至少有三个状态必须盯紧首先是AVPlayerItem.status。这个字段反映了当前音频是否能够正常播放。它会经历unknown、readyToPlay、failed三个阶段。网络地址不可达、音频格式不支持、服务端返回403这类问题都会把status推向failed同时error字段里会带原因。这一步不处理好播放器失败时用户看到的就是无声无息的卡死。其次是AVPlayerItem.loadedTimeRanges。它表示已经缓冲进内存的时间范围也就是播放器“有多少余粮”。监听它一是可以给缓冲进度条提供数据二是判断卡顿原因——如果loadedTimeRanges一直长时间不前进多半是网络或服务端问题。再有就是AVPlayer.timeControlStatus。这是AVPlayer自己暴露的播放状态可能是playing、paused或者waitingToPlayAtSpecifiedRate。最后这个状态非常关键它表示播放器因缓冲不足在“等粮草”界面这时候应该显示加载动画。很多播放器卡住后UI没反应就是因为没监听这个状态。item.addObserver(self, forKeyPath: #keyPath(AVPlayerItem.status), options: [.new], context: nil) player.addObserver(self, forKeyPath: #keyPath(AVPlayer.timeControlStatus), options: [.new], context: nil)2.3 进度监听CMTime与addPeriodicTimeObserver音乐播放器必须实时显示当前播放进度这个需求对应的是addPeriodicTimeObserver。它允许你每隔固定间隔获取一次播放位置然后刷新UI上的进度条和时间标签。这里有一个容易写错的地方CMTimeGetSeconds拿到的播放位置是Double类型直接格式化显示即可但不要试图用Int截断后去和总时长做进度百分比会出现精度问题。正确做法是用浮点运算结束后再取整。let interval CMTime(value: 1, timescale: 2) // 每0.5秒回调一次 timeObserver player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { time in let current CMTimeGetSeconds(time) let total CMTimeGetSeconds(self.player.currentItem?.duration ?? .zero) if total.isFinite total 0 { let progress current / total self.slider.value Float(progress) self.currentTimeLabel.text self.formatTime(current) } }注意timescale的选择。音乐播放器UI不需要太高的刷新频率0.5秒完全够用。如果你用每秒60次的精度去刷新滑动进度条时会明显感到跟手性变差而且白白消耗电量。3. 播放器封装把核心逻辑收拢到一个类里3.1 为什么必须做封装很多新手写播放器习惯把AVPlayer直接扔在ViewController里这样写demo确实最快。但音乐播放器一旦涉及锁屏控制、播放列表切换、远程命令响应ViewController就会膨胀成上千行代码而且播放器的生命周期和页面生命周期耦合在一起页面pop之后音乐停了锁屏也控不了这种架构问题越到后面越难收拾。我建议用单例封装一个PlayerManager要么用class加static let要么用枚举实现单例。原因是播放器属于全局共享资源不管你在哪个页面都需要能访问到同一个播放实例。你从列表A进入详情页再从详情页切到列表B播放不能中断。单例生命周期跟App一致天然符合这个诉求。3.2 播放列表与索引管理一个合格的音乐播放器必然有“播放列表”这个概念。最简单的实现是维护一个[SongModel]数组和一个currentIndex切歌时通过索引从数组里取出新的模型然后替换AVPlayerItem。这里有一个关键的优化点不要在当前歌曲播完之后才去创建下一个AVPlayerItem而是提前用AVPlayerItem把相邻曲目准备好。切换时直接替换能极大减少切歌时的等待感。实际操作中可以维护三个Item当前播放的、下一首的、上一首的切歌后立即用新的AVPlayerItem补齐队列。本地音乐播放场景下这个优化带来的体感提升非常明显尤其是切歌频繁时。还有一点容易被忽视播放列表为空或越界时要优雅处理。我把这些边界逻辑都收敛在封装的内部对外只暴露play()、pause()、next()、previous()、seek(to:)这几个方法。调用方不需要关心内部状态出错时返回统一错误回调。3.3 对外暴露的状态模型封装类不能把KVO的监听细节暴露给界面层否则每个页面都要写一遍KVO逻辑。更好的做法是内部监听、内部转换然后通过闭包或代理对外广播统一的播放状态。比如定义这样一个枚举enum PlaybackState { case none case loading case playing case paused case failed(Error) }界面层只认这个枚举根据不同的状态切换UIloading时显示菊花playing时显示暂停按钮paused时显示播放按钮failed时弹出提示。这样页面逻辑就非常干净不需要关心底层到底是KVO来的状态还是缓冲超时判定的状态。4. 界面集成从列表到播放控制台4.1 列表页与播放器页的分工音乐播放器通常包含两个页面歌曲列表页和正在播放页。列表页负责展示曲目、当前播放状态标记播放页负责大封面、进度条、歌词和播放控制。我在这个项目里采用了一个轻量方案播放页作为列表页的push页面列表页通过PlayerManager单例获取当前播放状态用一张isPlaying的小图标标注当前正在播放的歌曲。点击列表项后设置播放源并push到播放页。这里要提醒一个每个iOS开发都会遇到的坑列表页重用cell时播放状态图标会错乱。因为cell是复用的不在屏幕内的cell状态会被新内容覆盖。我踩过之后总结出一个固定解法在cellForRowAt里每次都强制刷新播放状态动态计算当前行的模型是否等于PlayerManager.shared.currentModel等于就显示状态图标不等就隐藏。不要依赖cell内部自己保存的状态变量那样反复滑动列表一定会出问题。4.2 播放页的核心交互播放页最常见的布局是大封面、歌名、歌手、进度条和时间标签、控制按钮组。控制按钮组至少需要上一曲、播放暂停、下一曲这三个按钮。进度条我使用的是UISlider但默认的UISlider有两个体验问题。一是thumb太小不好点二是拖动时会持续触发valueChanged如果你在这个回调里实时seek播放器会频繁跳转造成卡顿甚至崩溃。正确做法是在touchDown时记录用户开始拖动valueChanged期间只更新UI时间标签不要seek直到touchUpInside或touchUpOutside时才把最终值映射到CMTime并调用seek。这样逻辑清晰也避免连续跳转。另一个容易被忽略的是边拖动边显示时间。手指滑动时进度条的时间标签应该跟随手指位置变化而不是等松手才变。将valueChanged事件里只做时间和滑块的刷新这个体验就很接近主流音乐播放器了。4.3 封面加载与缓存封面一般来自网络我喜欢用SDWebImage因为它成熟可靠。但音乐播放器有一个特殊要求锁屏界面上也要显示封面。这意味着封面数据不能只停留在UIImageView层面还要拿到实际图片数据传给系统。如果用SDWebImage可以在图片下载完成的回调里把UIImage转成Data再交给MPNowPlayingInfoCenter去更新锁屏封面。不过要注意如果你的图片源是大尺寸封面每次锁屏更新都传原图Data会占用大量内存。建议后台更新时使用压缩后的缩略图比如尺寸限制在800x800以内保证锁屏界面清晰度够了就好。5. 关键实现环节后台播放与锁屏控制5.1 音频会话配置这是最容易漏掉的一步AVPlayer默认情况下退到后台是会暂停的因为App没有声明后台音频模式。要让播放器在锁屏后继续响需要在工程配置和代码层做两件事。工程配置层面需要打开后台模式Capabilities里的Audio这一点很多人知道。但代码层面的AVAudioSession配置同样关键少写了同样不行。我见过有人只开了Capabilities、没写代码结果锁屏就断的案例。正确写法如下let session AVAudioSession.sharedInstance() do { try session.setCategory(.playback, mode: .default, options: []) try session.setActive(true) } catch { print(AudioSession 激活失败: \(error)) }这里category选择.playback表示App的音频播放优先级高于静音键和锁屏适合音乐播放器。如果你做的是通话或录音类App那就要用.playAndRecord但纯音乐播放器用.playback就够了。还有一点要提setActive的时机。最好在进入App时就激活而不是等到用户第一次点击播放才激活。因为隔了几分钟才激活会导致极短延迟虽然不明显但快速点击播放时偶尔会丢掉第一帧音频。5.2 锁屏控制MPRemoteCommandCenter的使用锁屏界面或控制中心的播放控制走的是MPRemoteCommandCenter。这套API说简单也简单说坑也坑。简单是因为只需要注册几个命令复杂在于回调处理不当会出现“按一下暂停却连续触发两次”这类问题。以播放暂停按钮为例注册方式如下let commandCenter MPRemoteCommandCenter.shared() commandCenter.playCommand.addTarget { [weak self] event in guard let self self else { return .commandFailed } self.resume() return .success } commandCenter.pauseCommand.addTarget { [weak self] event in guard let self self else { return .commandFailed } self.pause() return .success }注意block里的返回值是.commandFailed还是要.success的直接决定了系统是否知道这个命令被成功处理。我之前遇到过一版代码回调里播放逻辑执行了但返回了.commandFailed结果锁屏按钮按一下没反应、再按一次才走一次体验非常奇怪。还有一点previousTrackCommand和nextTrackCommand在耳机线控里也对应着双击和连按三下的操作。音乐播放器里这两个命令一定要注册否则线控切歌是无效的。5.3 锁屏信息更新锁屏界面的歌曲信息通过MPNowPlayingInfoCenter.default()设置。它是一个字典包含标题、艺人、封面、时长、当前时间、播放速率等字段。let infoCenter MPNowPlayingInfoCenter.default() var nowPlayingInfo [String: Any]() nowPlayingInfo[MPMediaItemPropertyTitle] song.name nowPlayingInfo[MPMediaItemPropertyArtist] song.artist nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] duration nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] currentTime nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] isPlaying ? 1.0 : 0.0 infoCenter.nowPlayingInfo nowPlayingInfo这里有个细节MPNowPlayingInfoPropertyElapsedPlaybackTime要在暂停和播放切换时及时更新否则锁屏上的进度条会保持最后一帧不变甚至显示错误位置。另外普通播放状态下不需要更新封面以外的字段过度频繁地刷新锁屏信息会增加系统开销。5.4 耳机插拔与来电中断处理除了锁屏音乐播放器还要处理耳机插拔、来电打断这类音频会话事件。收到AVAudioSession.interruptionNotification时要判断是began暂停还是ended恢复。来电属于中断的一种如果App不处理就会出现电话挂了之后音乐自己乱播的情况。我处理的策略是中断开始时暂停播放并记录“应该恢复”中断结束后延迟0.5秒判断“是否仍在播放列表顶部且用户未手动暂停”满足条件才自动恢复播放。这里不能无条件恢复因为有些用户可能希望在接完电话之后不继续放歌。6. 进阶优化缓存、断网重连与体验细节6.1 网络音乐缓存的两种思路如果项目涉及远程音乐URL缓存策略必不可少。最简单的方案是依赖AVPlayer自带的资源缓存。AVPlayer默认会缓存一部分数据到临时目录但这份缓存跟在系统级App无法直接控制也不会持久保存。更可控的方案是使用AVAssetResourceLoaderDelegate做自定义代理。这是AVPlayer最强大的扩展点之一你可以拦截播放器每一次网络请求把数据同时写入本地文件下次播放同一个URL时代理检测到本地已有缓存直接返回本地数据。很多网上的缓存库都是基于这个机制做的。但这个方案有复杂度实现时需要处理请求头Range、206分段响应、数据拼接等问题不适合在入门阶段引入。我建议如果只是想做一个功能完整的播放器先用系统默认策略顶住等到确定需要节省流量或提升二次播放体验时再研究资源代理。毕竟播放器功能的主线是播放体验不是缓存。6.2 播放失败判定与重试网络音乐播放最常见的三个失败场景404、403、网络超时。404和403会在status回调里直接变成failed比较直观。网络超时则不同它表现为长时间缓冲但status仍然是readyToPlaytimeControlStatus一直是waitingToPlayAtSpecifiedRate。对于超时建议用一个Timer做个软超时机制超过15秒还在等待缓冲就主动停止播放并报错提示。这个机制一开始可能没人跟你说但不加的话遇到弱网时播放器就一直转圈用户只能手动杀掉App非常伤体验。6.3 切歌时避免卡顿切歌卡顿的元凶通常是前一首歌的播放器还在收尾新歌的AVPlayerItem刚开始初始化资源加载和UI刷新都挤在同一帧。我优化后的流程是先player.replaceCurrentItem(with: newItem)这一句是原子操作系统会快速中断当前播放并装载新资源随后立刻把UI切到loading状态等新的playerItem状态变成readyToPlay再播放。整个过程对用户来说是干净的“点击下一曲——瞬间变loading——新歌响起”。注意到中间不要调用play()太早否则会出现“点击切歌后先听到旧歌零点几秒再变新歌”的怪异现象。7. 常见问题与排查技巧实录7.1 锁屏界面显示不了歌曲信息排查顺序从易到难首先是MPNowPlayingInfoCenter需要在音频播放中设置才能生效如果你在申请远程控制之前就设置信息系统可能丢弃。其次是检查AVAudioSession是否激活成功锁屏界面的信息展示依赖音频会话通道。再次是确认UIApplication.shared.beginReceivingRemoteControlEvents()是否调用。7.2 退后台就暂停或者一会儿之后断掉这个问题十有八九是Capabilities里的后台音频模式没开启或者开启的不是audio而是其他模式。检查路径项目Target - Signing Capabilities - Background Modes - 勾选Audio, AirPlay, and Picture in Picture。如果模式勾了还断再看AVAudioSession的激活状态用session.isOtherAudioPlaying看看是不是被其他App抢占。有些设备连接了蓝牙音箱但蓝牙模块异常系统会认为“有其他音频在播放”从而阻断你的播放请求。7.3 进度条拖动无反应或跳回原位置拖动后跳回原位置大概率是你设置新进度之后被周期性监听回调覆盖了。监听回调会根据当前播放器时间不断刷新slider值如果你seek之后slider还停留在旧值看起来就像“拖不动”。解决方法是把“用户是否正在拖动”作为一个标志位在valueChanged期间暂停UI的进度刷新拖完松手后再恢复。这个标志位是进度条实现里最重要的细节之一没有它很多细微问题都会冒出来。7.4 切歌之后会自动播放上一首尾音这是AVPlayer的replaceCurrentItem之后前一首歌的缓冲数据被残留了下来。虽然没有直接代码能“清缓存”但可以通过在替换item前把player暂停并把rate置为0加一句player.removeTimeObserver清理监听可以减少这类幽灵音频的出现概率。另外如果你的列表数据里混用了不同的音频码率切换时偶尔会有极短的沙沙声这个时候在替换之前延迟300毫秒再执行切歌逻辑能有效规避。7.5 播放器在iOS 17上状态丢失有段时间我发现同一套代码在更高版本系统上偶发“播放了但没有声音”检查下来是AVPlayer实例在App退到后台后被系统回收了。解决办法是把player实例从局部变量提升为播放器单例的强引用属性并且确保在App进入前台时检测timeControlStatus如果发现是paused且currentItem ! nil可以主动恢复播放。8. 最后的实操心得整体做下来我最大的体会是AVPlayer并非一个开箱即用的播放器组件它更像一套需要自己组装的生产工具。你用它的成本在于状态监听分散、细节门槛多但收益是你完全可以掌控播放链路的每一个环节。对于音乐播放器来说这种掌控力太重要了。如果你准备动手做自己的播放器我建议先把本地播放链路做一个最小闭环从列表选歌、切歌、拖动进度到锁屏展示和控制再到后台播放。这个闭环跑通了再想网络流播放、歌词解析、音效均衡这些进阶功能心里就有底了。AVPlayer的边界摸清楚之后你会发现音乐播放器的复杂度其实远没有第一眼看上去那么高。
返回列表