在 AVFoundation 之上构建自定义播放器

1,056 阅读6分钟

在构建具有视频播放功能的 iOS/tvOS 应用程序时,最好的播放器解决方案可以是构建一个自定义的播放器来匹配确切的应用程序要求。本文是在 AVFoundation 之上构建自定义播放器的基本要点的技术概述。

这个想法是AVPlayer用作播放引擎,利用其低效率和稳定性。

关键点

要创造出色的玩家体验,重要的几点是:

  • 为播放器提供最佳流
  • 观察球员
  • 管理 DRM
  • 显示控件

为播放器提供最佳流

AVPlayer通过 接收其流AVPlayerItem。为确保无缝的用户体验,应尽快完成项目准备工作。Apple 发布了一个关于此的WWDC 视频。

在提供AVPlayerItemto 之前AVPlayer,可以做一些准备工作:

  • 管理 DRM
  • 寻找正确的播放位置
  • 选择音频和字幕语言

管理 DRM

在这里,我们谈论的是Fairplay DRM,它是AVPlayer.

当 HLS 流受到 Fairplay 保护时,HLS 播放列表拥有一个SESSION_KEY带有 URI的标签,此信息由项目收集,以让应用程序和操作系统加载密钥以对流进行解码。要获取许可证,我们需要将 an 设置AVAssetResourceLoaderDelegateAVPlayerItem's AVURLAsset。委托在AVURLAsset资源加载(在我们的例子中是许可证)上创建一个钩子,让应用程序在播放时获取许可证。在挂钩期间,应用程序需要:

  • 获取申请证书
  • 使用AVAssetResourceLoadingRequest'sstreamingContentKeyRequestData方法生成请求数据
  • 向应用服务器询问请求数据的内容密钥
  • 要求AVAssetResourceLoadingRequest用内容键响应
  • 关闭钩子
// mark - AVAssetResourceLoaderDelegate

func resourceLoader(_ resourceLoader: AVAssetResourceLoader,
                    shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest) -> Bool {
    guard loadingRequest.isContentKeyRelated else return { false }
    loadContentKey(for: loadingRequest) { result in
        switch result {
        case let .failure(error):
            loadingRequest.finishLoading(with: error)            
        case let .success(data):
            loadingRequest.dataRequest?.respond(with: data)
            loadingRequest.finishLoading()            
        } 
    }
    return true
}

// mark - Private

private func loadContentKey(for loadingRequest: AVAssetResourceLoadingRequest,
                            completion: (Result<Data, Error>) -> Void) {
    let uriData = loadingRequest.uriData
    getApplicationCertificate() { certificate in
        let challengeData = try loadingRequest.streamingContentKeyRequestData(forApp: certificate, contentIdentifier: uriData, options: nil)
        self.fetchContentKey(with: challengeData, uriData: uriData, completion: completion)
    }
}

也可以在播放前预取许可证,而不是在播放时预取许可证;我们不会谈论预取,但机制基本相同。预取密钥是一种提升用户体验的好方法,它避免了在播放开始之前的内容密钥提取、零点几秒的长度、任务。

寻找正确的播放位置

要在用户请求的位置开始播放,想法是使用AVPlayerItem seekTo方法。这些方法前后都需要时间容差,应使用容差参数以通过利用流编码来允许更快的查找。

AVPlayer用引用其时间CMTime类型。如果流是基于时间的,则任何非实时流都应该是这种情况,很自然地从TimeInterval表示流中位置的a 执行查找,从 0 开始。然后从TimeIntervalto的转换CMTime非常简单。

另一方面,对于实时流,很自然地浏览带有日期的回放,因为它代表一个正在进行的事件。为此,HLS 流带有一个EXT-X-PROGRAM-DATE-TIME标签,允许播放器将日期转换为自己的CMTime参考。

最后的建议:如果项目的状态不是 ,则AVPlayerItemseekToDate方法不会完成readyToPlay

选择音频和字幕语言

播放器可以允许用户选择语言和字幕。在播放开始时自动应用用户首选项也可能是一个有趣的功能。为了确保最佳性能,这甚至可以在播放之前完成。为此,我们的想法是异步加载AVPlayerItem资产mediaSelectionOptions并在可用选项中选择适当的选项。

extension AVPlayerItem {

    func ad_select(languageTag: String, subtitlesTag: String, completion: () -> Void) {
        ad_loadAssetContent { [weak self] in
            self?.ad_selectOption(tagged: languageTag, for: .audible)
            self?.ad_selectOption(tagged: subtitlesTag, for: .audible)
        }
    }

