前言
iOS多媒體播放主要有2個(gè)技術(shù)層框架可以實(shí)現(xiàn):
AVFoundation庫:OC語言對底層進(jìn)行封裝的高級層接口,其中處理音頻、視頻播放功能的是AVPlayer。優(yōu)點(diǎn):由于AVPlayer已經(jīng)對底層諸如音視頻采集、解編碼等細(xì)節(jié)封裝了,應(yīng)用層不需要關(guān)心這些實(shí)現(xiàn)細(xì)節(jié),所以使用簡單,普通開發(fā)者可以不用知道什么是碼率、采樣率等音視頻專業(yè)知識,即可實(shí)現(xiàn)音視頻播放的功能。缺點(diǎn)就是:由于高度封裝,靈活性較差,例如沒有開放諸如緩存的存取的API,給開發(fā)者控制視頻緩存帶來了難度。
AudioToolBox: 采用較底層的C語音實(shí)現(xiàn)的音視頻的采集、I/O處理、解碼、編碼、PCM等處理的API集合。優(yōu)點(diǎn)是:靈活度高,開發(fā)者可以開發(fā)出專業(yè)的音視頻播放軟件,主要是供音視頻專業(yè)技術(shù)開發(fā)者使用。但對于非音視頻專業(yè)的普通iOS開發(fā)者并不友好,對音視頻領(lǐng)域不是很了解的話,有一定的門檻。
作為非音視頻專業(yè)領(lǐng)域,只是個(gè)APP應(yīng)用的iOS開發(fā)者,AudioToolBox是沒有把握的,寫出來也是一堆bug哈哈~
所以主要還是利用AVPlayer實(shí)現(xiàn)播放器,可是如果想播放完一次視頻后,下次可以利用緩存播放,AVPlayer并不提供緩存API,我們沒法知道AVPlayer的緩存在哪里。經(jīng)過研究發(fā)現(xiàn),目前實(shí)現(xiàn)帶緩存功能的AVPlayer播放器主要從2個(gè)方向:
- 在播放視頻的同時(shí),開啟一個(gè)線程下載該視頻URL。
- 利用AVAssetResourceLoaderDelegate控制視頻數(shù)據(jù)流的請求。
毫無疑問,第一種方案播放一個(gè)視頻,需要耗費(fèi)用戶2倍的流量;而第二種方案只要一遍的流量,既播放了視頻、又緩存了視頻,所以,我的技術(shù)方案就是采用AVAssetResourceLoaderDelegate實(shí)現(xiàn)。
方案思路
AVAssetResourceLoaderDelegate
首先了解一下AVAssetResourceLoaderDelegate所在的層:

其中核心類:
AVAssetResourceLoader:這個(gè)類負(fù)責(zé)多媒體(音視頻)二進(jìn)制數(shù)據(jù)的加載(下載),然后回調(diào)給上層Asset,讓視頻播放。但是這個(gè)類作為AVURLAsset是只讀屬性,但是它允許下面這個(gè)代理去如何加載數(shù)據(jù)資源。
AVAssetResourceLoaderDelegate:它是一個(gè)協(xié)議,那么任何實(shí)現(xiàn)了該協(xié)議的對象都可以充當(dāng)AVAssetResourceLoader的代理來指示視頻數(shù)據(jù)的加載,既然數(shù)據(jù)資源可以有開發(fā)人員自行加載然后再回填給播放器,那么緩存就可以有自己控制了,OK,這就是我們這個(gè)方案的思路。
注意:通過測試發(fā)現(xiàn),如果給AVURLAssert設(shè)置成正??梢韵螺d的URL時(shí),AVAssetResourceLoaderDelegate的代理是不觸發(fā)的,很可能的推測就是AVAssetResourceLoader解析資源URL做了判斷(偽碼):
if (URL可以自行解析下載) {
內(nèi)部自己解析...
} else {
由外部AVAssetResourceLoaderDelegate解析
}
所以,我們?yōu)榱俗孉VURLAssert強(qiáng)行走外部代理解析,我們可以故意給AVURLAssert傳一個(gè)不合法的URL(為了讓AVAssetResourceLoader不能正常解析URL),我們可以在正確的URL前面拼接約定好的標(biāo)識,然后在后面我們真正去下載前,再將特定的標(biāo)識去掉即可得到能正常下載的URL了。大意是這樣:

架構(gòu)設(shè)計(jì)
- KWResourceLoader:該類負(fù)責(zé)AVAssetResourceLoaderDelegate代理的2個(gè)實(shí)現(xiàn)方法:
resourceLoader:(AVAssetResourceLoader *)resourceLoader shouldWaitForLoadingOfRequestedResource:(AVAssetResourceLoadingRequest *)loadingRequest
resourceLoader:(AVAssetResourceLoader *)resourceLoader didCancelLoadingRequest:(AVAssetResourceLoadingRequest *)loadingRequest
上面shouldWait代理表示要等待加載的資源,在播放中會觸發(fā)多次,以便于分片加載資源,resourceLoader中5個(gè)我們需要關(guān)心的:
1.request:請求資源的URL
2.contentInfomationRequest:這個(gè)里面包含了該音視頻資源的頭部信息,如視頻的格式、總長度字節(jié)數(shù)、是否支持分片下載等重要信息。這些信息需要我們下載視頻的時(shí)候自行填充這些信息,以便AVPlayer 播放前知道視頻的duration和格式信息,如果我們不填充視頻頭信息,視頻是無法播放的,這點(diǎn)是需要注意的地方。
3.dataRequest:這個(gè)里面含有每次分片加載資源的位置offset和請求的長度length信息,以便于我們下載器分片下載對應(yīng)的data.
4.finishWithLoading/withError: 每次音視頻data片段加載加載完畢后,我們要finishLoading ,目的是通知播放器本次資源加載結(jié)束,那么AVAssetResourceLoaderDelegate就又會觸發(fā)shouldWait方法讓我們繼續(xù)加載后面的data,如此反復(fù),直到資源data全部加載完畢。
5.responseWithData: 在finishLoading之前,我們要將不斷下載得到的data數(shù)據(jù)不斷的塞給resouceLoader,以便播放器在一邊下載數(shù)據(jù)的同時(shí)一邊開始播放。
整體架構(gòu)流程圖如下:

