说起播放器,在 iOS 平台上就离不开 AVPlayer。什么?你说还有 AVAudioPlayer!用不了,根本用不了一点!在流媒体横行的时代,一个“只”能播放本地音频文件的播放器已经被后浪拍死在沙滩上了。撇开跨平台和一些不常见的视频格式不谈,使用 AVPlayer 作为音视频播放器应该是优先考虑的选择。开箱 AVPlayer,你会发现基于 KVO 那一套接口使用起来真的是难受和折磨,要是还想要先下后播或者边下边播这样的缓存功能,唯有带上痛苦面具一边撸代码一边累牛满面了。好在“万能”的 github 上有不少的开源项目可供研究,但未找到一个兼具易用、支持缓存(先下后播/边下边播)、音频会话管理和后台播放控制的轮子。即然无法做伸手党了,遂决定自个儿动手从头撸一个,期望能够对开源界做一点微不足道的贡献。搞砸了还望大伙轻喷,手动狗头保命~

需求分析

回顾用过的绝大多数媒体类 App,对于一个播放器来说,通常需要包含如下功能:

  1. 能够播放本地或者流媒体的音频或者视频 URL。
  2. 对于流媒体 URL 来说,有先下后播和边下边播两种缓存行为。
  3. 播放器支持播放、暂停、截图、进度拖动、倍速、后台播放控制和媒体信息展示等功能。
  4. 管理音频会话策略。(因为硬件资源是唯一的,为了能够正常的进行播放,建议每次播放前都设置一遍。)

接口设计

从功能集合上来讲,播放器提供的接口数量是一定的。如果它们只是简单的堆叠排列,对于刚接触它的用户来说使用体验就会变得糟糕,因为用户只能从文档注释来区分各个接口的作用。比如:哪些接口负责初始化,哪些接口可以用来控制播放器,哪些接口能获取播放器的状态等。遵循一切架构问题都可以加中间层这一教条,我们可以把这些接口根据特性划分成不同的协议,让用户面向协议来使用播放器。

用工厂方法接收必要构造条件

既然选择以面向协议的方式来定义接口,那么播放器就应该是不透明实例。用工厂方法来接收构造一个播放器的必须参数是不错的选择:

1
2
3
4
5
6
7
8
9

public protocol URLConvertible {
func asURL() throws -> URL
}

public enum ZonPlayer {
public static func player(_ url: URLConvertible) -> ZPSettable {}
}

无疑,url 是播放器唯一的必要参数。考虑到对 String、URLRequest 等可以转换为 URL 的支持,url 类型约束为 URLConvertible 协议而非 URL 类型。当转换失败时,通过错误回调告知使用方,在一些知名开源组件中我们会经常看到它的身影。

功能协议拆解

ZPSettable 是用于描述播放器所有可供设置的接口集合,它主要是对其他一系列协议共同约束的别名:

1
2
3
4
5
6
7
8
9
10

public protocol ZPSettable: ZPObservable,
ZPSessionSettable,
ZPRemoteControlSettable,
ZPCacheSettable {
var url: URLConvertible { get } // 主要为初始化工程内部使用
var maxRetryCount: Int { get nonmutating set }
var progressInterval: TimeInterval { get nonmutating set }
}

为了能在使用侧支持链式调用,还需要对协议进行一些扩展:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

extension ZPSettable {
/// Maximum retry time when an error occurred. The default value is 1.
public func maxRetryCount(_ maxRetryCount: Int) -> Self {
self.maxRetryCount = maxRetryCount
return self
}

/// The time interval for playback progress callback. The default value is 1.
public func progressInterval(_ progressInterval: TimeInterval) -> Self {
self.progressInterval = progressInterval
return self
}
}

这里的 ZPObservable 是播放器状态监听协议,借用 Kingfisher 中 Delegate 做法,将闭包捕获参数进行弱引用转换,定义为如下形式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

