目錄
一、m3u8緩存播放的整個流程
二抹凳、控制媒體下載的并發(fā)數(shù)
三遏餐、控制單個媒體的切片下載并發(fā)數(shù)
四、下載的中斷和恢復(fù)
五赢底、注意的問題與思路延伸
更新(相關(guān)demo會繼續(xù)完善境输,使用operation實(shí)現(xiàn)了并發(fā)控制的版本)
一蔗牡、m3u8緩存播放的整個流程
1.下載m3u8文件
2.解析m3u8文件獲取視頻切片單元的信息。
3.根據(jù)2.獲取的視頻切片信息中的切片鏈接下載切片并保持到本地嗅剖。
3.根據(jù)獲取的切片信息與本地服務(wù)器的配置信息辩越,拼接出切片的本地地址、生成新的m3u8文件并保存到本地信粮。
4.開啟本地服務(wù)器黔攒,使用本地url播放本地m3u8文件。
附上:時序圖强缘,具體可看demo
二督惰、控制媒體下載的并發(fā)數(shù)
這里使用信號量來控制并發(fā)數(shù)
- (void)downloadVideoWithUrlString:(NSString *)urlStr downloadProgressHandler:(ZBLM3u8ManagerDownloadProgressHandler)downloadProgressHandler downloadSuccessBlock:(ZBLM3u8ManagerDownloadSuccessBlock) downloadSuccessBlock
{
dispatch_async(_downloadQueue, ^{
dispatch_semaphore_wait(_movieSemaphore, DISPATCH_TIME_FOREVER);
__weak __typeof(self) weakself = self;
[[self downloadContainerWithUrlString:urlStr] startDownloadWithUrlString:urlStr downloadProgressHandler:^(float progress) {
downloadProgressHandler(progress);
} completaionHandler:^(NSString *locaLUrl, NSError *error) {
if (!error) {
[weakself.downloadContainerDictionary removeObjectForKey:[ZBLM3u8Setting uuidWithUrl:urlStr]];
NSLog(@"下載完成:%@",urlStr);
downloadSuccessBlock(locaLUrl);
}
else
{
NSLog(@"下載失敗:%@",error);
[self resumeDownload];
}
NSLog(@"%@",weakself.downloadContainerDictionary.allKeys);
dispatch_semaphore_signal(_movieSemaphore);
}];
});
}
這里可以設(shè)置_movieSemaphore的的初始值為具體的可同時下載數(shù)。
Example:_movieSemaphore = dispatch_semaphore_create(1),意味著同一時間只允許下載一個視頻旅掂,等同于視頻的串行下載赏胚。
三、控制單個媒體的切片下載并發(fā)數(shù)
開始的時候商虐,考慮使用AFURLSessionManager中的operationQueue.maxConcurrentOperationCount來控制并發(fā)觉阅。但這是行不通的。因?yàn)檫@個queue是用于回調(diào)而不是用于下載隊(duì)列秘车。
/**
The operation queue on which delegate callbacks are run.
*/
@property (readonly, nonatomic, strong) NSOperationQueue *operationQueue;
再看AF中初始化
- (instancetype)initWithSessionConfiguration:(NSURLSessionConfiguration *)configuration {
self = [super init];
if (!self) {
return nil;
}
if (!configuration) {
configuration = [NSURLSessionConfiguration defaultSessionConfiguration];
}
self.sessionConfiguration = configuration;
self.operationQueue = [[NSOperationQueue alloc] init];
self.operationQueue.maxConcurrentOperationCount = 1;
self.session = [NSURLSession sessionWithConfiguration:self.sessionConfiguration delegate:self delegateQueue:self.operationQueue];
...
這個queue確實(shí)是用于回調(diào)典勇,而我是需要控制下載并發(fā)。這似乎不滿足叮趴。而且實(shí)測中也發(fā)現(xiàn)確實(shí)不行割笙。那么,只能在任務(wù)發(fā)起哪里做并發(fā)控制眯亦,同樣伤溉,這里還是采用信號量。這里的控制相對復(fù)雜一點(diǎn)妻率、因?yàn)楹竺娴娜蝿?wù)恢復(fù)乱顾、失敗任務(wù)重新創(chuàng)建也要做控制。
- (void)startDownload
{
//因?yàn)檫@是外部調(diào)用的方法舌涨,操作的執(zhí)行要放到異步線程中。避免因?yàn)椴l(fā)控制中的等待而堵塞外部線程
dispatch_async(self.downloadQueue, ^{
if (!_fileDownloadInfos.count) {
_completaionHandler(nil);
return;
}
NSLog(@"downloadInfoCount:%ld",(long)_fileDownloadInfos.count);
[_fileDownloadInfos enumerateObjectsUsingBlock:^(ZBLM3u8FileDownloadInfo * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
//控制切片下載并發(fā)
dispatch_semaphore_wait(self.tsSemaphore, DISPATCH_TIME_FOREVER);
if ([ZBLM3u8FileManager exitItemWithPath:obj.filePath]) {
obj.success = YES;
[self verifyDownloadCountAndCallbackByDownloadSuccess:YES];
}
else
{
//如果收到中斷信號扔字,中斷下載流程釋放信號量并返回
if (self.suspend) {
obj.beStopCreateTask = YES;
dispatch_semaphore_signal(self.tsSemaphore);
NSLog(@"suspend and return! don not createDownloadTask!");
return ;
}
else
{
//真正的創(chuàng)建下載任務(wù)
[self createDownloadTaskWithIndex:idx];
}
}
}];
});
}
//信號量在每個任務(wù)的回調(diào)后都會釋放一次
- (void)verifyDownloadCountAndCallbackByDownloadSuccess:(BOOL) isSuccess
{
dispatch_semaphore_signal(self.tsSemaphore);
...
這里也看到信號量的控制問題囊嘉,必須理清信號量的獲得和釋放時機(jī),一次獲得必須有一次釋放革为。不釋放或者重復(fù)釋放扭粱,都會導(dǎo)致并發(fā)的控制不準(zhǔn)。如果這樣震檩,那這里的并發(fā)控制就沒有意義了琢蛤。
要做到準(zhǔn)確獲取和釋放蜓堕,重點(diǎn)在于理清程序的執(zhí)行路徑。在每一條執(zhí)行路徑中都必須釋放信號量博其。這個跟鎖的使用也是一樣的套才。
調(diào)試現(xiàn)象:如果程序不像預(yù)料中運(yùn)行,又沒有什么錯誤慕淡,那很有可能就是堵塞了背伴。鎖沒有釋放或者信號量的處理有問題。
處理步驟:點(diǎn)擊xcode調(diào)試欄的暫停按鈕峰髓,查看程序的調(diào)用棧傻寂,分析每個線程的運(yùn)行情況,找到堵塞具體執(zhí)行代碼携兵。根據(jù)具體的邏輯修正問題疾掰。
四、下載的中斷和恢復(fù)
這里有幾個小問題:根據(jù)判斷NSURLSessionTask 提供的3個方法可以做一些中斷和恢復(fù)處理
- (void)suspend;
掛起任務(wù)徐紧,但只能掛起執(zhí)行中的任務(wù)静檬。對于已經(jīng)創(chuàng)建而且執(zhí)行resum方法但并沒真正執(zhí)行的任務(wù)無效(這里非常坑)浪汪。
通常我們使用這個方法的時候會判斷下任務(wù)的具體狀態(tài)巴柿,如果是task.state == NSURLSessionTaskStateRunning采取執(zhí)行 [task suspend]。但這個判斷是不準(zhǔn)確的死遭。如果一個任務(wù)創(chuàng)建并執(zhí)行resume但并沒真正執(zhí)行广恢,它的狀態(tài)也是為NSURLSessionTaskStateRunning。如果這個時候程序收到中斷消息呀潭,對狀態(tài)為NSURLSessionTaskStateRunning 的任務(wù)全部執(zhí)行suspend操作钉迷,你會發(fā)現(xiàn)有些任務(wù)不聽話,繼續(xù)執(zhí)行钠署。到底什么搞鬼...
我的理解是這樣的糠聪,這些不聽話的任務(wù)正是那些添加到下載隊(duì)列中等待執(zhí)行的任務(wù),而在等待狀態(tài)下收到suspend消息是不管用的谐鼎。但它接收cannel消息是管用的舰蟆。那么問題的解決就是找出這些等待的任務(wù)。
處理辦法:通過判斷接收字節(jié)數(shù)來區(qū)分狀態(tài)±旯鳎現(xiàn)在我只面向你接收的字節(jié)數(shù)身害,而不管你真開啟還是假開啟了。
switch (obj.downloadTask.state) {
case NSURLSessionTaskStateRunning:
{
//等待中草戈,假開啟狀態(tài)塌鸯,
if (obj.downloadTask.countOfBytesReceived <= 0) {
[obj.downloadTask cancel];
}
else
{
//正在下載,真開啟狀態(tài)唐片,
[obj.downloadTask suspend];
}
}
break;
- (void)resume;
官方文檔是這么說明的:Resumes the task, if it is suspended.意思是指只能發(fā)起被掛起的任務(wù)丙猬。
存在兩種情況:
1.新創(chuàng)建的任務(wù)并沒有執(zhí)行resume涨颜,此時狀態(tài)為:NSURLSessionTaskStateSuspended
2.執(zhí)行suspend方法后被手動掛起的任務(wù),狀態(tài)同為NSURLSessionTaskStateSuspended茧球。
同時這里提供了額外信息:狀態(tài)為NSURLSessionTaskStateCompleted的任務(wù)是不能通過resume重新發(fā)起的庭瑰。而在某些情況下我們需要對這種狀態(tài)的任務(wù)重新發(fā)起,包括手動cannel的袜腥、執(zhí)行失敗的见擦。這種情況下只能根據(jù)具體的情況,重新創(chuàng)建任務(wù)并發(fā)起羹令。
if (obj.downloadTask.error &&
obj.downloadTask.state == NSURLSessionTaskStateCompleted)
{
//下載失敗的任務(wù)重新創(chuàng)建
[self createDownloadTaskWithIndex:idx];
}
- (void)cancel;
官方文檔說明:
This method returns immediately, marking the task as being canceled. Once a task is marked as being canceled, URLSession:task:didCompleteWithError: will be sent to the task delegate, passing an error in the domain NSURLErrorDomain with the code NSURLErrorCancelled. A task may, under some circumstances, send messages to its delegate before the cancelation is acknowledged.
This method may be called on a task that is suspended.
簡而言之:調(diào)用這個方法可以cannel任意狀態(tài)的任務(wù)包括掛起的任務(wù)鲤屡。并執(zhí)行didCompleteWithError回調(diào)(AF中會執(zhí)行回調(diào)并返回錯誤NSURLErrorCancelled),狀態(tài)被標(biāo)記為NSURLSessionTaskStateCompleted福侈。故上面恢復(fù)失敗任務(wù)的時候酒来,通過task.state和task.error共同判斷。
總結(jié)下任務(wù)生命周期中的任務(wù)狀態(tài)變化:
1.創(chuàng)建成功:...Suspended
2.執(zhí)行resume:...Running(可以通過判斷countOfBytesReceived來區(qū)分任務(wù)處于等待還是下載中)
3.執(zhí)行suspend:...Suspended
4.執(zhí)行cannel:(中間狀態(tài)...Canceling)->...Completed(可以結(jié)合task.error判斷任務(wù)執(zhí)行結(jié)果)
回歸正題:
視頻單元的中斷和恢復(fù)肪凛,中斷就是調(diào)用suspend方法掛起正在執(zhí)行的任務(wù)堰汉,cannel掉等待的任務(wù),代碼跟上面說明方法的時候非常雷同伟墙。這里著重說明下恢復(fù)翘鸭。如果單單恢復(fù)其實(shí)很簡單,恢復(fù)掛起的任務(wù)和重新創(chuàng)建錯誤的任務(wù)戳葵。這只是從程序的角度看待就乓,要使一個程序有更高的可用性,應(yīng)在功能實(shí)現(xiàn)的同時做的更加的合理拱烁。應(yīng)優(yōu)先恢復(fù)掛起的任務(wù)生蚁、然后重新創(chuàng)建錯誤的任務(wù)。這樣做都是為了承前啟后更快的把一個下載任務(wù)完成戏自。而且這個視頻切片是講究有序的邦投,所以我們恢復(fù)的時候也要遵從FIFO的原則。
五擅笔、注意的問題與思路延伸
1.解析m3u8注意的問題
meu8的解析格式太多志衣,很容易出現(xiàn)問題,應(yīng)使用try/catch來保證程序的健壯性猛们。
2.根據(jù)url獲取原始m3u8文件信息念脯,這個操作太耗時了。為了提高程序的效率阅懦,獲取到原始m3u8文件后應(yīng)做本地持久化并用于二次下載和二。如果整個流程下載成功徘铝,可以選擇刪除該文件耳胎,或者不操作惯吕。由于是純文本文件,少量的文件冗余是允許的怕午。
3.文件的操作通過開啟一個同步隊(duì)列來處废登。可以設(shè)定low優(yōu)先級避免占用太高的cpu資源郁惜。其實(shí)高cpu占用會伴隨著另外一個問題堡距,手機(jī)的發(fā)熱量。
4.key的處理問題
如果存在key的下載兆蕉,需要把key下載到本地羽戒,約定好key的名稱和新建m3u8文件中的key鏈接。這樣本地播放就能正常加解密虎韵。
例如下載到本地的key保存為.../key易稠。那么鏈接應(yīng)該是http:localhost:port/.../key
5.中斷的優(yōu)先級
程序的中斷操作擁有最高優(yōu)先級的,因?yàn)橐魏螤顟B(tài)下都能中斷下載包蓝。無論是為了程序的流暢性驶社、網(wǎng)絡(luò)變?yōu)橐苿有盘柋苊馐褂糜脩舻囊苿恿髁康劝l(fā)出中斷命令,都必須立即響應(yīng)测萎。
6.切片數(shù)量的全局分配
多個視頻同時下載亡电,多個切片同時并發(fā)。如果要做到控制全局的切片并發(fā)數(shù)而不是單個視頻的切片并發(fā)數(shù)硅瞧。這就要設(shè)計(jì)一個算法在全局Manger哪里做分配和回收份乒。
7.保證app流程,監(jiān)控網(wǎng)速開啟和中斷下載
下載視頻的功能應(yīng)該要保證app本身網(wǎng)絡(luò)請求的正常運(yùn)行零酪。
app如何獲取到網(wǎng)絡(luò)的帶寬冒嫡,好像只能通過下載文件方式來推算∷奈可在應(yīng)用請求空閑時通過短時間下載一個可用源來計(jì)算帶寬孝凌,同時監(jiān)控app 實(shí)時網(wǎng)絡(luò)吞吐,適當(dāng)?shù)拈_關(guān)下載月腋。雖然很難做到實(shí)時蟀架,但是在切換網(wǎng)絡(luò)的時候進(jìn)行帶寬重測、又或者地理位置變化一定距離后進(jìn)行帶寬重測榆骚、又或者定時作帶寬重測片拍。還有就是考慮wifi狀態(tài)下才進(jìn)行下載。
8.切換網(wǎng)絡(luò)后請求失去連接妓肢,恢復(fù)下載的問題
因?yàn)榫W(wǎng)絡(luò)切換本地ip變化捌省,發(fā)起的請求會失去連接。如果通過downloadTask是沒辦法做到恢復(fù)下載的碉钠。雖然可以用resumeData來恢復(fù)下載纲缓,但是這個只能在cannel操作的時候獲取卷拘,至于失去連接的情況下是沒辦法獲取到的(系統(tǒng)提供的api中沒有在失敗回調(diào)哪里返回resumeData的)。(思路是這樣祝高,不一定能實(shí)現(xiàn))這個時候需要自己創(chuàng)建文件句柄栗弟,使用dataTask做到文件續(xù)下。初始化dataTask的時候設(shè)定請求頭'Accept-Ranges'參數(shù)為文件的已下載字節(jié)數(shù)(需要服務(wù)器支持)工闺,就可以獲取到未下載的部分?jǐn)?shù)據(jù)乍赫。
9.并發(fā)中鎖的處理
要理清那些代碼可能存在并發(fā),那些操作要保證原子性陆蟆。難就難在一個方法中會存在部分代碼塊是并發(fā)執(zhí)行的雷厂,這有利于效率的提高;部分些代碼要原子操作叠殷。最優(yōu)的做法就是對原子操作用鎖來保證罗侯,沒有任何多余的代碼加入到同步操作中,這樣也是效率最高的溪猿。而拿捏不準(zhǔn)的情況下钩杰,可以鎖定更多的代碼,至少這樣不會因?yàn)椴l(fā)而導(dǎo)致問題诊县,但這樣就犧牲了效率和及時性讲弄。當(dāng)一個簡單的系統(tǒng),要做到最優(yōu)好像并不難依痊,但是一個復(fù)雜的系統(tǒng)做到最優(yōu)就非常難了或者是要花費(fèi)非常大的精力避除。基于這個demo的實(shí)現(xiàn)多線程流了不少坑胸嘁,總結(jié)下多線程還是復(fù)雜瓶摆。開發(fā)中優(yōu)先考慮線程安全,再提高性能吧性宏。
10.是否需要全部切片下載完成才能播放
其實(shí)并不需要全部下載完成就能播放的群井。保證key 先下載下來,而且要保證有序下載毫胜,然后下載一定量的切片文件书斜,這個時候就可以組裝m3u8文件到本地,發(fā)起播放酵使。只要后面下載的切片能滿足播放器的播放荐吉,就不會出現(xiàn)問題。但如果供應(yīng)不足視頻就會停了口渔,播放不了样屠,盡管后面文件下載下來了,還是不能自動恢復(fù),仿佛失去了緩沖功能痪欲。這里就是跟直接請求服務(wù)器的差別了混巧,直接請求服務(wù)器,因?yàn)槲募旧硎谴嬖诘那诳l(fā)起的請求是存在的,如果網(wǎng)速慢秘蛔,播放器的反應(yīng)是緩沖陨亡;而本地服務(wù)播放就不同了,如果文件在播放前沒有下載下來深员,發(fā)起的請求立馬就掛了负蠕,這個請求不存在,當(dāng)然就不存在緩沖倦畅。
11.線程多開占用資源遮糖,每個線程占用512K到1M空間。建議使用單線程下載叠赐,且穩(wěn)定性高欲账。
dome雖然實(shí)現(xiàn)了多線程下載,偶發(fā)死鎖的問題會存在芭概,就是有坑H弧!罢洲!踢故。但m3u8文本文件跟數(shù)據(jù)解析部分處理是穩(wěn)定的。(已更新修正部分問題惹苗。)
鏈接:https://github.com/zmubai/ZBLM3U8DownLoadTest
更新:
- 之前的demo問題不少殿较,就抽空改了下,問題有所改善桩蓉,但還不能達(dá)到穩(wěn)定淋纲。就又弄了一個簡單的demo,把主要功能實(shí)現(xiàn)院究,做到簡單點(diǎn)穩(wěn)定點(diǎn)帚戳。
地址:https://github.com/zmubai/m3u8DownloadSimpleDemo[2019-4-7] - 要實(shí)現(xiàn)并發(fā)控制,使用信號量的方式是可以實(shí)現(xiàn)的儡首,但有一個很大的缺點(diǎn)片任,就是暫停控制變得麻煩蔬胯。假設(shè)并發(fā)控制數(shù)為2对供,同時發(fā)起10個,那么有8個在等待,如果執(zhí)行暫停产场,那么已經(jīng)執(zhí)行的2個可以暫停鹅髓,但等待的8個取消不了,需要讓他們發(fā)起京景,然后再取消窿冯,操作變得很麻煩。衡量了一下确徙,可以使用NSOperationQueue去控制并發(fā)醒串,繼承NSOperation,創(chuàng)建子類,并通過設(shè)置finish變量為true鄙皇,讓任務(wù)完成芜赌。只有當(dāng)finish為true,任務(wù)才會在隊(duì)列中移除,這樣就能控制并發(fā)伴逸,并且更易于執(zhí)行cannel等操作缠沈。[2019-4-7]
3.由于之前版本的實(shí)現(xiàn)不是特別滿意,就使用operation實(shí)現(xiàn)的版本错蝴。(支持使用cocoaPods 安裝)
支持媒體并發(fā)控制洲愤,支持單個媒體文件并發(fā)控制。支持任務(wù)取消顷锰,支持任務(wù)掛起和恢復(fù)禽篱。
地址:https://github.com/zmubai/BNM3u8Cache[2019-12]