    // MARK: - Private

    private func ad_loadAssetContent(with completion: @escaping () -> Void) {
        let selector = #selector(getter: AVAsset.availableMediaCharacteristicsWithMediaSelectionOptions)
        let selectorString = NSStringFromSelector(selector)
        asset.loadValuesAsynchronously(forKeys: [selectorString], completionHandler: completion)
    }

    private func ad_selectOption(tagged optionTag: String, for mediaCharacteristic: AVMediaCharacteristic) {
        guard
            let option = asset
                .mediaSelectionGroup(forMediaCharacteristic: mediaCharacteristic)?
                .options
                .filter({ $0.extendedLanguageTag == optionTag })
                .first else {
            return
        }
        ad_select(mediaSelectionOption: option, for: mediaCharacteristic)
    }

    private func ad_select(mediaSelectionOption: AVMediaSelectionOption, for mediaCharacteristic: AVMediaCharacteristic) {
        guard let group = asset.mediaSelectionGroup(forMediaCharacteristic: mediaCharacteristic) else { return }
        select(mediaSelectionOption, in: group)
    }
}

观察

一旦流在屏幕上播放,应用程序肯定需要有关播放的反馈,首先显示相关控件,并可能监视播放器性能和用户活动。

定期观察

播放之后很重要,首先,更新控件传输栏,这可以通过定期时间观察器来完成AVPlayer

observer = avPlayer.addPeriodicTimeObserver(
    forInterval: CMTime(seconds: 1, preferredTimescale: CMTimeScale(NSEC_PER_SEC)),
    queue: DispatchQueue.main
) {  _ in
    // Compute the current player state and provide it to the controls
}

事件观察

当播放器很好地播放视频而没有其他任何事情发生时,定期观察是完美的。但是视频播放充满了伏击。

大多数事件来自AVPlayerItem,并且可以通过 KVO 捕获。loadedTimeRanges, isPlaybackBufferEmpty, isPlaybackLikelyToKeepUp,isPlaybackBufferFullseekableTimeRanges有助于了解播放器缓冲区的工作方式。status是必不可少的,因为它定义了播放器是否能够获取内容;这通常是发生播放启动错误的地方。

avPlayer.replaceCurrentItem(with: playerItem)
observer = playerItem.observe(\.status) { [weak self] (item, _) in
    switch item.status {
    case .readyToPlay:
        self?.avPlayer.play()
    case .failed:
        // handle item.error
    default:
        break
}

一些事件AVPlayer也很重要。rate, 连接到控件中的播放/暂停按钮,或externalPlaybackActive激活 Airplay。

NotificationCenter发出有趣的事件,其中applicationWillResignActive/ applicationDidBecomeActive,或AudioSessionRouteChange; 注册这些通知将使播放器在应用程序进入后台时暂停和恢复,或者在 Airplay 启动/停止时更新 UI。

显示控件

准备好流并且播放器正常播放后,最后一步是在播放器视图顶部显示控件。为了使控件在播放时保持最新,一个好的解决方案是将所有播放信息收集到一个属性对象中,在每次播放更改时发布此对象,并注册负责显示这些更改的控件的视图控制器。然后很容易将每个 UI 元素连接到其相关信息。

结论

这些强制性步骤是构建自定义播放器的良好基础;他们处理播放中最重要的部分。但是,可能需要一些额外的工作才能提供出色的用户体验。在可能的额外功能中,根据项目的具体情况,可能有监控播放器性能、提供高级控制(例如平移关闭手势)甚至支持 Airplay 或 Google Chromecast。

文末推荐:iOS热门文集

面试基础

iOS面试基础知识 (一)

iOS面试基础知识 (二)

iOS面试基础知识 (三)

iOS面试基础知识 (四)

iOS面试基础知识 (五)

知识详解

iOS面试要点之GCD面试要点

iOS面试要点之多线程面试要点

iOS面试要点之block面试要点

iOS面试要点之Runtime面试要点

iOS面试要点之RunLoop面试要点

iOS面试要点之内存管理面试要点

iOS面试要点之MVC、MVVM面试要点

iOS面试要点之网络性能优化要点

iOS面试要点之网络编程面试要点

iOS面试要点之KVC&KVO面试要点

iOS面试要点之数据存储面试要点

iOS面试要点之混编技术面试要点

iOS面试要点之设计模式面试要点

iOS面试要点之UI面试要点