public protocol ZPObservable {
var callbackQueue: DispatchQueue { get nonmutating set }

var waitToPlay: ZPDelegate<(ZonPlayable, ZPWaitingReason), Void>? { get nonmutating set }
var play: ZPDelegate<(ZonPlayable, Float), Void>? { get nonmutating set }
var pause: ZPDelegate<ZonPlayable, Void>? { get nonmutating set }
var finish: ZPDelegate<(ZonPlayable, URL), Void>? { get nonmutating set }
var error: ZPDelegate<(ZonPlayable, ZonPlayer.Error), Void>? { get nonmutating set }
var progress: ZPDelegate<(ZonPlayable, TimeInterval, TimeInterval), Void>? { get nonmutating set }
var duration: ZPDelegate<(ZonPlayable, TimeInterval), Void>? { get nonmutating set }
var background: ZPDelegate<(ZonPlayable, Bool), Void>? { get nonmutating set }
var rate: ZPDelegate<(ZonPlayable, Float, Float), Void>? { get nonmutating set }
}

// 链式调用扩展
extension ZPObservable {
/// Which dispatch queue to callback, the default value is main queue.
public func callbackQueue(_ queue: DispatchQueue) -> Self {
self.callbackQueue = queue
return self
}
// ......
}

剩余的 ZPSessionSettableZPRemoteControlSettableZPCacheSettable 则分别描述音频会话、远程控制和缓存行为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36

public protocol ZPSessionable {
func apply() throws
}

public protocol ZPSessionSettable {
var session: ZPSessionable? { get nonmutating set }
}

/// The command of remote control.
public protocol ZPRemoteCommandable {
func enable()
func disable()
}

/// The remote control for player in lock screen or control center.
///
/// - Important: The time for playback is rounded.
public protocol ZPRemoteControllable {
var commands: [ZPRemoteCommandable] { get nonmutating set }

var title: String? { get nonmutating set }
var artist: String? { get nonmutating set }
var artwork: UIImage? { get nonmutating set }

var extraInfo: [String: Any]? { get nonmutating set }
}

public protocol ZPCacheable {
func prepare(url: URL, completion: @escaping (Result<AVURLAsset, ZonPlayer.Error>) -> Void)
}

public protocol ZPCacheSettable {
var cache: ZPCacheable? { get nonmutating set }
}

至于为什么播放器要主动管理音频会话与后台控制,后续在接口的实现章节会进一步说明。最后,为了确认使用侧已经完成播放器需要的所有设置,我们还得定义一个结束调用并返回一个播放器抽象实例:

1
2
3
4
5
6
7

extension ZPSettable {
public func activate() -> ZonPlayable {}

public func activate(in view: ZonPlayerView) -> ZonPlayable {}
}

其中 ZonPlayerView 是一个 UIView 的子类,用于展示视频图层。如果是播放音频,就调用 activate() 完成初始化即可。ZonPlayable 则是播放器支持的所有功能集合,就目前而言,它是 ZPControllable & ZPGettable 的别名,分别表示播放器的控制行为和可供读取的状态:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

public protocol ZPControllable {
func play()
func pause()
func seek(to time: TimeInterval, completion: ((Bool) -> Void)?)
func setRate(_ value: Float)

/// Take snapshot for video frame.
/// - Parameters:
/// - time: Specified time interval for video, will replace current time instead of nil.
/// - completion: Callback with optional image in main queue.
func takeSnapshot(at time: TimeInterval?, completion: @escaping (UIImage?) -> Void)

/// Let player can playback in background.
/// - Important: You should set supported audio session at first.
func enableBackgroundPlayback()
func disableBackgroundPlayback()
func suspendPlayingInfo()
func resumePlayingInfo()
}

public protocol ZPGettable {
var isPlaying: Bool { get }

/// The volume for player is greater than or equal to 0 and less than or equal to 1.
var volume: Float { get }
var rate: Float { get }
var currentTime: TimeInterval { get }
var duration: TimeInterval { get }
var url: URL { get }
}

总结

在面向协议编程的大前提下,我们先后定义了 ZonPlayer 一系列的接口协议。虽然 Swift Module 天生自带模块名为命名空间,但保险起见我们还是在协议面前加上了前缀,Apple 的风格亦是如此。虽然我个人更喜欢类型嵌套(后面会看到不少这样的使用),但无奈它不支持协议的定义。下篇文章我们将详细说明部分协议定义的用意,以及播放器实现过程中遇到的一些坑点,👋~