01
DRM 介绍
DRM,即数字版权管理(Digital Rights Management),是指使用加密技术保护视频内容、通过专业技术安全地存储和传输密钥(加密密钥和解密密钥)、并允许内容生产商设置商业规则,限制内容观看者的一种系统。
1.1 DRM 工作流程
DRM使用对称加密算法(Symmetric-key algorithms)对视频内容进行加密,对称加密算法使用同一把密钥加密和解密;
首先,通过密钥(通常为AES-128)将内容加密,然后传输给客户端。这把密钥由专用服务器提供,安全可靠;
当客户端想要播放加密视频,就要向DRM服务器发送请求获取解密密钥;
服务器会对客户端进行鉴权,如果客户端通过鉴权,服务器就会将解密密钥和许可规则发送给它;
在收到解密密钥后,客户端使用被称为CDM(Content Decryption Module,内容解密模块)的安全软件解密,并解码视频,然后将其安全地发送给屏幕。
1.2 DRM 的几种方案
常见的 DRM 方案有下面几种,其中在 Apple 平台上,使用 FairPlay 方案:

FairPlay 支持的协议:

我们采用的是 HLS + fmp4 的方案。
FairPlay 支持的平台和系统要求:

FairPlay 播放 DRM 视频的流程
用户点击播放按钮后,传递一个
.m3u8
播放地址给到 AVPlayer;播放器下载解析
m3u8
清单文件,发现#EXT-X-KEY
,表明这是一个被加密的视频;向系统请求 SPC 信息;
向后台请求 CKC 信息。秘钥服务器会使用收到的 SPC 中的相应信息查找内容秘钥,将其放入 CKC 返回给客户端;
AVFoundation 收到 CKC 信息后,使用其中的密钥解密、解码视频,继续完成后续播放流程。
名词解释
SPC (Secure Playback Context),译为服务器播放上下文。里面存放的是加密后的密钥请求信息(encrypted key request);
CKC (Content Key Context),译为内容密钥上下文。里面存放的是加密后的密钥响应信息(encrypted key response),包含用于解密的密钥,以及该密钥的有效期;
KSM (Key Security Module),译为密钥安全模块,属于后端的模块;
CDM (Content Decryptio Module) 译为内容解密模块,属于客户端负责解密视频的模块,使用 AVPlayer 播放视频并正确提供给系统 CKC 信息后,由 AVFoundation 内部自动完成。
.m3u8
清单文件中的 EXT-X-KEY
标签示例:
#EXTM3U
#EXT-X-PLAYLIST-TYPE:VOD
#EXT-X-KEY:METHOD=SAMPLE-AES,URI="skd://12341234123412341234123412341234?iv=12341234123412341234123412341234"
#EXTINF:10.0,
seg-1.m4s
...
#EXTINF:2.0,
seg-35.m4s
#EXT-X-ENDLIST
下面是一张从 FPS 官方文档中裁出来的一张时序图:

1.3 Tip1:Apple 平台上 HLS 的 fmp4 分片的 TAG 应为 hvc1
hev1
和 hvc1
是两种 codec tag,表示 mp4 容器中 hevc 流的不同打包方式。Quicktime Player 和 iOS 不支持 hev1
tag 的 mp4(见 https://developer.apple.com/av-foundation/HEVC-Video-with-Alpha-Interoperability-Profile.pdf page 3 最后一句话:The codec type shall be ‘hvc1’.)。
如果使用 AVPlayer 播放 tag 是 hev1
的 MP4 视频,表现会是有声音无画面。
02
管理密钥的两种方式
上面一节说过,播放 FairPlay 视频需要把正确的解密密钥拿到,才能播放 FairPlay 视频,否则会出现播放失败或者播放绿屏等异常情况。

Apple 提供了两种方式来管理 FairPlay 的密钥。
使用
AVAssetResourceLoader
使用
AVContentKeySession
2.1 方式一:使用 AVAssetResourceLoader 管理秘钥
这种方式播放视频,只能在用户点击播放后,播放流程过程中去请求密钥。

具体的使用方式如下:
通过
[self.urlAsset resourceLoader]
获取AVAssetResourceLoader
对象,并设置代理[[self.urlAsset resourceLoader] setDelegate:loaderDelegate queue:globalNotificationQueue()];
;创建一个实现
AVAssetResourceLoaderDelegate
的类,实现其中的resourceLoader: shouldWaitForRenewalOfRequestedResource:
方法;向 iOS 系统请求 SPC 信息
向服务端请求 CKC 信息
开始播放流程
[player replaceCurrentItemWithPlayerItem:newItem]
。
SofaAssetLoaderDelegate *loaderDelegate = [[SofaAssetLoaderDelegate alloc] init];
loaderDelegate.fpCerData = [self fpCerData];
loaderDelegate.fpRedemptionUrl = fpRedemption;
loaderDelegate.asset = self.urlAsset;
[[self.urlAsset resourceLoader] setDelegate:loaderDelegate queue:globalNotificationQueue()];[self.urlAsset loadValuesAsynchronouslyForKeys:requestedKeys completionHandler:^{dispatch_async( dispatch_get_main_queue(), ^{AVPlayerItem *newItem = [AVPlayerItem playerItemWithAsset:weakSelf.urlAsset automaticallyLoadedAssetKeys:keys];[weakSelf.player replaceCurrentItemWithPlayerItem:newItem];});
}]; @interface SofaAssetLoaderDelegate()<AVAssetResourceLoaderDelegate>
@end
@implementation SofaAssetLoaderDelegate- (BOOL)resourceLoader:(AVAssetResourceLoader *)resourceLoader shouldWaitForRenewalOfRequestedResource:(AVAssetResourceRenewalRequest *)renewalRequest
{return [self resourceLoader:resourceLoader shouldWaitForLoadingOfRequestedResource:renewalRequest];
}
@end- (BOOL)resourceLoader:(AVAssetResourceLoader *)resourceLoader shouldWaitForLoadingOfRequestedResource:(AVAssetResourceLoadingRequest *)loadingRequest
{AVAssetResourceLoadingDataRequest *dataRequest = loadingRequest.dataRequest;NSURL *url = loadingRequest.request.URL;NSError *error = nil;BOOL handled = NO;if (![[url scheme] isEqual:URL_SCHEME_NAME]) {returnNO;}NSLog( @"shouldWaitForLoadingOfURLRequest got %@", loadingRequest);NSString *assetStr;NSData *assetId;NSData *requestBytes;assetStr = [url host];assetId = [NSData dataWithBytes: [assetStr cStringUsingEncoding:NSUTF8StringEncoding] length:[assetStr lengthOfBytesUsingEncoding:NSUTF8StringEncoding]];NSLog( @"contentId: %@", assetStr);NSData *certificate = self.fpCerData;// 向 iOS 系统请求获取 SPC 信息requestBytes = [loadingRequest streamingContentKeyRequestDataForApp:certificatecontentIdentifier:assetIdoptions:nilerror:&error];NSData *responseData = nil; // 将获取到的 SPC 发送给服务器,请求 CKC 信息responseData = [SofaAVContentKeyManager getlicenseWithSpcData:requestBytescontentIdentifierHost:assetStrleaseExpiryDuration:&expiryDurationfpRedemptionUrl:self.fpRedemptionUrlerror:&error];//Content Key Context (CKC) message from key server to applicationif (responseData != nil) {// Provide the CKC message (containing the CK) to the loading request.[dataRequest respondWithData:responseData];[loadingRequest finishLoading];} else {[loadingRequest finishLoadingWithError:error];}handled = YES;return handled;
}// 向 KSM 后台请求密钥
+ (NSData *)getlicenseWithSpcData:(NSData *)requestBytes contentIdentifierHost:(NSString *)assetStr leaseExpiryDuration:(NSTimeInterval *)expiryDuration fpRedemptionUrl:(NSString *)fpRedemptionUrl error:(NSError **)errorOut
{int64_t req_start_tick = SOFA_CURRENT_TIMESTAMP_MS;LOGI(TAG, "going to send payload to URL %s, timestamp: %lld", [fpRedemptionUrl cStringUsingEncoding:NSUTF8StringEncoding], req_start_tick);LOGI(TAG, "payload length = %lu", (unsignedlong)requestBytes.length);NSLog( @"payload : %@", requestBytes);NSRange range = {152, 20};NSData* cert_hash = [requestBytes subdataWithRange:range];NSLog( @"cert hash : %@", cert_hash);NSURL *url = [NSURL URLWithString:fpRedemptionUrl];NSMutableURLRequest *postRequest = [NSMutableURLRequest requestWithURL:url];[postRequest setHTTPMethod:@"POST"];[postRequest setValue:@"application/octet-stream" forHTTPHeaderField:@"Content-Type"];NSString *contentLength = [NSString stringWithFormat:@"%lu", (unsignedlong)[requestBytes length]];[postRequest setValue:contentLength forHTTPHeaderField:@"Content-Length"];[postRequest setHTTPBody:requestBytes];// Send the HTTP POST requestNSURLResponse* response = nil;NSData* data = [NSURLConnection sendSynchronousRequest:postRequest returningResponse:&response error:errorOut];int64_t req_end_tick = SOFA_CURRENT_TIMESTAMP_MS;LOGI(TAG, "request ckc elapsed %lld, error %d", req_end_tick - req_start_tick, (*errorOut)?1:0);return data;
}
上述代码,请求 SPC 信息时候,入参 certificate
就是在苹果开发者后台下载下来的证书文件:

2.2 方式二:使用 AVContentKeySession
苹果还有第二种管理密钥的方式 AVContentKeySession
,这个 API 是于 2017 年首次公布的,相比 AVAssetResourceLoader
,它可以更好的管理秘钥,并且和视频播放过程进行解耦。
开发者可以根据用户的行为,提前下载请求即将要播放的视频的密钥信息,以加快视频的起播速度(苹果官方称之为 prewraming)。
AVContentKeySession
还支持播放离线下来的 FairPlay 视频(这个我们后面的内容中会提到)。

AVContentKeySession
的使用方法简单介绍
1. 创建 Session 并设置代理:
// 创建 session
// 用户 AVContentKeySessionDelegate 代理方法回调的线程
_keyQueue = dispatch_queue_create("com.sohuvideo.contentkeyqueue", DISPATCH_QUEUE_SERIAL);
self.keySession = [AVContentKeySession contentKeySessionWithKeySystem:AVContentKeySystemFairPlayStreaming];
[self.keySession setDelegate:self queue:_keyQueue];
2. 现代理方法:
#pragma mark AVContentKeySessionDelegate
// 两种情况下会被调用:
// 1. 开发者调用了函数 -processContentKeyRequestWithIdentifier:initializationData:options: 会触发此回调。 这种情况出现在 prewarming 视频播放或下载 FairPlay 视频请求 Persistable ContentKey 的时候
// 2. 已经调用 [self.keySession addContentKeyRecipient:urlSession] ,然后正常播放 urlSession 的时候,会自动触发此回调。
- (void)contentKeySession:(nonnullAVContentKeySession *)session didProvideContentKeyRequest:(nonnullAVContentKeyRequest *)keyRequest {// 调用 [keyRequest makeStreamingContentKeyRequestDataForApp:contentIdentifier:options:completionHandler] 获取 spc// 请求后台,获取 CKC// 调用 [keyRequest processContentKeyResponseError:error] OR [keyRequest processContentKeyResponse:keyResponse] 结束密钥管理流程
}// 调用 -renewExpiringResponseDataForContentKeyRequest 会触发此回调
- (void)contentKeySession:(AVContentKeySession *)session didProvideRenewingContentKeyRequest:(AVContentKeyRequest *)keyRequest {
}// 请求 persistable content key 时候,开发者调用 respondByRequestingPersistableContentKeyRequest 函数,会触发此回调
- (void)contentKeySession:(AVContentKeySession *)session didProvidePersistableContentKeyRequest:(AVPersistableContentKeyRequest *)keyRequest {
}// 存放在本地的 persistable content key 被使用后,这个方法可能会自动触发,这时候需更新存放在本地的 content key 数据。 (存放期 content key 更新为播放期 content key)
- (void)contentKeySession:(AVContentKeySession *)session didUpdatePersistableContentKey:(NSData *)persistableContentKey forContentKeyIdentifier:(id)keyIdentifier {
}// 请求 content key 失败回调
- (void)contentKeySession:(AVContentKeySession *)session contentKeyRequest:(AVContentKeyRequest *)keyRequest didFailWithError:(NSError *)err {
}
3. 添加 URLAsset 到 Session:
[self.keySession addContentKeyRecipient:recipient];
三个使用场景
下面分三个场景来具体介绍 AVContentKeySession 的使用
场景一:无需 prewarming,用户点击播放按钮后,使用 AVContentKeySession 管理 key request
这个场景,类似使用 AVAssetResourceLoader
,都是在用户点击按钮后才去请求 FairPlay 视频的解密秘钥,视频的首帧指标会比较大。
// 添加 urlAsset 到 session
[self.keySession addContentKeyRecipient:recipient];// 使用 AVPlayer 播放 asset
NSURL *assetUrl = [NSURL URLWithString:dataSource.path];
self.urlAsset = (AVURLAsset *)[AVAsset assetWithURL:assetUrl];;NSArray *requestedKeys = @[@"playable"];
[self.urlAsset loadValuesAsynchronouslyForKeys:requestedKeys completionHandler: ^{dispatch_async( dispatch_get_main_queue(), ^{AVPlayerItem *newItem = [AVPlayerItem playerItemWithAsset:weakSelf.urlAsset automaticallyLoadedAssetKeys:keys];[weakSelf.player replaceCurrentItemWithPlayerItem:newItem];});
}];// 调用 replaceCurrentItemWithPlayerItem: 开始播放后,session delegate 的回调方法 contentKeySession:didProvideContentKeyRequest: 会自动触发
- (void)contentKeySession:(nonnullAVContentKeySession *)session didProvideContentKeyRequest:(nonnullAVContentKeyRequest *)keyRequest {[self handleStreamingContentKeyRequest:keyRequest];
}- (void)handleStreamingContentKeyRequest:(AVContentKeyRequest *)keyRequest {NSString *contentKeyIdentifierString = (NSString *)keyRequest.identifier;NSURL * contentKeyIdentifierURL = [NSURL URLWithString:contentKeyIdentifierString];NSString *assetIDString = contentKeyIdentifierURL.host;if (!assetIDString || assetIDString.length == 0) {LOGE(TAG, "[func:%s] Failed to retrieve the assetID from the keyRequest!", __func__);return;}[self _handleContentKeyRequest:keyRequest];
}// 请求 SPC 信息
- (void)_handleContentKeyRequest:(AVContentKeyRequest *)keyRequest {if (!self.applicationCertificate) {LOGE(TAG, "[func:_handleContentKeyRequest] no fairplay certificate");return;}NSString *contentKeyIdentifierString = (NSString *)keyRequest.identifier;NSURL * contentKeyIdentifierURL = [NSURL URLWithString:contentKeyIdentifierString];NSString *assetIDString = contentKeyIdentifierURL.host;NSData * assetIDData = [assetIDString dataUsingEncoding:NSUTF8StringEncoding];if (!assetIDString || assetIDString.length == 0) {LOGE(TAG, "[func:_handleContentKeyRequest] Failed to retrieve the assetID from the keyRequest!");return;}__weaktypeof(self) weakSelf = self;void (^requestSPCCallback)(NSData * _Nullable data, NSError * _Nullable error )= ^void(NSData * _Nullable contentKeyRequestData, NSError * _Nullable error) {if (error) {LOGE(TAG, "request spc Error: %s", [error.debugDescription cStringUsingEncoding:NSUTF8StringEncoding]);[weakSelf _processContentKeyResponseWithRequest:keyRequest ForAssetIDString:assetIDString WithResponse:NULL error:error];} else {[weakSelf _getLicenceseWithSpcData:contentKeyRequestData contentId:assetIDString keyRequest:keyRequest];}};[keyRequest makeStreamingContentKeyRequestDataForApp:self.applicationCertificate contentIdentifier:assetIDData options:@{AVContentKeyRequestProtocolVersionsKey : @[@1]} completionHandler:requestSPCCallback];
}// 请求 CKC 信息
- (void)_getLicenceseWithSpcData:(NSData *)spcData contentId:(NSString *)assetIDString keyRequest:(AVContentKeyRequest *)keyRequest{NSTimeInterval expiryDuration = 0.0;NSError *error;SofaContentAsset *assetContent = [self.contentKeyToStreamNameMap objectForKey:assetIDString];if (!assetContent) {LOGE(TAG, "[func:_getLicenceseWithSpcData] assetContent nul");return;}// http 请求:spc->ckcNSData *ckcData = [SofaAVContentKeyManager getlicenseWithSpcData:spcData contentIdentifierHost:assetIDString leaseExpiryDuration:&expiryDuration fpRedemptionUrl:assetContent.redemptionUrl error:&error];if (error) {LOGE(TAG, "[func:%s] CKC response Error: %s",__func__, [error.debugDescription cStringUsingEncoding:NSUTF8StringEncoding]);[self _processContentKeyResponseWithRequest:keyRequest ForAssetIDString:assetIDString WithResponse:NULL error:error];} else {AVContentKeyResponse *keyResponse = [AVContentKeyResponse contentKeyResponseWithFairPlayStreamingKeyResponseData:ckcData];[self _processContentKeyResponseWithRequest:keyRequest ForAssetIDString:assetIDString WithResponse:keyResponse error:NULL];}
}- (void)_processContentKeyResponseWithRequest:(AVContentKeyRequest *)keyReq ForAssetIDString:(NSString *)assetIdString WithResponse:(AVContentKeyResponse *)keyResponse error:(NSError *)error {if (error) {[keyReq processContentKeyResponseError:error]; // 如果请求 spc 或者 ckc 某个步骤出错,需要调用 processContentKeyResponseError:error} else {[keyReq processContentKeyResponse:keyResponse]; // 通知系统请求秘钥信息成功,可以继续后续播放流程}
}
场景二:使用 prewarming,减少首帧时间,提升用户体验
这种情况是开发者可以根据用户行为,来预测即将播放的视频(例如预测用户会继续播放下一剧集),提前将该视频的解密秘钥获取下来,以便后续播放。
// 在合适时机,主动调用 processContentKeyRequestWithIdentifier:initializationData:options 来触发 session delegate 的回调方法 contentKeySession:didProvideContentKeyRequest:
// asset.contentId 是一个字符串,标识该加密的视频资源。 需要通过接口提前获取到。 示例: `sdk://1341234123412341234123412434`
[self.keySession processContentKeyRequestWithIdentifier:asset.contentId initializationData:NULL options:NULL];// 后面的流程就和场景一一样了,在 session 回调方法里请求 spc,请求 ckc,告知系统秘钥请求完成或失败 [keyRequest processContentKeyResponse:keyResponse]
场景三:离线下载 FairPlay 视频,用户可以在无网情况下播放
这种情况也需要开发者在下载任务开始之前,主动调用 processContentKeyRequestWithIdentifier:initializationData:options
,不同点在于需要在 session delegate 回调方法里请求 persistable key,并将其存储下来。
请求 presistable key。
respondByRequestingPersistableContentKeyRequestAndReturnError:
;存储解密密钥信息 persistable key。
[contentKey writeToURL:fileUrl options:NSDataWritingAtomic error:&err]
;使用本地的 persistable key 播放 FairPlay 视频,触发回调更新 persitable key
contentKeySession: didUpdatePersistableContentKey: forContentKeyIdentifier:
。
// 下载任务开始之前,调用 processContentKeyRequestWithIdentifier
- (void)requestPersistableContentKeysForAsset:(SofaContentAsset *)asset {NSString *contentId = [asset.contentId componentsSeparatedByString:@"//"].lastObject;// pendingPersistableContentKeyIdentifiers 数组保存待处理的 persistable key 请求标识[self.pendingPersistableContentKeyIdentifiers addObject:contentId];LOGI(TAG, "[func:requestPersistableContentKeysForAsset] Requesting persistable key for assetID `\(%s)`", [contentId cStringUsingEncoding:NSUTF8StringEncoding]); [self.keySession processContentKeyRequestWithIdentifier:asset.contentId initializationData:NULL options:NULL];
}- (void)contentKeySession:(nonnullAVContentKeySession *)session didProvideContentKeyRequest:(nonnullAVContentKeyRequest *)keyRequest {[self handleStreamingContentKeyRequest:keyRequest];
}- (void)handleStreamingContentKeyRequest:(AVContentKeyRequest *)keyRequest {NSString *contentKeyIdentifierString = (NSString *)keyRequest.identifier;NSURL * contentKeyIdentifierURL = [NSURL URLWithString:contentKeyIdentifierString];NSString *assetIDString = contentKeyIdentifierURL.host;// 如果存在待处理的 persistable key 请求或者这个视频的 persistable key 已经存放在本地了// 则调用 respondByRequestingPersistableContentKeyRequestAndReturnError 去请求// 会触发 session delegate 回调 contentKeySession:didProvidePersistableContentKeyRequestif([self.pendingPersistableContentKeyIdentifiers containsObject:assetIDString] ||[self persistableContentKeyExistsOnDiskWithContentKeyIdentifier:assetIDString]) {NSError *err;if (@available(iOS 11.2, *)) {// Informs the receiver to process a persistable content key request.[keyRequest respondByRequestingPersistableContentKeyRequestAndReturnError:&err];if (err) {[self _handleContentKeyRequest:keyRequest];}}return;}[self _handleContentKeyRequest:keyRequest];
}- (void)contentKeySession:(AVContentKeySession *)session didProvidePersistableContentKeyRequest:(AVPersistableContentKeyRequest *)keyRequest {[self handlePersistableContentKeyRequest:keyRequest];
}- (void)handlePersistableContentKeyRequest:(AVPersistableContentKeyRequest *)keyRequest {NSString *contentKeyIdentifierString = (NSString *)keyRequest.identifier;NSURL * contentKeyIdentifierURL = [NSURL URLWithString:contentKeyIdentifierString];NSString *assetIDString = contentKeyIdentifierURL.host;NSData *data = [[NSFileManager defaultManager] contentsAtPath:[self urlForPersistableContentKeyWithContentKeyIdentifier:assetIDString].path];if (data) {// 播放离线视频时,本地存在秘钥信息,直接使用AVContentKeyResponse *response = [AVContentKeyResponse contentKeyResponseWithFairPlayStreamingKeyResponseData:data];[self _processContentKeyResponseWithRequest:keyRequest ForAssetIDString:assetIDString WithResponse:response error:NULL];} else {// 开启离线下载任务时,本地还不存在秘钥信息// 立马启动请求秘钥流程,同在线播放。 注意此时的 keyRequest 是 AVPersistableContentKeyRequest[self.pendingPersistableContentKeyIdentifiers removeObject:assetIDString];[self _handleContentKeyRequest:keyRequest];return;}
}// ... 中间流程的函数调用参考场景一// 请求 ckc
- (void)_getLicenceseWithSpcData:(NSData *)spcData contentId:(NSString *)assetIDString keyRequest:(AVContentKeyRequest *)keyRequest{NSData *ckcData = [SofaAVContentKeyManager getlicenseWithSpcData:spcData contentIdentifierHost:assetIDString leaseExpiryDuration:&expiryDuration fpRedemptionUrl:assetContent.redemptionUrl error:&error];// 在请求下来 CKC 信息后,判断 keyRequest 是 AVPersistableContentKeyRequest,则把 CKC 存放到本地 if ([keyRequest isKindOfClass:[AVPersistableContentKeyRequestclass]]) {AVPersistableContentKeyRequest *keyRequestCopy = (AVPersistableContentKeyRequest *)keyRequest;NSError *error2;NSData *persistableKeyData = [keyRequestCopy persistableContentKeyFromKeyVendorResponse:ckcData options:NULL error:&error2];if (error2) {LOGE(TAG, "[func:%s] get persistable key error: %s",__func__, [error2.debugDescription cStringUsingEncoding:NSUTF8StringEncoding]);[self _processContentKeyResponseWithRequest:keyRequest ForAssetIDString:assetIDString WithResponse:NULL error:error2];return;} else {// valid until end of storage duration. eg 30 days. // when use this key to playback, MIGHT receive callback // `contentKeySession: didUpdatePersistableContentKey: forContentKeyIdentifier:`ckcData = persistableKeyData; // 写数据到本地[self writePersistableContentKey:ckcData withContentKeyIdentifier:assetIDString];}}AVContentKeyResponse *keyResponse = [AVContentKeyResponse contentKeyResponseWithFairPlayStreamingKeyResponseData:ckcData];[self _processContentKeyResponseWithRequest:keyRequest ForAssetIDString:assetIDString WithResponse:keyResponse error:NULL];
}- (void)writePersistableContentKey:(NSData *)contentKey withContentKeyIdentifier:(NSString *)contentKeyIdentifier {NSURL *fileUrl = [self urlForPersistableContentKeyWithContentKeyIdentifier:contentKeyIdentifier];NSError *err;[contentKey writeToURL:fileUrl options:NSDataWritingAtomic error:&err];if (!err) {LOGI(TAG, "Stored the persisted content key: `\(%s)`", [fileUrl.path cStringUsingEncoding:NSUTF8StringEncoding]);}
}
persistent key 写入本地成功后,就可以开始使用 AVAssetDownloadTask
下载视频了,见后面小节。
播放离线视频具体流程,和场景一类似。不同点在于,在 session delegate 方法 contentKeySession:didProvideContentKeyRequest:
会判断本地存放有该视频的 persistable key 就会直接使用本地存放的 persistable key:
NSData *data = [[NSFileManager defaultManager] contentsAtPath:[self urlForPersistableContentKeyWithContentKeyIdentifier:assetIDString].path];
if (data) {// 播放离线视频时,本地存在秘钥信息,直接使用AVContentKeyResponse *response = [AVContentKeyResponse contentKeyResponseWithFairPlayStreamingKeyResponseData:data];[self _processContentKeyResponseWithRequest:keyRequest ForAssetIDString:assetIDString WithResponse:response error:NULL];
}
同时在本地 persistable key 用于播放后,系统会回调 contentKeySession: didUpdatePersistableContentKey: forContentKeyIdentifier:
来更新 persistale key 中的过期时间为播放期过期时间:
- (void)contentKeySession:(AVContentKeySession *)session didUpdatePersistableContentKey:(NSData *)persistableContentKey forContentKeyIdentifier:(id)keyIdentifier {NSString *contentKeyIdentifierString = (NSString *) keyIdentifier;NSURL *contentKeyIdentifierURL = [NSURL URLWithString:contentKeyIdentifierString];NSString *assetIDString = contentKeyIdentifierURL.host;if (!contentKeyIdentifierString || !contentKeyIdentifierURL || !assetIDString) {LOGE(TAG, "Failed to retrieve the assetID from the keyRequest!");return;}LOGI(TAG, "Trying to update persistable key for asset: \(%s)", [assetIDString cStringUsingEncoding:NSUTF8StringEncoding]); [self deletePeristableContentKeyWithContentKeyIdentifier:assetIDString]; // delete the old persistable key[self writePersistableContentKey:persistableContentKey withContentKeyIdentifier:assetIDString];// save new key, playback duration,eg:24H
}
关于 Persistable Key 过期时间
上面有提过存储期和播放期两个概念的过期时间,具体如下:
存储期 Storage Duration,是说秘钥存储到本地,在没有观看之前,称之为存储期。可以给这个存储期秘钥设置一个比较长的有效期,例如 30 天。在有效期内用户随时可以开启播放,有效期过了秘钥就自动失效。
我们在下载视频之前,请求并存储下来的 persistable key,就是存储期的秘钥。
播放期 Playback Duration,是指一旦用户开始播放视频,就到了播放期。这时候通过 contentKeySession:didUpdatePersistableContentKey:forContentKeyIdentifier
获取的秘钥就是播放期的秘钥,我们要把这个新获取的 key 替换掉之前本地存储下来的 persistable key。可以给这个播放期秘钥设置一个比较短的有效期,例如 48 小时。
假设用户在下载 FairPlay 视频后,从来没有观看过。在这种情况下,第一个密钥成为系统上的唯一密钥,超过有效期后它会自动失效。
如果使用一个失效的 key 来播放 FairPlay 视频,playerItem
会报错:
- (void)observeValueForKeyPath:(NSString *)keyPathofObject:(id)objectchange:(NSDictionary *)changecontext:(void *)context {if (object == self.playerItem) {if ([keyPath isEqualToString:@"status"]) {if (self.playerItem.status == AVPlayerItemStatusFailed) {NSError *itemError = self.player.currentItem.error;if ([itemError.debugDescription containsString:@"-42800"]) {// Persistent Key 已过期,需重新请求}}}}
}
2.3 Tip1: 使用 fileURLWithPath 创建存放在本地路径下的媒体 URL
播放本地路径下的视频,创建 NSURL
时候需要使用 fileURLWithPath
:
// 不用 NSURL.init(string: <#T##String#>)
let fileUrl = NSURL.fileURL(withPath: "/Library/Caches/aHR0cDovLzEwLjI==_E0363AAE664D0C7E.movpkg")
let urlAsset = AVAsset.init(url: fileUrl)
2.4 Tip2: 使用单例管理 AVContentKeySession
关于是否使用单例来管理 AVContentKeySession
的讨论,详细可以见论坛这里(https://forums.developer.apple.com/forums/thread/108708):
@interface SofaAVContentKeyManager ()<AVContentKeySessionDelegate>
@property (nonatomic, strong, readwrite) AVContentKeySession *keySession;
@end+ (instancetype)sharedInstance {staticdispatch_once_t onceToken;static SofaAVContentKeyManager *instance;dispatch_once(&onceToken, ^{instance = [[self alloc] init];});return instance;
}- (instancetype)init {self = [super init];if (self) {[self createKeySession];}returnself;
}- (void)createKeySession {_keyQueue = dispatch_queue_create("com.sohuvideo.contentkeyqueue", DISPATCH_QUEUE_SERIAL);self.keySession = [AVContentKeySession contentKeySessionWithKeySystem:AVContentKeySystemFairPlayStreaming];[self.keySession setDelegate:self queue:_keyQueue];
}
03
视频下载 AVAssetDownloadTask
3.1 使用 AVAssetDownloadTask 可以下载 HLS 视频,步骤如下:
1. 创建 AVAssetDownloadURLSession
实例:
let hlsAsset = AVURLAsset(url: assetURL)let backgroundConfiguration = URLSessionConfiguration.background(withIdentifier: "assetDownloadConfigurationIdentifier")
// AVAssetDownloadURLSession 继承自 `NSURLSession`,支持创建 `AVAssetDownloadTask`
let assetDownloadURLSession = AVAssetDownloadURLSession(configuration: backgroundConfiguration,assetDownloadDelegate: self, delegateQueue: OperationQueue.main())
2. 创建下载任务并启动:
guard let downloadTask = assetDownloadURLSession.makeAssetDownloadTask(asset: asset.urlAsset, assetTitle: asset.stream.name, assetArtworkData: nil, options: nil) else {return}
downloadTask.taskDescription = asset.stream.name
downloadTask.resume()
3. 实现协议 AVAssetDownloadDelegate
中的下载回调方法:
// 下载任务确定好下载路径的回调
func urlSession(_ session: URLSession, assetDownloadTask: AVAssetDownloadTask, willDownloadTo location: URL) {print("下载即将开始,路径: ", location.path)
}// 下载进度更新的回调
func urlSession(_ session: URLSession, assetDownloadTask: AVAssetDownloadTask, didLoad timeRange: CMTimeRange, totalTimeRangesLoaded loadedTimeRanges: [NSValue], timeRangeExpectedToLoad: CMTimeRange) {var percentComplete = 0.0for value in loadedTimeRanges {let loadedTimeRange: CMTimeRange = value.timeRangeValuepercentComplete +=CMTimeGetSeconds(loadedTimeRange.duration) / CMTimeGetSeconds(timeRangeExpectedToLoad.duration)}print("下载进度: ", percentComplete)
}// 下载任务完成下载的回调
func urlSession(_ session: URLSession, assetDownloadTask: AVAssetDownloadTask, didFinishDownloadingTo location: URL) {print("文件下载完成,存放路径: ", location.path)
}// 任务结束的回调
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: (any Error)?) {guardlet task = task as? AVAssetDownloadTaskelse { return }iflet error = error asNSError? {switch (error.domain, error.code) {case (NSURLErrorDomain, NSURLErrorCancelled):print("用户取消")case (NSURLErrorDomain, NSURLErrorUnknown):fatalError("不支持模拟器下载 HLS streams.")default:fatalError("错误发生 \(error.domain)")}} else {// 会在 urlSession(_ session: URLSession, assetDownloadTask: AVAssetDownloadTask, didFinishDownloadingTo location: URL) 之后调用print("task complete")}
}
3.2 Tip1: 下载的路径不可以自己设置,需要在下载完成后移动到想要存放的目录下
// 下载任务完成回调
func urlSession(_ session: URLSession, assetDownloadTask: AVAssetDownloadTask, didFinishDownloadingTo location: URL) {let cachePath = NSURL.fileURL(withPath: NSSearchPathForDirectoriesInDomains(FileManager.SearchPathDirectory.cachesDirectory, FileManager.SearchPathDomainMask.userDomainMask, true).first!.appending("xxx.movpkg")) // 替换成你自己设置的缓存路径move(file: location, to: cachePath)
}func move(file: URL, to destinationPath: URL) {guardFileManager.default.fileExists(atPath: file.path) else {print("Source file does not exist.")return}do {ifFileManager.default.fileExists(atPath: destinationPath.path) {tryFileManager.default.removeItem(at: destinationPath) // 如果目标路径已经有了同名文件,需要先删除,否则会 move 失败}tryFileManager.default.moveItem(at: file, to: destinationPath)} catch {print("Error moving file: \(error)")}
}
3.3 Tip2: 下载后的文件是个 movpkg
mp4 和 hls 视频下载完成后的文件都是以为 movpkg
结尾的,但是使用 AVPlayer 无法播放 mp4.movpkg
。只有 HLS 视频下载后的文件 hls.movpkg
是一个 bundle
,可以使用AVPlayer/AVPlayerViewController
播放。
如果下载的是一个 MP4 视频,可以在下载结束调用 move(file: URL, to destinationPath: URL)
时候,把 destinationPath
设置为一个 xxx.mp4
结尾的路径,这样后续可以正常播放这个 xxx.mp4


3.4 Tip3: 使用 AVAggregateAssetDownloadTask
如果你的 HLS 流中包含多个不同的码率、音轨、字幕等,可以使用 AVAggregateAssetDownloadTask
来下载指定的媒体流。
使用 func aggregateAssetDownloadTask(with URLAsset: AVURLAsset, mediaSelections: [AVMediaSelection], assetTitle title: String, assetArtworkData artworkData: Data?, options: [String : Any]? = nil) -> AVAggregateAssetDownloadTask?
来创建下载任务,代码如下:
// Get the default media selections for the asset's media selection groups.
let preferredMediaSelection = asset.urlAsset.preferredMediaSelectionguard let task =assetDownloadURLSession.aggregateAssetDownloadTask(with: asset.urlAsset,mediaSelections: [preferredMediaSelection], // 指定希望下载的媒体版本(例如不同的清晰度或语言轨道)assetTitle: asset.stream.name,assetArtworkData: nil,options:[AVAssetDownloadTaskMinimumRequiredMediaBitrateKey: 265_000]) // 下载时要求的最低媒体比特率为 265 kbps。这可以帮助控制下载的质量
else { return }task.taskDescription = asset.stream.name
task.resume()
相应的 AVAssetDownloadDelegate
协议的回调方法也变成了下面几个:
func urlSession(_ session: URLSession, aggregateAssetDownloadTask: AVAggregateAssetDownloadTask,willDownloadTo location: URL) {
}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) {
}
04
参考链接
1.Apple Developer, FairPlay Streaming(https://developer.apple.com/streaming/fps/);
2.WWDC2018, AVContentKeySession Best Practices
(https://devstreaming-cdn.apple.com/videos/wwdc/2018/507axjplrd0yjzixfz/507/507_hd_avcontentkeysession_best_practices.mp4?dl=1);
3.WWDC2020, Discover how to download and play HLS offline(https://developer.apple.com/videos/play/wwdc2020/10655/)。