iOS m3u8本地快取播放(控制下載併發、暫停恢復)

weixin_33727510發表於2017-10-11
目錄
一、m3u8快取播放的整個流程
二、控制媒體下載的併發數
三、控制單個媒體的切片下載併發數
四、下載的中斷和恢復
五、注意的問題與思路延伸

一、m3u8快取播放的整個流程

1.下載m3u8檔案
2.解析m3u8檔案獲取視訊切片單元的資訊。
3.根據2.獲取的視訊切片資訊中的切片連結下載切片並保持到本地。
3.根據獲取的切片資訊與本地伺服器的配置資訊,拼接出切片的本地地址、生成新的m3u8檔案並儲存到本地。
4.開啟本地伺服器,使用本地url播放本地m3u8檔案。

附上:時序圖,具體可看demo

1690665-3f245e01576dfac2.png
image.png

二、控制媒體下載的併發數

   這裡使用訊號量來控制併發數
- (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訊息是管用的。那麼問題的解決就是找出這些等待的任務。
    處理辦法:通過判斷接收位元組數來區分狀態。現在我只面向你接收的位元組數,而不管你真開啟還是假開啟了。
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.檔案的操作通過開啟一個同步佇列來處。可以設定low優先順序避免佔用太高的cpu資源。其實高cpu佔用會伴隨著另外一個問題,手機的發熱量。

4.key的處理問題
如果存在key的下載,需要把key下載到本地,約定好key的名稱和新建m3u8檔案中的key連結。這樣本地播放就能正常加解密。
例如下載到本地的key儲存為.../key。那麼連結應該是http:localhost:port/.../key

5.中斷的優先順序
程式的中斷操作擁有最高優先順序的,因為要任何狀態下都能中斷下載。無論是為了程式的流暢性、網路變為移動訊號避免使用使用者的移動流量等發出中斷命令,都必須立即響應。

6.切片數量的全域性分配
多個視訊同時下載,多個切片同時併發。如果要做到控制全域性的切片併發數而不是單個視訊的切片併發數。這就要設計一個演算法在全域性Manger哪裡做分配和回收。

7.保證app流程,監控網速開啟和中斷下載
下載視訊的功能應該要保證app本身網路請求的正常執行。
app如何獲取到網路的頻寬,好像只能通過下載檔案方式來推算。可在應用請求空閒時通過短時間下載一個可用源來計算頻寬,同時監控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

相關文章