⌈iOS⌋从头撸一个播放器 II —— 音频会话、远程控制和播放器实现
在上文中,我们完成了 ZonPlayer
的需求分析和接口设计,后者的核心思想是面向协议编程。使用者不用关心播放器具体是什么类型,而是通过 ZPSettable
完成播放器的初始化设置,并且通过遵循 ZonPlayable
的返回值进行播放器行为的控制和状态的读取。如果从权责分明的设计原则来看,ZPSettable
中包含的音频会话和远程控制设置(可选)似乎有违规范。事实上,为了简化播放器上下文的环境的构建,特别是解决官方 SDK 挖下的坑,如此设计是目前能想到的无奈之举。欲知细节如何,且看下文分解!
导致卡顿的音频会话
因为硬件资源是所有 App 共享的,所以为了确保按照预期的会话策略播放音频,我们需要在这之前完成相关的设置。相关代码其实很简单,大体如下:
1 | try AVAudioSession.sharedInstance().setCategory(.soloAmbient) |
如果你拥抱了 MetricKit或者有其他的监控卡顿的手段,不出意外的话你会发现在 setActive
和更改策略的时候(如果是 active 的话,会立即生效)出现了主线程卡顿。有意思的是在 Objective-C 关于 setActive
的文档中,Apple 早已经说明了会存在此问题:
Note that activating an audio session is a synchronous (blocking) operation.
Therefore, we recommend that applications not activate their session from a thread where a long
blocking operation will be problematic.
于是,关于设置音频会话的“最佳实践”得变为如下这种形式:
1 | aBackgroundQueue.async { |
如此这般,对于使用者来说体验是很糟糕的。为此,我们将 ZPSettable
的描述范围向外进行了扩展,包括了播放器依赖的上下文环境,而非只是其本身的设置。
挖坑的远程控制
参看 Apple 的架构设计,远程控制和 AVPlayer 隶属于两个不同的框架:MediaPlayer 和 AVFoundation。开发者使用 MPNowPlayingInfoCenter 展示正在播放的媒体信息,通过 MPRemoteCommandCenter 进行播放器的远程控制(控制中心和锁屏界面等):
1 | let info: [String: Any] = [ |
从自身的开发经历来看,当播放内容快速来回切换时,就可能大概率出现 MPNowPlayingInfoCenter 不更新的情况。(测试工程师反馈的问题,😅)最初百思不得其解,直到看到一篇 stack overflow 上的帖子才意识到 MPNowPlayingInfoCenter 本身不是线程安全的。如果想要规避多线程竞争问题,App 本身最好持有一个单例 info 字典,任何对远程控制中心信息的修改都映射到这上面,当完成修改后再进行 对 MPNowPlayingInfoCenter 的赋值。此外在 Swift 中,对字典的读写本身也是非线程安全,并且还会有崩溃的风险。考虑下面这段代码:
1 | var dict: [String: Double] = [:] |
在 iOS 环境下进行测试,上述代码出现了两种崩溃问题:
- EXC_BAD_ACCESS
- [XXXX objectForKey:] unrecognized selector sent to instance XXXXXX
翻看 Swift 源码,不难对其进行归因:Dictionary 底层通过 Variant 中的 _BridgeStorage<RawDictionaryStorage> 存储键值对,通过下标访问值时,在 iOS 运行环境下,会将 Variant 中的存储对象通过值传递(owned)转换为 CocoaDictionary,然后将其通过 unsafeBitCast 强制转换为 NSDictionary,然后返回 object(forKey:) 值。当通过下标设置值时(如果 Key 与 Value 非 Class),其 Variant 会被重新初始化,当存在多线程数据竞争时,其中某个线程就可能访问错误的地址。如果旧地址已经释放,则因为 EXC_BAD_ACCESS 崩溃。如果旧地址被重新分配,且无法响应 objectForKey 消息,则因为 [XXXX objectForKey:] unrecognized selector sent to instance XXXXXX。
再说控制命令,在 iOS 15.x.x 上(或许是某些特定设备,无法完全验证,🙇),使用 MPRemoteCommandCenter 所管理的命令且 App 处于后台播放时,调用 command.removeTarget
会导致媒体播放自动暂停即无法连续播放,而再次点击播放可以恢复。一个解决办法就是再次封装 command,由其内部通过 Target-Action 管理 command 行为。当不再需要 command 时,将其 isEnabled
设置为 false。
鉴于上述两个已知的关于远程控制的坑,ZonPlayer
将相关设置纳入到 ZPSettable
,即其中的 ZPRemoteControllable
(可选使用),以期能为使用者减少填坑的焦虑。
那些年我们一起撸过的 AVPlayer
按照既定框架,很容易实现一个遵循 ZonPlayable
的内部播放器:
1 | final class Player: NSObject {} |
从前序的设置来看,会存在一系列的异步行为,那么在播放器初始化完成之前如何响应 ZPGettable
和 ZPControllable
是需要优先考虑的问题。考虑到易用性和简化系统的复杂度,前者直接返回当前内部的 AVPlayer 的实际状态即可,后者则需要将行为暂时存储起来。当播放器状态变为 readyToPlay
时依次调用(其中 play
和 pause
是互斥行为,根据时序总是保留新值。),并且将 AVPlayer 赋值给视频图层容器视图,以避免出现在某些设备上出现不从头开始播放的诡异问题。
如果播放器的状态变为 failed
,则表明当前的 AVPlayer 已经无法进行播放了,需要重新创建 AVPlayer。ZPSettable
中的 maxRetryCount
就是为这种情况服务的,不过需要额外处理的是:当 AVPlayer 的 error code 为 -11819 时,意味着系统媒体服务被重置,在 App 回到前台并且收到 AVAudioSession.mediaServicesWereResetNotification
之前,AVPlayer 会一直处于无法播放的状态,即重试逻辑需要放在通知回调之后。
剩下的诸如播放控制、状态之类的就是 KVO 和通知监听等老套路了,这里就不再过多赘述。
总结
适配永远是大前端绕不开的话题,得益于开源社区的活跃,我们几乎总是有各种奇技淫巧去解决这些问题。到目前为止,除开缓存逻辑,ZonPlayer
已经具备我们所规划的功能。下篇文章我们将完成这最后一块拼图,而其中的 AVAssetResourceLoader 绝对是唯一的“主角”,👋~