概述
最近修改了項(xiàng)目中的視頻播放功能, 由之前的全量下載完再播, 改為了邊下邊播的方式. 由于我們項(xiàng)目中的視頻在發(fā)出時(shí)都進(jìn)行了加密, 所以整個(gè)過程其實(shí)就是邊下載邊解密邊播放.
邊下邊播的技術(shù)方案, 網(wǎng)上的博客很容易搜到, 不外乎兩種方式, 內(nèi)置本地代理服務(wù)器
和AVAssetResourceLoader
. 我們采取了系統(tǒng)提供的AVAssetResourceLoader
這一方案.
方案原理
具體的AVAssetResourceLoader
實(shí)現(xiàn)原理網(wǎng)上可以找到很多邏輯圖, 如下圖(來自網(wǎng)絡(luò))所示.
這里結(jié)合我們的實(shí)際代碼簡單的介紹一個(gè)這個(gè)圖片.
在平時(shí)使用AVPlayer播放url時(shí), 我們會(huì)這樣創(chuàng)建一個(gè)播放器(簡略)
let videoAsset = AVURLAsset(url: "http://resource_url/xxxxx")
let item = AVPlayerItem(asset: videoAsset)
let player = AVPlayer(playerItem: item)
如果我們這樣設(shè)置播放, 整個(gè)播放的內(nèi)部流程其實(shí)都我們都是不可見的, 視頻的下載和緩存等, 我們只能通過已知的一些方法,來控制播放器的播放暫停等.
如果想要實(shí)現(xiàn)我們項(xiàng)目中想要的效果, 邊下載邊播放, 同時(shí), 我們可能需要接手視頻的緩存這一模塊, 所以我們就必須得能進(jìn)入到整個(gè)播放流程中, AVAssetResourceLoader
其實(shí)就算是蘋果給我們留的一個(gè)小口子, 然后通過設(shè)置遵守AVAssetResourceLoaderDelegate
這一協(xié)議的代理對象, 接手?jǐn)?shù)據(jù)處理的這一過程(包括獲取數(shù)據(jù)和向播放器填充數(shù)據(jù)).
videoAsset.resourceLoader.setDelegate(self, queue: queue)
注意事項(xiàng)
- 要進(jìn)入到
AVAssetResourceLoader
的代理回調(diào), 除了要給videoAsset.resourceLoader設(shè)置delegate之外, 還需要把我們的url改為不能識別的scheme. 我們一遍的資源路徑都是http或者h(yuǎn)ttps, 我們需要把url的scheme改為不能識別的(私有的), 比如http://resource/xxx/xxx.mp4
改為http-prefix://reource/xxxx/xxx.mp4
- url路徑的最后必須要有視頻的后綴, 類似.mp4, 我之前使用的資源路徑是沒有后綴的, 導(dǎo)致了播放器無法起播.
AVAssetResourceLoaderDelegate
AVAssetResourceLoaderDelegate
有兩個(gè)常用的回調(diào)方法如下
// MARK: - AVAssetResourceLoaderDelegate
func resourceLoader(_ resourceLoader: AVAssetResourceLoader, shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest) -> Bool {}
func resourceLoader(_ resourceLoader: AVAssetResourceLoader, didCancel loadingRequest: AVAssetResourceLoadingRequest) {}
當(dāng)播放器開始播放的時(shí)候, 會(huì)通過shouldWaitForLoadingOfRequestedResource
這個(gè)回調(diào)方法向我們索要數(shù)據(jù), 具體所要數(shù)據(jù)的信息細(xì)節(jié)都封裝在loadingRequest
里面.
因?yàn)檫@個(gè)回調(diào)會(huì)走很多次, 上圖中表示的是要保存起來每一次的loadingRequest, 但在實(shí)際項(xiàng)目中, 我使用了不太一樣的策略, 我把每一次loadingRequest都對應(yīng)一個(gè)worker對象來處理, 這樣每次索要數(shù)據(jù), 都有一個(gè)單獨(dú)的worker來處理相對應(yīng)的網(wǎng)絡(luò)請求(暫不考慮緩存), 這樣比較條理. 同時(shí)我們也需要保存起我們的worker, 因?yàn)槿绻シ牌餍枰С诌M(jìn)度條拖動(dòng)時(shí), 需要手動(dòng)seek到某一個(gè)位置, 這樣會(huì)觸發(fā)didCancel
這個(gè)回調(diào), 所以我們也需要把我們對應(yīng)的worker內(nèi)部停掉.
回調(diào)處理
當(dāng)我們收到一個(gè)回調(diào)時(shí), 我們主要關(guān)注這個(gè)AVAssetResourceLoadingRequest類型的loadingRequest.
他內(nèi)部有一個(gè)dataRequest屬性, dataRequest中有requestedOffset, requestedLength等一些有用信息. 我們通過requestedOffset和requestedLength構(gòu)建出我們的Range, 塞到請求頭里面去, 獲取相應(yīng)range的數(shù)據(jù).
當(dāng)我們的player開始播放時(shí), 收到的第一個(gè)回調(diào), requestedOffset=0,requestedLength=2, 也就是索要0-1這兩個(gè)字節(jié), 這次請求其實(shí)可以理解為一個(gè)嗅探請求, 目的是為了得到視頻的相關(guān)信息, 文件大小, 類型等.
guard request.contentInformationRequest == nil else {
if request.dataRequest?.requestsAllDataToEndOfResource == false {
request.contentInformationRequest?.contentLength = totalLen
} else {
request.contentInformationRequest?.contentLength = Int64(data.count)
}
request.contentInformationRequest?.isByteRangeAccessSupported = true
request.contentInformationRequest?.contentType = "video/mp4"
request.finishLoading()
return
}
上述代碼就是第一個(gè)嗅探請求的處理方式, 通過request.contentInformationRequest==nil
, 判斷出是第一個(gè)嗅探請求, 然后我們需要填充request的contentInformationRequest, 然后填充信息結(jié)束調(diào)用finishLoading()
, 當(dāng)前的loadingRequest就結(jié)束了.
第一個(gè)嗅探請求結(jié)束后, 如果我們返回的沒有問題, 那播放器會(huì)立刻進(jìn)行下一個(gè)回調(diào), 開始所要視頻數(shù)據(jù), 我在項(xiàng)目中測試時(shí), 第二個(gè)請求一般都是0-xxx(文件大小-1), 索要整個(gè)文件, 這時(shí)我們dataTask類型的請求,等待服務(wù)器一片一片的返回?cái)?shù)據(jù), 沒收到一部分?jǐn)?shù)據(jù)后調(diào)用dataRequest.respond(with: data)
, 全部收取完畢之后調(diào)用request.finishLoading()
.
其實(shí)這就是最基本的數(shù)據(jù)填充的邏輯, 除了第一個(gè)嗅探請求特殊處理一下, 后面的就是收到數(shù)據(jù), 就填充回dataRequest, 索要的數(shù)據(jù)全部填充完畢, 調(diào)用finishLoading.
在我們請求整個(gè)文件的過程中, 有時(shí)候會(huì)發(fā)現(xiàn)一種現(xiàn)象, 就是respond一部分?jǐn)?shù)據(jù)之后, loadingRequest被cancel了, 然后又開始索要很后面的range的數(shù)據(jù), 其實(shí)這可以理解為一個(gè)尋找文件的moov的過程, 文件的moov可能在文件頭, 也可能在文件尾部.moov里面定義視頻的時(shí)間尺度,時(shí)長,顯示特性以及每個(gè)軌道信息等, 這一部分可以通過了解mp4文件頭格式來多做一下了解.
我們不管他索要的是那一部分?jǐn)?shù)據(jù), 只要我們請求到對應(yīng)的數(shù)據(jù), respond回去就沒問題.
補(bǔ)充
那這么簡單的邏輯對于我們自己的項(xiàng)目來說難點(diǎn)是什么呢,這里簡單描述一下.
前面有說到我們項(xiàng)目中的資源都是經(jīng)過加密的, 使用了AES的加密算法, 這樣我們在接受到數(shù)據(jù)之后, 是不能直接返回給dataRequest
的, 需要我們先解密, 然后簡單的說我們使用的加密策略是每16字節(jié)是一個(gè)加密片段, 但請求返回的數(shù)據(jù)并不能保證每次都是16倍數(shù), 所以我們處理16的倍數(shù)才能進(jìn)行解密這一個(gè)問題, 然后還有一個(gè)range的修正問題, 打比方我們需要1-10這10個(gè)字節(jié)的數(shù)據(jù), 但是我請求頭的range是不能直接寫1-10的, 因?yàn)榘凑瘴覀兠?6個(gè)字節(jié)是一個(gè)加密片段, 我們需要的1-10, 在0-15這個(gè)片段中, 所以我們必須要先請求下來0-15這一個(gè)片段, 然后解密, 再從中拿出1-10, 填充回去. 當(dāng)然了還有一些細(xì)節(jié)就不展開敘述了, 等有機(jī)會(huì)結(jié)合項(xiàng)目單獨(dú)聊一聊AES的加解密方法.
總結(jié)
上面就是在實(shí)現(xiàn)邊下邊播過程中總結(jié)到的一些小點(diǎn), 當(dāng)然每個(gè)人在實(shí)際項(xiàng)目可能會(huì)遇到不一樣的問題. 同時(shí)本文沒有涉及到數(shù)據(jù)的緩存, github上也有很多不錯(cuò)的緩存方案, 大家可以看看.
感謝閱讀.