實(shí)現(xiàn)細(xì)節(jié):
KWResouceLoader
- 給AVURLAsset的URL添加特定頭部,以便resouceLoader不能正常解析,從而觸發(fā)shouldWait。
- (NSURL *)assetURLWithURL:(NSURL *)url {
if (!url) {
return nil;
}
NSURL *assetURL = [NSURL URLWithString:[kCacheScheme stringByAppendingString:[url absoluteString]]];
return assetURL;
}
然后把拼接的URL傳給AVURLAsset:
//將URL拼接特定標(biāo)識,目的是讓AVURLAsset不能自行下載,從而觸發(fā)shouldwait
url = [self.loadManager assetURLWithURL:url];
self.asset = [AVURLAsset URLAssetWithURL:url options:nil];
[self.asset.resourceLoader setDelegate:self queue:dispatch_get_main_queue()];
設(shè)置resouceLoader的delegate, 即可觸發(fā)下面代理方法:
#pragma mark - AVAssetResourceLoaderDelegate
//開始等待加載資源
- (BOOL)resourceLoader:(AVAssetResourceLoader *)resourceLoader shouldWaitForLoadingOfRequestedResource:(AVAssetResourceLoadingRequest *)loadingRequest NS_AVAILABLE(10_9, 6_0) {
NSURL *resourceURL = [loadingRequest.request URL];
[KWLog kwLog:@"開始等待資源:%lld-%ld",loadingRequest.dataRequest.requestedOffset,
(long)loadingRequest.dataRequest.requestedLength];
if ([resourceURL.absoluteString hasPrefix:kCacheScheme]) {
//將該資源請求放入待下載列表里
[self.loadManager addResourceLoadReqeust:loadingRequest];
return YES;
}else {
return NO;
}
}
//取消下載觸發(fā)
- (void)resourceLoader:(AVAssetResourceLoader *)resourceLoader didCancelLoadingRequest:(AVAssetResourceLoadingRequest *)loadingRequest NS_AVAILABLE(10_9, 7_0) {
[KWLog kwLog:@"取消加載的資源:%lld-%ld",loadingRequest.dataRequest.requestedOffset,
(long)loadingRequest.dataRequest.requestedLength];
//取消下載
[self.loadManager cancelResourceLoadReqeust:loadingRequest];
}
上面第一個(gè)是將要加載加載某個(gè)URL片段,這個(gè)會多次觸發(fā),而且可能一次可能會觸發(fā)多次片段請求,所以我們應(yīng)該用一個(gè)數(shù)組來保存每次的request,最后在全部加載完后移除。
第二個(gè)是觸發(fā)取消下載的委托:通過大量的測試發(fā)現(xiàn),這個(gè)取消觸發(fā)一般有2種情況下回出現(xiàn):
當(dāng)前request片段較長,一般是一個(gè)請求至尾的大片段,而當(dāng)前網(wǎng)絡(luò)加載data資源網(wǎng)速欠佳,resouceLoader會取消這次請求,然后改成多個(gè)小分片請求,以保證播放的流暢性。
用戶進(jìn)行seek操作。當(dāng)用戶拖動進(jìn)度至一個(gè)尚未下載(加載)的進(jìn)度的時(shí)候,為了立即加載新的進(jìn)度的資源,會把之前正在加載的請求取消掉。
當(dāng)觸發(fā)了取消代理時(shí),我們應(yīng)該把正在下載的Task cancel掉,以節(jié)省用戶的流量。當(dāng)然,如果你不取消之前的Task也是可以的,這里我還是遵從Apple的代理,將正在下載的Task取消吧。
KWResouceLoader這個(gè)類不負(fù)責(zé)具體資源的加載、取消邏輯,它委托了KWResouceLoaderManager這個(gè)類負(fù)責(zé):
@protocol KWResourceLoadManagerDelegate <NSObject>
@required
//開始填充頭部信息
- (void)resouceLoadManager:(KWResourceLoadManager *)manager
fillContentInfomation:(KWHttpInfomation *)infomation
loadReqeust:(AVAssetResourceLoadingRequest *)request;
//接收數(shù)據(jù)
- (void)resouceLoadManager:(KWResourceLoadManager *)manager
didReceiveData:(NSData *)data
loadReqeust:(AVAssetResourceLoadingRequest *)request;
//加載資源結(jié)束
- (void)resouceLoadManager:(KWResourceLoadManager *)manager
didCompleteWithError:(nullable NSError *)error
loadReqeust:(AVAssetResourceLoadingRequest *)request;
@optional
//資源加載進(jìn)度
- (void)resouceLoadManager:(KWResourceLoadManager *)manager
resourceLoadProgress:(float)progress
loadReqeust:(AVAssetResourceLoadingRequest *)request;
@end
fillContentInfomation: 得到資源的頭信息,為播放做準(zhǔn)備
didReceiveData:得到的data調(diào)用responseWithData塞給播放器播放。
completeWithError: 本次request結(jié)束,觸發(fā)下一輪資源請求。
KWResouceLoaderManager
該類是整個(gè)框架的核心,它維護(hù)了一個(gè)所有要加載資源的隊(duì)列,并實(shí)現(xiàn)了一個(gè)消費(fèi)-生產(chǎn)模式,以保證了加載的順序,以及判斷從本地緩存還是網(wǎng)絡(luò)下載該片段。
消費(fèi)-生產(chǎn)模式
當(dāng)shouldWait觸發(fā)時(shí),說明有新的請求過來了,我們首先將request加到隊(duì)列中,然后判斷當(dāng)前是否繁忙(由于同一時(shí)刻只能有一個(gè)資源請求,所以我們應(yīng)該按順序請求資源):
- (void)addResourceLoadReqeust:(AVAssetResourceLoadingRequest *)request {
[self.loadRequestArray addObject:request];
if (self.isRunning) {
//當(dāng)前有正在加載的資源,新添加進(jìn)來資源,排隊(duì)
return;
}
//空閑狀態(tài),立即開始加載資源
[self beginLoadResource:request];
}
我用了isRunning標(biāo)識表示當(dāng)前是否有別的資源正在加載,如果有,將返回true,新的資源只能待定,否則即時(shí)“空閑狀態(tài)”,可以立刻加載新的資源。
判斷從緩存還是下載獲取資源
根據(jù)request我們獲取要請求的Range:
NSURL *URL = [self originURL:request.request.URL];
long long offset = request.dataRequest.requestedOffset;
long long length = request.dataRequest.requestedLength;
然后根據(jù)range判斷本地是否有該判斷緩存,如果沒有,則下載:
[KWFileManager readLocalBytesOfURL:URL range:NSMakeRange(offset, length) finish:^(NSData * _Nonnull data, NSError * _Nonnull error) {
if (data && !error) {
[KWLog kwLog:@"使用緩存"];
[self finishLoadRequest:request withLocalCacheData:data];
//加載下一個(gè)資源
[self loadNextToLoadedResource:request];
}else {
[KWLog kwLog:@"沒有緩存,開始下載資源"];
NSURLSessionTask *task = [self.downloader downloadURL:URL range:NSMakeRange(offset, length)];
[self.downloadTasks addObject:task];
}
}];
如果本地緩存獲取到data,(緩存存取由KWFileManager實(shí)現(xiàn),后面再具體說)則將data和頭信息回調(diào)給上層,然后繼續(xù)加載下一個(gè)資源;如果沒有緩存,則開啟一個(gè)下載任務(wù)。
downloader是由KWResouceDowloader實(shí)現(xiàn):
下載我主要是采用NSURLSessionTask實(shí)現(xiàn),由于要指定下載片段,所以我們r(jià)equest要設(shè)置HTTPHeaderField字段range:
- (NSURLSessionTask *)downloadURL:(NSURL *)URL range:(NSRange)range {
self.URL = URL;
self.offset = range.location;
self.length = range.length;
NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:URL];
request.cachePolicy = NSURLRequestReloadIgnoringLocalAndRemoteCacheData;
long long endOffset = self.offset + self.length - 1;
NSString *httpRange = [NSString stringWithFormat:@"bytes=%lld-%lld",self.offset,endOffset];
[request setValue:httpRange forHTTPHeaderField:@"Range"];
NSURLSessionTask *task = [self.session dataTaskWithRequest:request];
[task resume];
self.isDownloading = YES;
self.task = task;
return task;
}
這樣,就會下載該音視頻指定位置長度的二進(jìn)制文件,而不是整部下載,所以這是一個(gè)分片下載器。
然后有SessionDelegate代理得到下載的結(jié)果:
#pragma mark - NSURLSessionDelegate
//開始接受數(shù)據(jù)
- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask
didReceiveResponse:(NSURLResponse *)response
completionHandler:(void (^)(NSURLSessionResponseDisposition disposition))completionHandler {
//設(shè)置contentInformation
completionHandler(NSURLSessionResponseAllow);
}
- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveData:(NSData *)data {
if ([self.delegate respondsToSelector:@selector(downloader:didReceiveData:task:)]) {
[self.delegate downloader:self didReceiveData:data task:dataTask];
}
}
//下載完成
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task
didCompleteWithError:(nullable NSError *)error {
[KWLog kwLog:@"下載結(jié)束"];
self.isDownloading = NO;
if ([self.delegate respondsToSelector:@selector(downloader:didCompleteWithError:task:)]) {
[self.delegate downloader:self didCompleteWithError:error task:task];
}
}
關(guān)于contentInfomation
在上面didResponse開始返回?cái)?shù)據(jù)前,我們可以提取該資源的格式、長度信息:
KWHttpInfomation *info = [[KWHttpInfomation alloc] init];
if ([response isKindOfClass:[NSHTTPURLResponse class]]) {
NSHTTPURLResponse *HTTPURLResponse = (NSHTTPURLResponse *)response;
NSString *acceptRange = HTTPURLResponse.allHeaderFields[@"Accept-Ranges"];
info.byteRangeAccessSupported = [acceptRange isEqualToString:@"bytes"];
//考慮到絕大部分服務(wù)器都支持bytes,這里就全部設(shè)置為支持
info.byteRangeAccessSupported = YES;
info.contentLength = [[[HTTPURLResponse.allHeaderFields[@"Content-Range"]
componentsSeparatedByString:@"/"] lastObject] longLongValue];
if (info.contentLength == 0) {
info.contentLength = [HTTPURLResponse.allHeaderFields[@"Content-Length"] longLongValue];
}
}
NSString *mimeType = response.MIMEType;
CFStringRef contentType = UTTypeCreatePreferredIdentifierForTag(kUTTagClassMIMEType,
(__bridge CFStringRef)(mimeType),
NULL);
info.contentType = CFBridgingRelease(contentType);
if ([self.delegate respondsToSelector:@selector(downloader:informatiion:task:)]) {
[self.delegate downloader:self informatiion:info task:dataTask];
}
[KWLog kwLog:@"%@",info.debugDescription];
//緩存info
[KWFileManager saveContentInfomation:info URL:dataTask.originalRequest.URL];
注意點(diǎn):
1.info.byteRangeAccessSupported 這里我本來是根據(jù)headerFields獲取是否支持分片下載,后面我發(fā)現(xiàn)很多視頻headerField并沒有指明是否支持分片下載,但是測試發(fā)現(xiàn),這些未指明的視頻都是可以分片下載的,然后我網(wǎng)上查了一下,發(fā)現(xiàn)基本95%以上的服務(wù)器是支持分片下載的,所以這里我全部默認(rèn)為可分片下載了。
2.獲取的頭信息由于以便于下次不用下載,所以這里要寫入本地緩存。
3.提供Task cancel功能,以便于外部可隨時(shí)取消該資源下載。
KWFileManager
該文件負(fù)責(zé)緩存的讀取、存儲、清理等功能。
由于是緩存,所以,我放在了Cache主目錄下,然后新建了音視頻根目錄:
+ (NSString *)cacheRootPath {
//將下載默認(rèn)存放地址移到緩存目錄下
NSString *cachePath = [NSSearchPathForDirectoriesInDomains(NSCachesDirectory,
NSUserDomainMask, YES) lastObject];
//緩存根目錄
NSString *mediaPath = [cachePath stringByAppendingPathComponent:kMediaCachePath];
return mediaPath;
}
1.將URL的Md5值作為某個(gè)資源的文件夾名稱
由于每個(gè)每個(gè)資源URL長度不一,考慮到Md5唯一性和長度一致的特性,所以講md5值作為key是非常合適的。
2.某個(gè)片段文件名以“startOffset_endOffset_sugguestName”命名
NSString *fileName = [NSString stringWithFormat:@"%lld_%lld_%@",offset,
endOffset,task.response.suggestedFilename];
這樣存儲的文件最終形態(tài)如下圖所示:

