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/)。