Ec0ce02e81ec74f27b77a08b9977f5f0
WWDC20 10655 - 了解如何下载并离线播放 HLS

本文基于 Session 10655 - Discover how to download and play HLS offline

背景

HTTP Live Streaming(也称为 HLS) 技术是苹果研发的一套基于 HTTP 的流媒体技术。HLS 简单、易扩展,且支持动态码率和 CDN 缓存,是非常理想的一对多直播协议。更多关于本次 WWDC 中 HLS 相关的 Session 可以参考 WWDC20 内参小专栏 中已发表的几篇文章:

WWDC 2016 中,苹果初次介绍了下载 HLS 并离线播放的特性。本 Session 将对离线 HLS 的下载和播放进行一次全面的介绍,包含离线 HLS 的使用场景、使用 URLSession下载、使用 AVFoundation 播放离线 HLS 以及通过 FairPlay 保护离线 HLS 等内容。

是否需要使用离线 HLS

在使用离线 HLS 之前,我们首先要了解离线 HLS 的使用场景,也就是你的用户希望主动下载媒体文件时,比如他们需要在飞机上观看、用户的流量十分充足或者使用 Wi-Fi 时希望下载视频并稍后观看。在类似这些的情况下,离线 HLS 的能力都是非常需要的。

HLS 下载

使用离线 HLS 的第一个关键是发起 HLS 的下载任务。下载任务代表了整个媒体下载过程的生命周期。在发起了下载任务后,你可以持续监控任务的状态,并根据需要在应用中更新下载进程。

HLS 的下载任务使用 URLSession 进行。我们需要注意的是,下载任务会根据当前设备可用资源进行合适的安排,例如网络连接较弱的情况下,网络请求会暂停并等待网络情况恢复良好后重新开始。另一个比较重要的是下载任务会自动重试,比如在请求超时之后,AVFoundation 会重新尝试下载。

首先,我们以一个典型的 HLS Asset 为例子来展示 HLS 下载的详细实现过程,这个 Asset 包含一系列视频、音频和字幕:

AVAssetDownloadTask

构建下载任务

我们的下载任务通常包含两个变量:AVAssetDownloadTask 和 ,创建一个 AVAssetDownloadTask 需要先通过 URLSession 来配置特定的 AVAssetDownloadURLSession。当创建好 AVAssetDownloadURLSession 后,调用 makeAssetDownloadTask 来构造下载任务。下面的代码展示了如何通过上述步骤创建一个 2 mbps 的电影下载任务:

let hlsAsset = AVURLAsset(url: hlsURL!)

let backgroundConfiguration = URLSessionConfiguration.background(withIdentifier: "backgroundConfigurationIdentifier")
let assetURLSession = AVAssetDownloadURLSession(configuration: backgroundConfiguration, assetDownloadDelegate: nil, delegateQueue: OperationQueue.main)

// Download a moview at 2 mbps
let assetDownloadTask = assetURLSession.makeAssetDownloadTask(asset: hlsAsset, assetTitle: "A Movie", assetArtworkData: nil, options: [AVAssetDownloadTaskMinimumRequiredMediaBitrateKey: 2000000])!
assetDownloadTask.resume()

需要留意 AVAssetDownloadTask 会自动选择媒体源,例如当前设备地区是法国,那么下载任务会自动选择法语音频和字幕媒体文件。

监控下载状态

当下载任务构造完成并开始下载后,我们可能需要实时监控下载进度和状态。这里我们可以使用 AVAssetDownloadDelegate 协议中的两个关键方法来进行监控:

public protocol AVAssetDownloadDelegate : URLSessionTaskDelegate {

    // 监听下载进度
    func urlSession(_ session: URLSession, assetDownloadTask: AVAssetDownloadTask, didLoad timeRange: CMTimeRange, totalTimeRangesLoaded loadedTimeRanges: [NSValue], timeRangeExpectedToLoad: CMTimeRange)

    // 监听下载完成状态
    func urlSession(_ session: URLSession, assetDownloadTask: AVAssetDownloadTask, didFinishDownloadingTo location: URL)
}

AVAssetDownloadDelegate 协议的接口可以帮助我们在应用中监听并展示下载进度,接下来我们构建一个简单的示例来展示如何实现监听下载进度并转换为百分比进度:

class MyAssetDownloadDelegate: NSObject, AVAssetDownloadDelegate {
    func urlSession(_ session: URLSession, assetDownloadTask: AVAssetDownloadTask, didLoad timeRange: CMTimeRange, totalTimeRangesLoaded loadedTimeRanges: [NSValue], timeRangeExpectedToLoad: CMTimeRange) {

        // loadedTimeRanges to CMTimeRanges
        var percentComplete = 0.0
        for value in loadedTimeRanges {
            let loadedTimeRange: CMTimeRange = value.timeRangeValue
            percentComplete += CMTimeGetSeconds(loadedTimeRange.duration) / CMTimeGetSeconds(timeRangeExpectedToLoad.duration)
        }

        percentComplete *= 100
        print("当前已完成:\(percentComplete)")
    }
}

AVAggregateAssetDownloadTask

当我们需要同时下载多种音频和字幕时,我们需要使用 AVAggregateAssetDownloadTask,这样我们就可以选择下载所需要的资源文件。在我们上面提到的例子(法语音频和字幕)中,通过 AVAggregateAssetDownloadTask 可以同时下载英语、西班牙语的音频和字幕,以及单独的法语字幕:

构建下载任务

首先,我们需要根据下载需求构造 AVMediaSelection 数组,具体实现中可以通过 AVMediaSelectionOptionhlsAsset.mediaSelectionGroup 来选择所需要的媒体资源:

let hlsAsset = AVURLAsset(url: hlsURL!)
let mediaSelections: [AVMediaSelection] = [] // 需要选择的媒体资源

guard hlsAsset.statusOfValue(forKey: "availableMediaCharacteristicsWithMediaSelectionOptions", error: nil) == AVKeyValueStatus.loaded else {
    return
}

let mediaCharacteristic = AVMediaCharacteristic.audible // 或者 AVMediaCharacteristic.legible
let mediaSelectionGroup = hlsAsset.mediaSelectionGroup(forMediaCharacteristic: mediaCharacteristic)

if let options = mediaSelectionGroup?.options {
    for option in options {
        if /* 根据需要判断 AVMediaSelectionOption 中的选项 */ {
            let mutableMediaSelection = hlsAsset.preferredMediaSelection.mutableCopy()
            mediaSelections.append(mutableMediaSelection)
        }
    }
}

在构造好 AVMediaSelection 数组后,我们可以通过此数组构造 AVAggregateAssetDownloadTask 并进行下载:

let aggDownloadTask = assetURLSession.aggregateAssetDownloadTask(with: hlsAsset,
                                                                 mediaSelections: mediaSelections,
                                                                 assetTitle: "My Movie",
                                                                 assetArtworkData: nil,
                                                                 options: [AVAssetDownloadTaskMinimumRequiredMediaBitrateKey: 2000000])!
aggDownloadTask.resume()

监控下载状态

由于 AVAggregateAssetDownloadTask 同时下载多个媒体资源,在对其进行下载状态监控时相对于普通的 AVAssetDownloadTask 会有所不同。我们首先要做的是对所有需要下载的资源进行加权,例如文件较大、下载时间较长的视频媒体可以分配 70% 权重,较小的音频媒体可以分配 20%,更小的字幕则只占 10%:

如果在上面的例子中应用此规则,那么我们可以得到所有需要下载的资源文件的权重:

同样,我们在代码实现中需要使用 AVAssetDownloadDelegate 协议,但在这种多个媒体资源同时下载的情况下,我们需要使用到与之前类似的两个接口,但这些接口会包含 AVMediaSelection 参数:

public protocol AVAssetDownloadDelegate : URLSessionTaskDelegate {

    // 监控下载进度
    func urlSession(_ session: URLSession, aggregateAssetDownloadTask: AVAggregateAssetDownloadTask, didLoad timeRange: CMTimeRange, totalTimeRangesLoaded loadedTimeRanges: [NSValue], timeRangeExpectedToLoad: CMTimeRange, for mediaSelection: AVMediaSelection)

    // 监听下载完成状态
    func urlSession(_ session: URLSession, aggregateAssetDownloadTask: AVAggregateAssetDownloadTask, didCompleteFor mediaSelection: AVMediaSelection)
}

在应用启动时恢复下载

在下载任务进行的时候,应用可能会退出到后台,当应用重新加载到前台时,我们需要重新开始正在进行的下载任务。在 AppDelegate 的代理方法中使用相同的 identifier 创建 URLSession 可以直接断点重启未完成的下载任务:

```swift
class MyAppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
let onfiguration = URLSessionConfiguration.background(withIdentifier: "assetDownloadConfigurationIdentifier")
let session = AVAssetDownloadURLSession(configuration: backgroundConfiguration, assetDownloadDelegate: nil, delegateQueue: OperationQueue.main)

top Created with Sketch.