目錄
一、m3u8緩存播放的整個流程
二、控制媒體下載的并發數
三、控制單個媒體的切片下載并發數
四、下載的中斷和恢復
五、注意的問題與思路延伸
更新(相關demo會繼續完善,使用operation實現了并發控制的版本)
一、m3u8緩存播放的整個流程
1.下載m3u8文件
2.解析m3u8文件獲取視頻切片單元的信息。
3.根據2.獲取的視頻切片信息中的切片鏈接下載切片并保持到本地。
3.根據獲取的切片信息與本地服務器的配置信息,拼接出切片的本地地址、生成新的m3u8文件并保存到本地。
4.開啟本地服務器,使用本地url播放本地m3u8文件。
附上:時序圖,具體可看demo
二、控制媒體下載的并發數
這里使用信號量來控制并發數
- (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);
}];
});
}
這里可以設置_movieSemaphore的的初始值為具體的可同時下載數。
Example:_movieSemaphore = dispatch_semaphore_create(1),意味著同一時間只允許下載一個視頻,等同于視頻的串行下載。
三、控制單個媒體的切片下載并發數
開始的時候,考慮使用AFURLSessionManager中的operationQueue.maxConcurrentOperationCount來控制并發。但這是行不通的。因為這個queue是用于回調而不是用于下載隊列。
/**
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確實是用于回調,而我是需要控制下載并發。這似乎不滿足。而且實測中也發現確實不行。那么,只能在任務發起哪里做并發控制,同樣,這里還是采用信號量。這里的控制相對復雜一點、因為后面的任務恢復、失敗任務重新創建也要做控制。
- (void)startDownload
{
//因為這是外部調用的方法,操作的執行要放到異步線程中。避免因為并發控制中的等待而堵塞外部線程
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) {
//控制切片下載并發
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
{
//真正的創建下載任務
[self createDownloadTaskWithIndex:idx];
}
}
}];
});
}
//信號量在每個任務的回調后都會釋放一次
- (void)verifyDownloadCountAndCallbackByDownloadSuccess:(BOOL) isSuccess
{
dispatch_semaphore_signal(self.tsSemaphore);
...
這里也看到信號量的控制問題,必須理清信號量的獲得和釋放時機,一次獲得必須有一次釋放。不釋放或者重復釋放,都會導致并發的控制不準。如果這樣,那這里的并發控制就沒有意義了。
要做到準確獲取和釋放,重點在于理清程序的執行路徑。在每一條執行路徑中都必須釋放信號量。這個跟鎖的使用也是一樣的。
調試現象:如果程序不像預料中運行,又沒有什么錯誤,那很有可能就是堵塞了。鎖沒有釋放或者信號量的處理有問題。
處理步驟:點擊xcode調試欄的暫停按鈕,查看程序的調用棧,分析每個線程的運行情況,找到堵塞具體執行代碼。根據具體的邏輯修正問題。
四、下載的中斷和恢復
這里有幾個小問題:根據判斷NSURLSessionTask 提供的3個方法可以做一些中斷和恢復處理
- (void)suspend;
掛起任務,但只能掛起執行中的任務。對于已經創建而且執行resum方法但并沒真正執行的任務無效(這里非??樱?。
通常我們使用這個方法的時候會判斷下任務的具體狀態,如果是task.state == NSURLSessionTaskStateRunning采取執行 [task suspend]。但這個判斷是不準確的。如果一個任務創建并執行resume但并沒真正執行,它的狀態也是為NSURLSessionTaskStateRunning。如果這個時候程序收到中斷消息,對狀態為NSURLSessionTaskStateRunning 的任務全部執行suspend操作,你會發現有些任務不聽話,繼續執行。到底什么搞鬼...
我的理解是這樣的,這些不聽話的任務正是那些添加到下載隊列中等待執行的任務,而在等待狀態下收到suspend消息是不管用的。但它接收cannel消息是管用的。那么問題的解決就是找出這些等待的任務。
處理辦法:通過判斷接收字節數來區分狀態?,F在我只面向你接收的字節數,而不管你真開啟還是假開啟了。
switch (obj.downloadTask.state) {
case NSURLSessionTaskStateRunning:
{
//等待中,假開啟狀態,
if (obj.downloadTask.countOfBytesReceived <= 0) {
[obj.downloadTask cancel];
}
else
{
//正在下載,真開啟狀態,
[obj.downloadTask suspend];
}
}
break;
- (void)resume;
官方文檔是這么說明的:Resumes the task, if it is suspended.意思是指只能發起被掛起的任務。
存在兩種情況:
1.新創建的任務并沒有執行resume,此時狀態為:NSURLSessionTaskStateSuspended
2.執行suspend方法后被手動掛起的任務,狀態同為NSURLSessionTaskStateSuspended。
同時這里提供了額外信息:狀態為NSURLSessionTaskStateCompleted的任務是不能通過resume重新發起的。而在某些情況下我們需要對這種狀態的任務重新發起,包括手動cannel的、執行失敗的。這種情況下只能根據具體的情況,重新創建任務并發起。
if (obj.downloadTask.error &&
obj.downloadTask.state == NSURLSessionTaskStateCompleted)
{
//下載失敗的任務重新創建
[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.
簡而言之:調用這個方法可以cannel任意狀態的任務包括掛起的任務。并執行didCompleteWithError回調(AF中會執行回調并返回錯誤NSURLErrorCancelled),狀態被標記為NSURLSessionTaskStateCompleted。故上面恢復失敗任務的時候,通過task.state和task.error共同判斷。
總結下任務生命周期中的任務狀態變化:
1.創建成功:...Suspended
2.執行resume:...Running(可以通過判斷countOfBytesReceived來區分任務處于等待還是下載中)
3.執行suspend:...Suspended
4.執行cannel:(中間狀態...Canceling)->...Completed(可以結合task.error判斷任務執行結果)
回歸正題:
視頻單元的中斷和恢復,中斷就是調用suspend方法掛起正在執行的任務,cannel掉等待的任務,代碼跟上面說明方法的時候非常雷同。這里著重說明下恢復。如果單單恢復其實很簡單,恢復掛起的任務和重新創建錯誤的任務。這只是從程序的角度看待,要使一個程序有更高的可用性,應在功能實現的同時做的更加的合理。應優先恢復掛起的任務、然后重新創建錯誤的任務。這樣做都是為了承前啟后更快的把一個下載任務完成。而且這個視頻切片是講究有序的,所以我們恢復的時候也要遵從FIFO的原則。
五、注意的問題與思路延伸
1.解析m3u8注意的問題
meu8的解析格式太多,很容易出現問題,應使用try/catch來保證程序的健壯性。
2.根據url獲取原始m3u8文件信息,這個操作太耗時了。為了提高程序的效率,獲取到原始m3u8文件后應做本地持久化并用于二次下載。如果整個流程下載成功,可以選擇刪除該文件,或者不操作。由于是純文本文件,少量的文件冗余是允許的。
3.文件的操作通過開啟一個同步隊列來處??梢栽O定low優先級避免占用太高的cpu資源。其實高cpu占用會伴隨著另外一個問題,手機的發熱量。
4.key的處理問題
如果存在key的下載,需要把key下載到本地,約定好key的名稱和新建m3u8文件中的key鏈接。這樣本地播放就能正常加解密。
例如下載到本地的key保存為.../key。那么鏈接應該是http:localhost:port/.../key
5.中斷的優先級
程序的中斷操作擁有最高優先級的,因為要任何狀態下都能中斷下載。無論是為了程序的流暢性、網絡變為移動信號避免使用用戶的移動流量等發出中斷命令,都必須立即響應。
6.切片數量的全局分配
多個視頻同時下載,多個切片同時并發。如果要做到控制全局的切片并發數而不是單個視頻的切片并發數。這就要設計一個算法在全局Manger哪里做分配和回收。
7.保證app流程,監控網速開啟和中斷下載
下載視頻的功能應該要保證app本身網絡請求的正常運行。
app如何獲取到網絡的帶寬,好像只能通過下載文件方式來推算??稍趹谜埱罂臻e時通過短時間下載一個可用源來計算帶寬,同時監控app 實時網絡吞吐,適當的開關下載。雖然很難做到實時,但是在切換網絡的時候進行帶寬重測、又或者地理位置變化一定距離后進行帶寬重測、又或者定時作帶寬重測。還有就是考慮wifi狀態下才進行下載。
8.切換網絡后請求失去連接,恢復下載的問題
因為網絡切換本地ip變化,發起的請求會失去連接。如果通過downloadTask是沒辦法做到恢復下載的。雖然可以用resumeData來恢復下載,但是這個只能在cannel操作的時候獲取,至于失去連接的情況下是沒辦法獲取到的(系統提供的api中沒有在失敗回調哪里返回resumeData的)。(思路是這樣,不一定能實現)這個時候需要自己創建文件句柄,使用dataTask做到文件續下。初始化dataTask的時候設定請求頭'Accept-Ranges'參數為文件的已下載字節數(需要服務器支持),就可以獲取到未下載的部分數據。
9.并發中鎖的處理
要理清那些代碼可能存在并發,那些操作要保證原子性。難就難在一個方法中會存在部分代碼塊是并發執行的,這有利于效率的提高;部分些代碼要原子操作。最優的做法就是對原子操作用鎖來保證,沒有任何多余的代碼加入到同步操作中,這樣也是效率最高的。而拿捏不準的情況下,可以鎖定更多的代碼,至少這樣不會因為并發而導致問題,但這樣就犧牲了效率和及時性。當一個簡單的系統,要做到最優好像并不難,但是一個復雜的系統做到最優就非常難了或者是要花費非常大的精力。基于這個demo的實現多線程流了不少坑,總結下多線程還是復雜。開發中優先考慮線程安全,再提高性能吧。
10.是否需要全部切片下載完成才能播放
其實并不需要全部下載完成就能播放的。保證key 先下載下來,而且要保證有序下載,然后下載一定量的切片文件,這個時候就可以組裝m3u8文件到本地,發起播放。只要后面下載的切片能滿足播放器的播放,就不會出現問題。但如果供應不足視頻就會停了,播放不了,盡管后面文件下載下來了,還是不能自動恢復,仿佛失去了緩沖功能。這里就是跟直接請求服務器的差別了,直接請求服務器,因為文件本身是存在的,發起的請求是存在的,如果網速慢,播放器的反應是緩沖;而本地服務播放就不同了,如果文件在播放前沒有下載下來,發起的請求立馬就掛了,這個請求不存在,當然就不存在緩沖。
11.線程多開占用資源,每個線程占用512K到1M空間。建議使用單線程下載,且穩定性高。
dome雖然實現了多線程下載,偶發死鎖的問題會存在,就是有坑?。。?。但m3u8文本文件跟數據解析部分處理是穩定的。(已更新修正部分問題。)
鏈接:https://github.com/zmubai/ZBLM3U8DownLoadTest
更新:
- 之前的demo問題不少,就抽空改了下,問題有所改善,但還不能達到穩定。就又弄了一個簡單的demo,把主要功能實現,做到簡單點穩定點。
地址:https://github.com/zmubai/m3u8DownloadSimpleDemo[2019-4-7] - 要實現并發控制,使用信號量的方式是可以實現的,但有一個很大的缺點,就是暫停控制變得麻煩。假設并發控制數為2,同時發起10個,那么有8個在等待,如果執行暫停,那么已經執行的2個可以暫停,但等待的8個取消不了,需要讓他們發起,然后再取消,操作變得很麻煩。衡量了一下,可以使用NSOperationQueue去控制并發,繼承NSOperation,創建子類,并通過設置finish變量為true,讓任務完成。只有當finish為true,任務才會在隊列中移除,這樣就能控制并發,并且更易于執行cannel等操作。[2019-4-7]
3.由于之前版本的實現不是特別滿意,就使用operation實現的版本。(支持使用cocoaPods 安裝)
支持媒體并發控制,支持單個媒體文件并發控制。支持任務取消,支持任務掛起和恢復。
地址:https://github.com/zmubai/BNM3u8Cache[2019-12]