上文中,我们完成了 ZonPlayer 的需求分析和接口设计,后者的核心思想是面向协议编程。使用者不用关心播放器具体是什么类型,而是通过 ZPSettable 完成播放器的初始化设置,并且通过遵循 ZonPlayable 的返回值进行播放器行为的控制和状态的读取。如果从权责分明的设计原则来看,ZPSettable 中包含的音频会话和远程控制设置(可选)似乎有违规范。事实上,为了简化播放器上下文的环境的构建,特别是解决官方 SDK 挖下的坑,如此设计是目前能想到的无奈之举。欲知细节如何,且看下文分解!

导致卡顿的音频会话

因为硬件资源是所有 App 共享的,所以为了确保按照预期的会话策略播放音频,我们需要在这之前完成相关的设置。相关代码其实很简单,大体如下:

1
2
try AVAudioSession.sharedInstance().setCategory(.soloAmbient)
try AVAudioSession.sharedInstance().setActive(true)

如果你拥抱了 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
2
3
4
5
6
7
8
9
10
11
12
13
14
aBackgroundQueue.async {
do {
try AVAudioSession.sharedInstance().setCategory(.soloAmbient)
try AVAudioSession.sharedInstance().setActive(true)

mainQueue.async {
// init player

// Handle player error
}
} catch {
// Handle audio session error
}
}

如此这般,对于使用者来说体验是很糟糕的。为此,我们将 ZPSettable 的描述范围向外进行了扩展,包括了播放器依赖的上下文环境,而非只是其本身的设置。

挖坑的远程控制

参看 Apple 的架构设计,远程控制和 AVPlayer 隶属于两个不同的框架:MediaPlayer 和 AVFoundation。开发者使用 MPNowPlayingInfoCenter 展示正在播放的媒体信息,通过 MPRemoteCommandCenter 进行播放器的远程控制(控制中心和锁屏界面等):

1
2
3
4
5
6
7
8
9
10
11
let info: [String: Any] = [
MPNowPlayingInfoPropertyPlaybackRate: 1.0,
MPMediaItemPropertyPlaybackDuration: 10,
// ......
]
MPNowPlayingInfoCenter.default().nowPlayingInfo = info
let playTarget = MPRemoteCommandCenter.shared().playCommand.addTarget { _ in
// Play
return .success
}
// ......

从自身的开发经历来看,当播放内容快速来回切换时,就可能大概率出现 MPNowPlayingInfoCenter 不更新的情况。(测试工程师反馈的问题,😅)最初百思不得其解,直到看到一篇 stack overflow 上的帖子才意识到 MPNowPlayingInfoCenter 本身不是线程安全的。如果想要规避多线程竞争问题,App 本身最好持有一个单例 info 字典,任何对远程控制中心信息的修改都映射到这上面,当完成修改后再进行 对 MPNowPlayingInfoCenter 的赋值。此外在 Swift 中,对字典的读写本身也是非线程安全,并且还会有崩溃的风险。考虑下面这段代码:

1
2
3
4
5
6
7
8
9
10
11
12
var dict: [String: Double] = [:]
let key = "ABCD"//A(name: "Key")
DispatchQueue.concurrentPerform(iterations: 2) { index in
if index % 2 == 0 {
print(dict[key] as Any)
print("get\(index)")
} else {
dict[key] = Double(index)
print("set\(index)")
}
}

在 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
2
3
4
final class Player: NSObject {}

extension Player: ZPGettable {}
extension Player: ZPControllable {}

从前序的设置来看,会存在一系列的异步行为,那么在播放器初始化完成之前如何响应 ZPGettableZPControllable 是需要优先考虑的问题。考虑到易用性和简化系统的复杂度,前者直接返回当前内部的 AVPlayer 的实际状态即可,后者则需要将行为暂时存储起来。当播放器状态变为 readyToPlay 时依次调用(其中 playpause 是互斥行为,根据时序总是保留新值。),并且将 AVPlayer 赋值给视频图层容器视图,以避免出现在某些设备上出现不从头开始播放的诡异问题。

如果播放器的状态变为 failed,则表明当前的 AVPlayer 已经无法进行播放了,需要重新创建 AVPlayer。ZPSettable 中的 maxRetryCount 就是为这种情况服务的,不过需要额外处理的是:当 AVPlayer 的 error code 为 -11819 时,意味着系统媒体服务被重置,在 App 回到前台并且收到 AVAudioSession.mediaServicesWereResetNotification 之前,AVPlayer 会一直处于无法播放的状态,即重试逻辑需要放在通知回调之后。

剩下的诸如播放控制、状态之类的就是 KVO 和通知监听等老套路了,这里就不再过多赘述。

总结

适配永远是大前端绕不开的话题,得益于开源社区的活跃,我们几乎总是有各种奇技淫巧去解决这些问题。到目前为止,除开缓存逻辑,ZonPlayer 已经具备我们所规划的功能。下篇文章我们将完成这最后一块拼图,而其中的 AVAssetResourceLoader 绝对是唯一的“主角”,👋~