我是以每個(gè)下載請求的Range+name作為文件名,這樣文件名稱就包含了資源的位置長度,以便于下次快速檢索從緩存讀取的文件。
那么,由于下次請求的位置和長度不可能剛好和緩存中的一樣長,那么這里采取了2種方式:
- 1.優(yōu)先單個(gè)碎片文件,取子集:
for (NSString *fileName in files) {
NSArray *components = [fileName componentsSeparatedByString:@"_"];
if (components.count >= 3) {
NSString *offset = [components firstObject];
NSString *endOffset = components[1];
if(start == 0 && end == 1) {
if (start == [offset longLongValue] &&
end == [endOffset longLongValue]) {
//落在本地某個(gè)片段內(nèi)
targetFileName = fileName;
break;
}
}else if (start >= [offset longLongValue] && end <= [endOffset longLongValue] &&
(start != 0 && end != 1)) {
//落在本地某個(gè)片段內(nèi)
targetFileName = fileName;
break;
}
}
}
- 跨碎片拼接:有些碎片頭尾是可以連接成一個(gè)大的碎片的,然后再去子集:
//嘗試垮碎片查找
NSString *firstFileName = [self fileNameOffset:start files:files];
if (firstFileName) {
NSRange firstRange = [self fileRange:firstFileName];
if (firstRange.length != 0) {
long long firstEnd = firstRange.location + firstRange.length - 1;
NSMutableArray *sequentFiles = [NSMutableArray arrayWithObject:firstFileName];
[self getNextSequentFileNameByLastEnd:firstEnd files:files output:&sequentFiles];
if (sequentFiles.count > 1) {
NSString *lastFileName = [sequentFiles lastObject];
NSRange lastRange = [self fileRange:lastFileName];
if (lastRange.location + lastRange.length - 1 >= end) {
[readFiles addObjectsFromArray:sequentFiles];
}
}
}
}
上面在查找下一個(gè)連續(xù)的碎片采取了遞歸方式:
+ (void)getNextSequentFileNameByLastEnd:(long long)lastEnd files:(NSArray *)files output:(NSMutableArray **)outputArray {
NSString *findFileName = nil;
for (NSString *fileName in files) {
NSRange range = [self fileRange:fileName];
if (range.length != 0) {
if (range.location == lastEnd + 1) {
findFileName = fileName;
break;
}
}
}
if (findFileName) {
NSRange fileRange = [self fileRange:findFileName];
if (fileRange.length != 0) {
long long end = fileRange.location + fileRange.length - 1;
NSMutableArray *outPut = *outputArray;
[outPut addObject:findFileName];
[self getNextSequentFileNameByLastEnd:end files:files output:&outPut];
}
}
}
以上是緩存處理文件的主要難點(diǎn)。其他的文件寫入、刪除常規(guī)操作,故不再敘述。
以上就是我這個(gè)項(xiàng)目的主要細(xì)節(jié)。
項(xiàng)目評價(jià)
- 優(yōu)點(diǎn)
1.實(shí)現(xiàn)了邊播放邊緩存資源
2.支持seek操作,可立即從新的進(jìn)度繼續(xù)播放,較流暢
3.支持片段緩存,而不是僅僅整體緩存,節(jié)省了用戶流量
4.支持緩存自定義清理,可以清理單個(gè)資源文件
- 待優(yōu)化點(diǎn)
1.某些資源頻繁cancel請求的問題,當(dāng)?shù)谝淮斡|發(fā)了cancel請求后,第二次請求過程中,即便有部分緩存,視頻不會立即播放,必須等待本次片段全部請求完畢才開始播放。這個(gè)原因尚不明確,因?yàn)閞esouceLoader是個(gè)黑匣子,理論上,下載過程中是不必等到finishLoading完畢后才播放資源,只要resonseWithData:給緩沖區(qū)塞給了足夠可播放的data即可播放,但是第一次cancel后新價(jià)值的資源總是要帶到加載完畢才播放,用戶等待較長,但是一般出現(xiàn)該請求都是弱網(wǎng)絡(luò)產(chǎn)生,一般情況下流暢度不錯(cuò)。
2.緩存獲取我的方案理論上不是最優(yōu)解,因?yàn)槲覜]有考慮本地有部分緩存,另外部分需要下載的情況。但是,這中方案理論上會多出很多的請求次數(shù),增加了請求次數(shù)和復(fù)雜度,所以最終沒有采用,不排除后續(xù)會優(yōu)化這一處。
- 總結(jié)
整體項(xiàng)目前后花了大約1星期的時(shí)間完成,由于以前對AVAssetResourceLoader并沒有接觸過,很多屬性和方法都是自己摸索著嘗試,通過大量的測試,基本上摸清了resouceLoaderDelegate的”脾氣“。
整體效果比較滿意,當(dāng)然,網(wǎng)上也研究了別的同學(xué)的方案,最終我還是采用了自己的緩存思路實(shí)現(xiàn),鍛煉了能力,雖然不是最優(yōu)解,但是經(jīng)過了大量優(yōu)化測試,bug較少,現(xiàn)在將項(xiàng)目開源出來,和大家一起分享,如果有覺得這個(gè)方案對自己有用,就給個(gè)star 吧,同時(shí)以熱烈歡迎對音視頻有興趣的同學(xué)和我留言,繼續(xù)探討更優(yōu)化的方案哦,謝謝~
demo效果圖:
