IOS音視訊(四十五)HTTPS 自簽名證書 實現邊下邊播

孔雨露發表於2020-04-05

@[TOC](IOS音視訊(四十五)HTTPS 自簽名證書 實現邊下邊播)

1. 邊下邊播概述

由於JimuPro相簿裡面獲取視訊,需要將視訊全部下載到本地後才能播放,如果視訊檔案很大,則使用者需要等待很長時間才能看到視訊,這種體驗效果不太友好,針對這個問題,需要IOS app端實現邊下邊播功能,使用一份資料流,完成觀看視訊的同事將視訊儲存到本地,等視訊播放完成後,視訊也就下載到了本地。下載完成後的視訊格式是.mp4格式,匯出來可以直接播放。當使用者第二次觀看次視訊時,將不從機器人端獲取視訊,直接讀取本地快取的視訊,也就是離線也可以觀看。

實現邊下邊播的方式,可以節省資料流量,實時觀看到機器人端錄製的視訊,可以拖拽的方式觀看。

這個功能滿足以下需求:

  • 支援正常播放器的一切功能,包括暫停、播放和拖拽。可以播放本地快取的視訊,也可以實時播放機器人端錄製的視訊。
  • 如果視訊載入完成且完整,將視訊檔案儲存到本地cache,下一次播放本地cache中的視訊,不再請求網路資料。
  • 如果視訊沒有載入完(半路關閉或者拖拽),下次播放時,先從快取中播放已經快取的視訊,並同時開啟下載功能,從上次的視訊末尾繼續下載剩下的部分。
  • 由於機器人端採用HTTPS + 自簽名證書的方式,實時播放視訊需要解決證書信任問題。

2. 邊下邊播實現方案

  • IOS客戶端實現邊下邊播的方案有很多,目前我研究的找到3種解決方案。下面將詳細介紹3種方案的實現原理。由於JimuPro裡面已經用到了開源的播放器:VGPlayer。這個播放器裡面基本上實現了方案三的細節問題。只是沒有實現HTTPS 自簽名證書認證的問題。

  • IOS專案中我推薦使用第三種方案實現邊下邊播功能。

2.1 方案一

  • 通過解析mp4的格式,將mp4的資料直接下載並寫入檔案,然後讓播放器直接播放的是本地的視訊檔案;

此方案是先下載視訊到本地檔案,然後把本地視訊檔案地址傳給播放器,播放器實際播放的是本地檔案。當播放器的播放進度大於當前的可播放的下載快取進度,則暫停播放,等快取到足夠播放時間之後,再讓播放器開始播放。這種方案的下載方式是與播放器完全沒有關係的,只是順序的將伺服器下發的視訊資料寫入本地檔案,然後讓播放器來讀取資料。

先下載然後直接播放本地檔案
以mp4檔案為例,通過解析mp4的格式,將mp4的資料直接下載並寫入檔案,然後讓播放器直接播放的是本地的視訊檔案;如下圖:
將mp4的資料直接下載並寫入檔案
這種方式雖然能夠滿足快取播放這個需求,但是會產生很多問題,例如視訊下載到本地,下載多少才可以把本地檔案作為視訊源傳給播放器即視訊開啟播放速度;播放的速度大於下載速度的話,該怎麼辦?如果播放器seek到檔案沒有快取的位置,應該怎麼處理?對於視訊關閉之後,第二次進入如何知道已經下載了多少?等等問題。

目前的已有解決方案是,當快取到500kb才把快取的地址傳給播放器,視訊檔案小於500kb則下載完之後再播放,起播慢(需要改進)。當下載進度比播放進度多5秒的資料量才讓播放器播放,不然的話就暫停。如果seek到沒有快取的地方就切換到網路上停止當前的下載,浪費一些流量。每次下載都會儲存一份配置檔案,來儲存是否下載完成,沒下載完成則第二次根據當前快取檔案大小,重新開始順序下載。

總的來說第一種方案有如下缺點:

  1. 使用者播放視訊的時候可能等待的時間較長(起播
  2. 流量浪費(seek之後會播網路流,停止下載)
  3. 需要太多控制視訊播放的邏輯來進行輔助,與播放器程式碼耦合嚴重。
  4. seek之後切源會耗時,每次seek比較慢

2.2 方案二

  • 使用的本地代理伺服器的方式: 在伺服器端(機器人端)支援分片下載的方式下,APP內建一個HTTPServer代理伺服器,代理伺服器實現將資料快取到本地,同時App的播放器之間重代理伺服器獲取播放資料。這種實現方式比較複雜一點,如果處理不好,容易導致crash的問題。

這個代理伺服器也可以做在機器人端,一個介面用於播放,一個介面用於下載。

代理伺服器方式實現邊下邊播

使用 HTTPServer,在本地開啟一個 http 伺服器,把需要快取的請求地址指向本地伺服器,並帶上真正的 url 地址。HTTPServer 不管我們有沒有使用快取功能,都要在應用開啟的時候默默開啟,對APP效能是一大損耗。並且我們引入 HTTPServer 庫也會增加一些包體積。

2.2.1 技術要點

此方案的特點如下:

  1. 通過代理伺服器,從socket擷取播放器請求資料;
  2. 根據擷取的range資訊,從網路伺服器請求視訊資料;
  3. 視訊資料寫入本地檔案,seek後可以從seek位置繼續寫入並播放;
  4. 邊下邊播,加快播放速度;
  5. 與播放器邏輯完全解耦,對於播放器只是一個地址

本方案是在播放器與視訊源伺服器之間加一層代理伺服器,擷取視訊播放器傳送的請求,根據擷取的請求,向網路伺服器請求資料,然後寫到本地。本地代理伺服器從檔案中讀取資料併傳送給播放器進行播放. 如下圖所示:

邊下邊播-代理伺服器模式流程

如上圖,具體流程細節如下:

  1. 啟動本地代理伺服器。
  2. 視訊源地址傳給本地代理伺服器。
  3. 將視訊源地址轉換成本地代理伺服器的地址作為播放器的視訊源地址。
  4. 播放器向本地代理伺服器傳送請求。
  5. 本地代理伺服器擷取這個請求,再根據解析出來請求的資訊向真正的伺服器發起請求。
  6. 本地代理伺服器開始接受資料,寫入檔案並將檔案資料再返回到播放器。
  7. 播放器接收到這些資料之後播放。
  8. seek之後重新進行以上步驟。

上面流程主要描述了代理伺服器實現的實時播放流程,下面重點探討一下代理伺服器的下載流程。

  • 下載流程實現

考慮到播放視訊的時候,使用者會拖動進度條進行seek,而此時需要從使用者拖動的位置進行下載,這樣會讓視訊檔案產生許多的空洞,如下圖所示:

圖1-seek檔案
為了節省流量,只會下載檔案中沒有資料的部分,也就是上圖1藍色的部分。因此需要儲存下載的片段資訊。目前採用的資料結構如下所示:

fragment = [start,end];
array = [fragment 0,fragment 1,fragment 2,fragment 3];
複製程式碼
  1. 其中fragment指的是下載的片段,start指的是片段開始的位置,end為片段的結束位置。
  2. array指的是儲存fragment的陣列,陣列中的fragment是依靠start從小到大來來插入到陣列中的,保證了陣列的有序性。
  3. 下載的片段是記錄在一個陣列中:array = [fragment0 ,fragment 1,fragment 2,fragment 3];

下載共分為兩個階段:seek階段補洞階段

  • seek階段:即為在播放的時候,根據使用者seek的位置來進行下載。

根據seek到的位置分為兩種情況:

  • 情況一:如果seek到的位置是在已有的片段中(例如圖中的seek1的位置,該處有資料),就從該片段(fragment1)的末尾請求資料(end1),直到下個片段的開始位置處(fragment2start),也就是向伺服器請求的range為:rang1 = (end1 ) —— start2; 這個片段下載完成後,假如把下載的片段記為fragment1.1,則會把fragment1fragment1.1fragment2合為一個片段為fragment1-2,則array = [fragment 0,fragement1-2,frament3];這次下載後的狀態圖2所示:

    圖2--情況一
    接下來一直下載直到array = [fragment 0,fragement1-3];之後會判斷fragement1-3有沒有到檔案末尾,如果到了就下載結束,如果沒到就從從fragement3的(end3)開始下載直到檔案末尾。

  • 情況二:如果seek到的位置沒有在已有的片段中,(例如說是在圖1中的seek2的位置),就從seek到的位置開始下載資料直到下一個片段的startfragment2start2),假如這個片段記為fragment1.1,則會把fragment1.1fragment2合併即陣列為:array= [fragment 0,fragment1,fagment1.1-2,fragment3]; 合併後的情況如下圖3所示:接下來的操作就是繼續下載,直到下載到檔案末尾;

    圖3--情況二
    如果片段太小儲存起來就會讓播放器下次播放的時候多傳送一次請求,這樣是很耗費資源。例如:如上圖3所示,如果fragment1的大小隻有1kb,想要補充fragment0fragment1.1-2之間的資料,就需要傳送兩次請求,這樣頻繁的傳送請求,比較浪費資源。因此當fragment太小,就不存在配置陣列中。這樣會少發一次請求,也不會浪費很大的流量。當下載片段太小(例如說下載的長度<20KB),就不儲存在片段陣列中(為了控制片段的粒度)。這樣會產生一個問題,當視訊檔案中間有一個空洞小於20KB,這個片段永遠補不上。這個時候就需要用到第二階段-補洞階段

  • 補洞階段: 第二階段補洞階段,就是第二次播放的時候,如果檔案中有空洞,這個時候不論片段再小,也會存到片段中。 最後當配置陣列中存的資料只剩下最後的{0,length}length為視訊總長度的時候,表示檔案已全部下載完成。

2.3 方案三

對於IOS平臺來說,還有一種更好的方案:使用IOS原生API ,使用 AVAssetResourceLoader,在不改變 AVPlayer API 的情況下,對播放的音視訊進行快取。

方案三跟方案二原理差不多,只不過是藉助IOS原始API來實現的。

  • 使用IOS系統自動API 實現視訊邊下邊播功能:

這裡的邊下邊播不是單獨開一個子執行緒去下載,而是把視訊播放的資料給儲存到本地。簡而言之,就是使用一遍的流量,既播放了視訊,也儲存了視訊。

具體實現方案如下:

  1. 需要在視訊播放器和伺服器之間新增一層類似代理的機制,視訊播放器不再直接訪問伺服器,而是訪問代理物件,代理物件去訪問伺服器獲得資料,之後返回給視訊播放器,同時代理物件根據一定的策略快取資料。
  2. AVURLAsset中的resourceLoader可以實現這個機制,resourceLoader的delegate就是上述的代理物件。
  3. 視訊播放器在開始播放之前首先檢測是本地cache中是否有此視訊,如果沒有才通過代理獲得資料,如果有,則直接播放本地cache中的視訊即可。
  4. 如果是用HTTP的方式,上述3步可以實現邊下邊播功能,如果是HTTPS,伺服器證書使用的是證書頒發機構簽名的證書,則也可以直接跟HTTP方式一樣處理。但是,如果是HTTPS+自簽名證書的方式,則需要在resourceLoader每次方式請求前,先校驗證書,也就是下面的第5步

2.3.1 AVPlayer實現邊下邊播流程

我們先來參考網上播放QQ音樂邊下邊播流程圖如下:

整個邊下邊播流程圖如下
QQ 音樂實現的快取策略大致如下:

先觀察並猜測企鵝音樂的快取策略(當然它不是用AVPlayer播放):   1、開始播放,同時開始下載完整的檔案,當檔案下載完成時,儲存到快取資料夾中;   2、當seek時    (1)如果seek到已下載到的部分,直接seek成功;(如下載進度60%,seek進度50%)    (2)如果seek到未下載到的部分,則開始新的下載(如下載進度60%,seek進度70%)       PS1:此時檔案下載的範圍是70%-100%       PS2:之前已下載的部分就被刪除了       PS3:如果有別的seek操作則重複步驟2,如果此時再seek到進度40%,則會開始新的下載(範圍40%-100%)   3、當開始新的下載之後,由於檔案不完整,下載完成之後不會儲存到快取資料夾中;   4、下次再播放同一歌曲時,如果在快取資料夾中存在,則直接播放快取檔案;

我們使用AVPlayer 來實現邊下邊播的大致流程跟上面QQ音樂的快取機制差不多,就是依賴於AVAssetResourceLoader. 大致流程如下:

AVPlayer播放流程

如上圖所示,我們簡單描述一下AVPlayer實現邊下邊播的流程:

  1. 當開始播放視訊時,通過視訊url判斷本地cache中是否已經快取當前視訊,如果有,則直接播放本地cache中視訊
  2. 如果本地cache中沒有視訊,則視訊播放器向代理請求資料
  3. 載入視訊時展示正在載入的提示(菊花轉)
  4. 如果可以正常播放視訊,則去掉載入提示,播放視訊,如果載入失敗,去掉載入提示並顯示失敗提示
  5. 在播放過程中如果由於網路過慢或拖拽原因導致沒有播放資料時,要展示載入提示,跳轉到第4步

快取代理策略:

  1. 當視訊播放器向代理請求dataRequest時,判斷代理是否已經向伺服器發起了請求,如果沒有,則發起下載整個視訊檔案的請求 2.如果代理已經和伺服器建立連結,則判斷當前的dataRequest請求的offset是否大於當前已經快取的檔案的offset,如果大於則取消當前與伺服器的請求,並從offset開始到檔案尾向伺服器發起請求(此時應該是由於播放器向後拖拽,並且超過了已快取的資料時才會出現)
  2. 如果當前的dataRequest請求的offset小於已經快取的檔案的offset,同時大於代理向伺服器請求的range的offset,說明有一部分已經快取的資料可以傳給播放器,則將這部分資料返回給播放器(此時應該是由於播放器向前拖拽,請求的資料已經快取過才會出現)
  3. 如果當前的dataRequest請求的offset小於代理向伺服器請求的range的offset,則取消當前與伺服器的請求,並從offset開始到檔案尾向伺服器發起請求(此時應該是由於播放器向前拖拽,並且超過了已快取的資料時才會出現)
  4. 只要代理重新向伺服器發起請求,就會導致快取的資料不連續,則載入結束後不用將快取的資料放入本地cache
  5. 如果代理和伺服器的連結超時,重試一次,如果還是錯誤則通知播放器網路錯誤
  6. 如果伺服器返回其他錯誤,則代理通知播放器網路錯誤

2.3.2 AVPlayer相關API簡介

IOS 播放網路視訊我們一般使用AVFoundation框架裡面的AVPlayer去實現自定義播放器,但是AVPlayer的相關API都是高度封裝的,這樣我們播放網路視訊時,往往不能控制其內部播放邏輯,比如我們會發現播放時seek會失敗,資料載入完畢後不能獲取到資料檔案進行其他操作,因此我們需要尋找彌補其不足之處的方法,這裡我們選擇了AVAssetResourceLoader。我們這裡實現邊下邊播功能也是依賴於它。

先來了解一下AVAssetResourceLoader的作用:讓我們自行掌握AVPlayer資料的載入,包括獲取AVPlayer需要的資料的資訊,以及可以決定傳遞多少資料給AVPlayer。

我們大致瞭解一下AVPlayer的元件圖:

AVPlayer元件圖

AVAssetResourceLoader:一個 iOS 6 就被開放出來,專門用來處理 AVAsset 載入的工具。這個完全滿足JimuPro執行在IOS10以上的要求。

AVAssetResourceLoader 有一個AVAssetResourceLoaderDelegate代理,這個代理有兩個重要的介面:

  • 要求載入資源的代理方法,這時我們需要儲存loadingRequest並對其所指定的資料進行讀取或下載操作,當資料讀取或下載完成,我們可以對loadingRequest進行完成操作。
- (BOOL)resourceLoader:(AVAssetResourceLoader *)resourceLoader 
shouldWaitForLoadingOfRequestedResource:(AVAssetResourceLoadingRequest *)loadingRequest;
複製程式碼
  • 取消載入資源的代理方法,這時我們需要取消loadingRequest所指定的資料的讀取或下載操作。
- (void)resourceLoader:(AVAssetResourceLoader *)resourceLoader 
didCancelLoadingRequest:(AVAssetResourceLoadingRequest *)loadingRequest;
複製程式碼

我們只要找一個物件實現了 AVAssetResourceLoaderDelegate 這個協議的方法,丟給 asset,再把 asset 丟給 AVPlayer,AVPlayer 在執行播放的時候就會去問這個 delegate:喂,你能不能播放這個 url 啊?然後會觸發下面這個方法:- (BOOL)resourceLoader:(AVAssetResourceLoader *)resourceLoader shouldWaitForLoadingOfRequestedResource:(AVAssetResourceLoadingRequest *)loadingRequest

我們在這個方法中看看 request 裡面的 url 是不是我們支援的,如果能支援就返回 YES!然後就可以開心的一邊下視訊資料,一邊塞資料給 AVPlayer 讓它顯示視訊畫面。

AVUrlAsset在請求自定義的URLScheme資源的時候會通過AVAssetResourceLoader例項來進行資源請求。它是AVUrlAsset的屬性,宣告如下:var resourceLoader: AVAssetResourceLoader { get }

AVAssetResourceLoader請求的時候會把相關請求(AVAssetResourceLoadingRequest)傳遞給AVAssetResourceLoaderDelegate(如果有實現的話),我們可以儲存這些請求,然後構造自己的NSUrlRequset來傳送請求,當收到響應的時候,把響應的資料設定給AVAssetResourceLoadingRequest,並且對資料進行快取,就完成了邊下邊播,整個流程大體如下圖:

AVAssetResourceLoaderDelegate實現邊下邊播流程
其中最為複雜的部分是資料偏移處理,因為資料是分塊下載和分塊填充的,我們的需要填充的物件是AVAssetResourceLoadingDataRequest,需要控制好currentOffset

下面我們將來詳細的介紹使用AVPlayer和AVAssetResourceLoaderDelegate來實現邊下邊播的具體實現。

3 HTTP邊下邊播 mp4檔案 實現細節

目前網上有好多關於IOS邊下邊播的程式碼,其實原理都是一樣的,只是實現方式,細節不一樣,這裡推薦兩個比較好的開原始碼:

  • OC版本:VIMediaCache 目前git上面有642顆星星,相當不錯。
  • Swift版本:VGPlayer 目前git上面有363顆星星,功能也比較完善,這是我比較推薦的。

3.1 邊下邊播原理

邊下邊播的原理已經在上面的3種方案介紹中詳細描述了,這裡主要是基於第三種方案用AVPlayer 來實現邊下邊播。這裡先拋開HTTPS字簽證書的簽名認證問題,先講解基於HTTP方式的邊下邊播,主流程圖如下:

AVPlayer邊下邊播主流程

整個過程就是分為兩大塊,一塊是實時播放視訊,一塊就是快取策略下載視訊。

3.1.1 實時播放原理

我們先來看第一塊,實時播放視訊(先不管下載和快取),實現上,我們可以分為兩步:

  1. 需要知道如何請求資料,url 是什麼,下載多少資料。
  2. 下載好的資料怎麼塞給 AVPlayer

3.1.1.1 請求資料

在上面的回撥方法中,會得到一個 AVAssetResourceLoadingRequest 物件,它裡面的屬性和方法不多,為了減少干擾,我精簡了一下這個類的標頭檔案,只留下我們會用到以及需要解釋的屬性和方法:

@interface AVAssetResourceLoadingRequest : NSObject 

 @property (nonatomic, readonly) NSURLRequest *request;

 @property (nonatomic, readonly, nullable) AVAssetResourceLoadingContentInformationRequest *contentInformationRequest NS_AVAILABLE(10_9, 7_0);

 @property (nonatomic, readonly, nullable) AVAssetResourceLoadingDataRequest *dataRequest NS_AVAILABLE(10_9, 7_0);

 - (void)finishLoading NS_AVAILABLE(10_9, 7_0);

 - (void)finishLoadingWithError:(nullable NSError *)error;

 @end 
複製程式碼

AVAssetResourceLoadingRequest 裡面,request 代表原始的請求,由於 AVPlayer 是會觸發分片下載的策略,還需要從dataRequest 中得到請求範圍的資訊。有了請求地址和請求範圍,我們就可以重新建立一個設定了請求 Range 頭的 NSURLRequest 物件,讓下載器去下載這個檔案的 Range 範圍內的資料。

3.1.1.2 賽資料給AVPlayer

AVPlayer 觸發下載時,總是會先發起一個 Range0-2 的資料請求,這個請求的作用其實是用來確認視訊資料的資訊,如檔案型別、檔案資料長度。當下載器發起這個請求,收到服務端返回的 response 後,我們要把視訊的資訊填充到 AVAssetResourceLoadingRequestcontentInformationRequest 屬性中,告知下載的視訊格式以及視訊長度。

AVAssetResourceLoadingRequest- (void)finishLoading 的時候,會根據 contentInformationRequest 中的資訊,去判斷接下去要怎麼處理。例如:下載 AVURLAsset 中 URL 指向的檔案,獲取到的檔案的 contentType 是系統不支援的型別,這個 AVURLAsset 將無法正常播放。

獲取完視訊資訊後,會收到剛才指定的 2 Bytedata 資料,下載到的資料怎麼辦? 可以塞給 AVAssetResourceLoadingRequest 裡的 dataRequestdataRequest 裡面用 - (void)respondWithData:(NSData *)data; 專門用來接收下載的資料,這個方法可以呼叫多次,接收增量連續的 data 資料。

AVAssetResourceLoadingRequest 要求的所有資料都下載完畢,呼叫 - (void)finishLoading 完成下載,AVAssetResourceLoader 會繼續發起之後的資料片段的請求。如果本次請求失敗,可以直接呼叫 - (void)finishLoadingWithError:(nullable NSError *)error; 結束下載。

3.1.1.3 重試機制

在實際的測試中,發現AVAssetResourceLoader 在執行載入的時候,會時不時的觸發取消下載呼叫 - (void)resourceLoader:(AVAssetResourceLoader *)resourceLoader didCancelLoadingRequest:(AVAssetResourceLoadingRequest *)loadingRequest,然後重新發起載入請求的策略。如果下載了部分,那麼重新發起的下載請求會從還沒有下載的部分開始。

AVAssetResourceLoaderDelegate 中還有 3 個方法可以針對特殊場景做處理,不過在目前的環境中都用不到所以可以選擇不實現這些方法。

3.1.2 下載快取原理

通過上面實時播放原理的介紹,我們已經知道 AVAssetResourceLoaderDelegate 的實現機制,當 AVAsset 需要載入資料時會通過 delegate 告訴外部,外部接管整個視訊下載過程。

當我們接管了視訊下載,便可以對視訊資料做任何事情。比如:快取、記錄下載速度、獲得下載進度等等。

實現一個下載器,就是用 URLSession 開啟一個 DataTask 請求資料,把接收到的資料塞給 DataRequest 並寫入本地磁碟。在實現下載器時主要有三個注意的點:1. Range 請求 2. 可取消下載 3. 分片快取

3.1.2.1 Range 請求

  • 能夠通過Range分片請求,是實現實時播放,邊下邊播的關鍵。

每次得到的 LoadingRequest 帶有請求資料範圍的資訊,比如期望請求第 100 位元組到 500 位元組,在建立 URLRequest 時需要設定 HTTPHeaderRange 值。

NSString *range = [NSString stringWithFormat:@"bytes=%lld-%lld", fromOffset, endOffset];
[request setValue:range forHTTPHeaderField:@"Range"];
複製程式碼

引入分塊下載最大的複雜點在於對響應資料的contentOffset的處理上,好在AVAssetResourceLoader幫我們處理了大量工作,我們只需要用好AVAssetResourceLoadingRequest就可以了。

例如,下面是程式碼部分,首先是獲取原始請求和傳送新的請求

func resourceLoader(_ resourceLoader: AVAssetResourceLoader, shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest) -> Bool {
    if self.session == nil {
        //構造Session
        let configuration = URLSessionConfiguration.default
        configuration.requestCachePolicy = .reloadIgnoringLocalAndRemoteCacheData
        configuration.networkServiceType = .video
        configuration.allowsCellularAccess = true
        self.session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil)
    }
    //構造 儲存請求
    var urlRequst = URLRequest.init(url: self.initalUrl!, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 20) // 20s超時
    urlRequst.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type")
    urlRequst.httpMethod = "GET"
    //設定請求頭
    guard let wrappedDataRequest = loadingRequest.dataRequest else{
        //本次請求沒有資料請求
        return true
    }
    let range:NSRange = NSMakeRange(Int.init(truncatingBitPattern: wrappedDataRequest.requestedOffset), wrappedDataRequest.requestedLength)
    let rangeHeaderStr = "byes=\(range.location)-\(range.location+range.length)"
    urlRequst.setValue(rangeHeaderStr, forHTTPHeaderField: "Range")
    urlRequst.setValue(self.initalUrl?.host, forHTTPHeaderField: "Referer")
    guard let task = session?.dataTask(with: urlRequst) else{
        fatalError("cant create task for url")
    }
    task.resume()
    self.tasks[task] = loadingRequest
    return true
}
複製程式碼

收到響應請求後,抓包檢視響應的請求頭,下圖是2個響應的請求頭:

range請求響應
Content-Length表示本次請求的資料長度 Content-Range表示本次請求的資料在總媒體檔案中的位置,格式是start-end/total,因此就有Content-Length = end - start + 1

3.1.2.2 可取消下載

AVAsset 在載入視訊時,經常會在某次資料請求還沒有完成時觸發取消下載,然後發起一個新的 LoadingReqeust。這個機制是 AVAsset 裡的黑盒,具體邏輯無法得知,比較像是 AVAsset 的一種重試機制。 作為下載器,在收到取消通知時,需要立刻停止下載。由於 DataRequestcancel 操作是非同步的,就有可能在 cancel 還未完成時,下一個 LoadingRequest 就已經到來,所以還需要需要保證同一個 URL 只能同時存在一個下載器在下載,否則會出現資料混亂的問題。

3.1.2.3 分片快取

如果只是單純的下載視訊,資料單調遞增,快取處理還是比較容易。然而現實是使用者對 player 的 seek 操作給視訊的快取管理帶來了巨大的挑戰,一旦涉及到使用者操作,可能性就越多,複雜度也會越高。

沒有 seek 的情況:網速正常時快取資料比播放時間走得開,正常播放;網速慢時,播放器 loading,直到有足夠的資料量進行播放,如果網速一直很慢就會播幾秒卡一下。

當加入 seek 後會有三種可能:

  • 第一種情況,視訊完全下載好,這時 seek 只需讀取相應快取即可,這種情況最簡單,就直接從快取讀資料即可。

    圖3.1.2.3.1 - seek時視訊完成下載了

  • 第二種情況,視訊下載一半,使用者 seek 到未下載部分,LoadingRequest 請求的部分全部都是未下載的資料。這時需要取消正在下載的資料,然後從 seek 的點開始下載資料。為了支援 seek 操作,下載器就需要支援分片快取。目前使用的解決方案是下載的視訊資料會根據請求的 Range 值,把資料儲存到檔案中對應的偏移值位置,並且每個視訊檔案都會另外再儲存一個與之對應的下載資訊檔案。這個資訊檔案會記錄當前下載了多少資料,總共有多少資料,下載了哪些片段的資料等資訊,之後的快取管理會非常依賴這個配置檔案。

    圖3.1.2.3.2 - seek時都是未下載的部分

  • 第三種情況,視訊被 seek 了多次,使用者 seek 到一個時間點,LoadingRequest 請求的部分包含了已下載和未下載的部分。這種情況是最複雜的!簡單的做法是,當成上面的情況來處理,全部都重新下載,雖然邏輯簡單,但這個方案會下載多次同樣的資料,不是最最優解。我的目標當然是做最優的解決方案,但也是複雜高很多的解決方案。

    圖3.1.2.3.3 - seek時既有下載完的部分,又有未下載的部分
    在收到 LoadingRequest 的請求範圍後,下載器會先獲取已經下載的資料資訊,把已下載的分片資訊分別建立一個 action,再把需要遠端下載的分片資料分別建立一個 action。最終組合就可能是 LocalAction(50-100 bytes) + RemoteAction(101-200 bytes) + LocalAction(201-300 bytes) + RemoteAction(300-400 bytes)。每一個 action 會按順序獲取資料再返回給 LoadingRequest。如下圖:
    圖3.1.2.3.4 - seek時既有下載完的部分,又有未下載的部分,建立action

3.2 邊下邊播實現細節

  • 在下載視訊時,出現錯誤無法正常下載是比較容易出現的。我們自己實現了 AVAssetResourceLoaderDelegate 在第一次請求就丟擲錯誤的話,播放器會馬上提示錯誤狀態,而如果是已經響應了部分資料,再拋錯誤,AVAssetResourceLoader 會忽略錯誤而一直處於 loading,直到超時。這種情況就比較尷尬,在上面給出的VIMediaCache 實現中, VIResourceLoaderManager 提供了 delegate,如果內部出現錯誤,就會丟擲錯誤,再又外部業務決定是如何處理。

  • 同一時間同一個 url 不能有多次下載: 由於快取內部實現是對每一個 url 都共用同一個下載配置檔案,如果同時有多次對同一個 url 進行下載,這個檔案下載資訊會被同時修改,下載資訊會變得混亂。VIMediaCache 裡的 MediaCache 內部做了簡單的處理,如果正在下載某 url,這時再想嘗試下載同樣的 url 會直接丟擲錯誤,提示無法開始下載。

  • 實際上VGPlayer只是參考VIMediaCache 方式的Swift版本實現,VIMediaCache 是真的大牛編寫的OC版本,值得好好研究。

  • 鑑於我們JimuPro工程師純swift專案,裡面處了第三方庫沒有使用OC程式碼,所以我優先選擇VGPlayer來實現機器人端到IOS app端的邊下邊播功能。

  • 由於VGPlayer沒有實現HTTPS的證書驗證,這裡我只需要簡單實現證書驗證程式碼即可。我們將在下面講解HTTPS的證書認證實現。這裡我簡單說一下我的實現, 在VGPlayerDownloadURLSessionManager.swift檔案的VGPlayerDownloadURLSessionManager類裡面增加一個URLSession的一個代理實現:

    增加一個URLSession的一個代理實現

  • 即使你參考上面的原始碼實現了邊下載邊播放,還是有些細節地方需要注意的: 例如要實現mp4檔案的邊下邊播功能,不僅依賴於上面講解的邊下邊播實現方案,還依賴於mp4的檔案格式。如果遇到這種mp4檔案的後設資料放在檔案末尾的,我們需要在伺服器端將mp4檔案做一下轉換才可以實現邊下邊播功能。

接下來詳細講解一下mp4格式處理問題。

3.3 邊下邊播mp4檔案格式需要注意

我們要明確一點就是即使你用上面的快取方式實現了邊下邊播的功能,並不是所有mp4都支援的,這個需要你理解邊下邊播的原理。

mp4視訊檔案頭中,包含一些後設資料。後設資料包含:視訊的寬度高度、視訊時長、編碼格式等。mp4後設資料通常在視訊檔案的頭部,這樣播放器在讀取檔案時會最先讀取視訊的後設資料,然後開始播放視訊。

當然也存在這樣一種情況:mp4視訊的後設資料處於視訊檔案最後,這樣播放器在載入視訊檔案時,一直讀取到最後,才讀取到視訊資訊,然後開始播放。如果缺少後設資料,也是這樣的情況。這就出現了mp4視訊不支援邊載入、邊播放的問題。

  • 為啥會出現上面說的這種情況呢,下面我們簡單分析一下原理:

在請求頭裡有一個Range:byte欄位來告訴媒體伺服器需要請求的是哪一段特定長度的檔案內容,對於MP4檔案來說,所有資料都封裝在一個個的box或者atom中,其中有兩個atom尤為重要,分別是moov atom和mdat atom。

  • moov atom:包含媒體的後設資料的資料結構,包括媒體的塊(box)資訊,格式說明等等。
  • mdat atom: 包含媒體的媒體資訊,對於視屏來說就是視訊畫面了。

在IOS中傳送一個請求,利用NSUrlSession直接請求視訊資源,針對元資訊在視訊檔案頭部的視訊可以實現邊下邊播,而元資訊在視訊尾部的視訊則會下載完才播放,為啥會這樣呢?

答案就是:雖然moov和mdat都只有一個,但是由於MP4檔案是由若干個這樣的box或者atom組成的,因此這兩個atom在不同媒體檔案中出現的順序可能會不一樣,為了加快流媒體的播放,我們可以做的優化之一就是手動把moov提到mdat之前。 對於AVPlayer來說,只有到AVPlayerItemStatusReadyToPlay狀態時,才可以開始播放視訊,而進入AVPlayerItemStatusReadyToPlay狀態的必要條件就是播放器讀到了媒體的moov塊。

如果mdat位於moov之後,那麼這樣的mp4視訊檔案是無法實現邊下邊播放的。要支援邊下邊播的mp4視訊需要滿足moov和mdat都位於檔案頭部,且moov位於mdat之前。如下圖所示:

moov位於mdat之前
當moov和mdat都位於檔案頭部,且moov位於mdat之前。我們理論上一個請求就可以播放所有的moov位於mdat之前的視訊的。但是,當我們seek拖拽播放的話,情況就變很複雜了,需要藉助分塊下載。

那麼,如果遇到這種mp4檔案的後設資料放在檔案末尾的,我們需要在伺服器端將mp4檔案做一下轉換才可以實現邊下邊播功能。

可行的方法是使用的是qt-faststart工具。 qt-faststart能夠將處於MP4檔案末尾的moov atom後設資料轉移到最前面,不過由於qt-faststart工具只能處理moov atom後設資料位於MP4末尾的檔案。 如果我們想要將所有檔案統一處理:整體思路是將MP4檔案通過ffmpeg處理,將moov atom後設資料轉移至末尾,然後使用qt-faststart工具轉移至最前面。

3.3.1 mp4 後設資料特殊處理

  1. 先將下載的FFmpeg包解壓:tar -jxvf ffmpeg-3.3.3.tar.bz2
  2. 配置:./configure --enable-shared --prefix=/usr/local/ffmpeg prefix就是設定安裝位置,一般都預設usr/local下。
  3. 安裝:
make
make install
複製程式碼

編譯安裝時間會很長,10分鐘左右吧,裝完以後可以去安裝目錄下檢視。 這時還沒有結束,現在使用的話一般會報如下錯誤:

ffmpeg: error while loading shared libraries: libavfilter.so.1: cannot open shared object file: No such file or directory
複製程式碼
  1. 需要編輯/etc/ld.so.conf檔案加入如下內容:/usr/local/lib,儲存退出後執行ldconfig命令。
echo "/usr/local/ffmpeg/lib" >> /etc/ld.so.conf
#注意這裡是你前面安裝ffmpeg的路徑
ldconfig
複製程式碼
  • qt-faststart 安裝 上面講到的qt-faststart工具其實就在ffmpeg的原始碼中有,因為在ffmpeg解壓完的檔案中存在qt-faststart的原始碼,所以直接使用,位置在解壓路徑/tools/qt-faststart.c

如果你想單獨下載點選這裡: qt-faststart下載 6. 進入ffmpeg解壓路徑執行命令:make tools/qt-faststart,會看到在tools中會出現一個qt-faststart檔案(還有一個.c檔案) 7. ffmpeg將後設資料轉移至檔案末尾:

cd ffmpeg安裝路徑/bin;./ffmpeg -i /opt/mp4test.mp4 -acodec copy -vcodec copy /opt/1.mp4
# /opt/mp4test.mp4為原始MP4檔案路徑,/opt/1.mp4為生成檔案的存放路徑

複製程式碼
  1. qt-faststart 將後設資料轉移到檔案開頭:
cd ffmpeg壓縮包解壓路徑/tools;
./qt-faststart /opt/1.mp4 /opt/2.mp4
複製程式碼

4 HTTPS 邊下邊播 自簽名證書認證

  • HTTPS SSL加密建立連線過程

如下圖:

HTTPS SSL加密建立連線過程
過程詳解:

  1. ①客戶端的瀏覽器向伺服器傳送請求,並傳送客戶端SSL 協議的版本號,加密演算法的種類,產生的隨機數,以及其他伺服器和客戶端之間通訊所需要的各種資訊。
  2. ②伺服器向客戶端傳送SSL 協議的版本號,加密演算法的種類,隨機數以及其他相關資訊,同時伺服器還將向客戶端傳送自己的證書。
  3. ③客戶端利用伺服器傳過來的資訊驗證伺服器的合法性,伺服器的合法性包括:證書是否過期,發行伺服器證書的CA 是否可靠,發行者證書的公鑰能否正確解開伺服器證書的“發行者的數字簽名”,伺服器證書上的域名是否和伺服器的實際域名相匹配。如果合法性驗證沒有通過,通訊將斷開;如果合法性驗證通過,將繼續進行第四步。
  4. ④使用者端隨機產生一個用於通訊的“對稱密碼”,然後用伺服器的公鑰(伺服器的公鑰從步驟②中的伺服器的證書中獲得)對其加密,然後將加密後的“預主密碼”傳給伺服器。
  5. ⑤如果伺服器要求客戶的身份認證(在握手過程中為可選),使用者可以建立一個隨機數然後對其進行資料簽名,將這個含有簽名的隨機數和客戶自己的證書以及加密過的“預主密碼”一起傳給伺服器。
  6. ⑥如果伺服器要求客戶的身份認證,伺服器必須檢驗客戶證書和簽名隨機數的合法性,具體的合法性驗證過程包括:客戶的證書使用日期是否有效,為客戶提供證書的CA 是否可靠,發行CA 的公鑰能否正確解開客戶證書的發行CA 的數字簽名,檢查客戶的證書是否在證書廢止列表(CRL)中。檢驗如果沒有通過,通訊立刻中斷;如果驗證通過,伺服器將用自己的私鑰解開加密的“預主密碼”,然後執行一系列步驟來產生主通訊密碼(客戶端也將通過同樣的方法產生相同的主通訊密碼)。
  7. ⑦伺服器和客戶端用相同的主密碼即“通話密碼”,一個對稱金鑰用於SSL 協議的安全資料通訊的加解密通訊。同時在SSL 通訊過程中還要完成資料通訊的完整性,防止資料通訊中的任何變化。
  8. ⑧客戶端向伺服器端發出資訊,指明後面的資料通訊將使用的步驟. ⑦中的主密碼為對稱金鑰,同時通知伺服器客戶端的握手過程結束。
  9. ⑨伺服器向客戶端發出資訊,指明後面的資料通訊將使用的步驟⑦中的主密碼為對稱金鑰,同時通知客戶端伺服器端的握手過程結束。
  10. ⑩SSL 的握手部分結束,SSL 安全通道的資料通訊開始,客戶和伺服器開始使用相同的對稱金鑰進行資料通訊,同時進行通訊完整性的檢驗。
  • 我這裡只給出我專案裡面使用VGPlayer播放器裡的HTTPS證書認證方式實現程式碼,只需要簡單的兩部即可實現:
  1. 先將伺服器給你自簽名證書新增到工程裡面:
    匯入自簽名證書
  2. 在VGPlayerDownloadURLSessionManager.swift檔案的VGPlayerDownloadURLSessionManager類裡面增加一個URLSession的一個代理實現:
    增加一個URLSession的一個代理實現
public func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
        let method = challenge.protectionSpace.authenticationMethod
        if method == NSURLAuthenticationMethodServerTrust {
            //驗證伺服器,直接信任或者驗證證書二選一,推薦驗證證書,更安全
            completionHandler( HTTPSManager.trustServerWithCer(challenge: challenge).0, HTTPSManager.trustServerWithCer(challenge: challenge).1)
            
        } else if method == NSURLAuthenticationMethodClientCertificate {
            //認證客戶端證書
             
            completionHandler( HTTPSManager.sendClientCer().0, HTTPSManager.sendClientCer().1)
            
        } else {
            //其他情況,不通過驗證
            completionHandler(.cancelAuthenticationChallenge, nil)
        }
    }
複製程式碼
  1. 認證類HTTPSManager的實現如下:
//
//  HTTPSManager.swift
//  JimuPro
//
//  Created by yulu kong on 2019/10/28.
//  Copyright © 2019 UBTech. All rights reserved.
//

import UIKit


class HTTPSManager: NSObject {
    
//    // MARK: - sll證書處理
//   static func setKingfisherHTTPS() {
//        //取出downloader單例
//        let downloader = KingfisherManager.shared.downloader
//        //信任Server的ip
//        downloader.trustedHosts = Set([ServerTrustHost.fileTransportIP])
//    }
//    
//   static func setAlamofireHttps() {
//        
//        SessionManager.default.delegate.sessionDidReceiveChallenge = { (session: URLSession, challenge: URLAuthenticationChallenge) in
//            
//            let method = challenge.protectionSpace.authenticationMethod
//            if method == NSURLAuthenticationMethodServerTrust {
//                //驗證伺服器,直接信任或者驗證證書二選一,推薦驗證證書,更安全
//                return HTTPSManager.trustServerWithCer(challenge: challenge)
////                return HTTPSManager.trustServer(challenge: challenge)
//                
//            } else if method == NSURLAuthenticationMethodClientCertificate {
//                //認證客戶端證書
//                return HTTPSManager.sendClientCer()
//                
//            } else {
//                //其他情況,不通過驗證
//                return (.cancelAuthenticationChallenge, nil)
//            }
//        }
//    }
    
    //不做任何驗證,直接信任伺服器
    static private func trustServer(challenge: URLAuthenticationChallenge) -> (URLSession.AuthChallengeDisposition, URLCredential?) {
        
        let disposition = URLSession.AuthChallengeDisposition.useCredential
        let credential = URLCredential.init(trust: challenge.protectionSpace.serverTrust!)
        return (disposition, credential)
        
    }
    
    //驗證伺服器證書
    static  func trustServerWithCer(challenge: URLAuthenticationChallenge) -> (URLSession.AuthChallengeDisposition, URLCredential?) {
        
        var disposition: URLSession.AuthChallengeDisposition = .performDefaultHandling
        var credential: URLCredential?
        
        //獲取伺服器傳送過來的證書
        let serverTrust:SecTrust = challenge.protectionSpace.serverTrust!
        let certificate = SecTrustGetCertificateAtIndex(serverTrust, 0)!
        let remoteCertificateData = CFBridgingRetain(SecCertificateCopyData(certificate))!
        
        //載入本地CA證書
//        let cerPath = Bundle.main.path(forResource: "oooo", ofType: "cer")!
//        let cerUrl = URL(fileURLWithPath:cerPath)
        
        let cerUrl = Bundle.main.url(forResource: "server", withExtension: "cer")!
        let localCertificateData = try! Data(contentsOf: cerUrl)
        
        if (remoteCertificateData.isEqual(localCertificateData) == true) {
            //伺服器證書驗證通過
            disposition = URLSession.AuthChallengeDisposition.useCredential
            credential = URLCredential(trust: serverTrust)
            
        } else {
            //伺服器證書驗證失敗
            //disposition = URLSession.AuthChallengeDisposition.cancelAuthenticationChallenge
            disposition = URLSession.AuthChallengeDisposition.useCredential
            credential = URLCredential(trust: serverTrust)
        }
        
        return (disposition, credential)
        
    }
    
    //傳送客戶端證書交由伺服器驗證
    static  func sendClientCer() -> (URLSession.AuthChallengeDisposition, URLCredential?) {
        
        let disposition = URLSession.AuthChallengeDisposition.useCredential
        var credential: URLCredential?
        
        //獲取專案中P12證書檔案的路徑
        let path: String = Bundle.main.path(forResource: "clientp12", ofType: "p12")!
        let PKCS12Data = NSData(contentsOfFile:path)!
        let key : NSString = kSecImportExportPassphrase as NSString
        let options : NSDictionary = [key : "123456"] //客戶端證書密碼
        
        var items: CFArray?
        let error = SecPKCS12Import(PKCS12Data, options, &items)
        
        if error == errSecSuccess {
            
            let itemArr = items! as Array
            let item = itemArr.first!
            
            let identityPointer = item["identity"];
            let secIdentityRef = identityPointer as! SecIdentity
            
            let chainPointer = item["chain"]
            let chainRef = chainPointer as? [Any]
            
            credential = URLCredential.init(identity: secIdentityRef, certificates: chainRef, persistence: URLCredential.Persistence.forSession)
        }
        
        return (disposition, credential)
    }
}


複製程式碼

6 播放器底層原理

6.1 視訊格式簡介

  • mp4 也叫做MPEG-4 官方介紹如下:
  1. MP4是一套用於音訊、視訊資訊的壓縮編碼標準,由國際標準化組織(ISO)和國際電工委員會(IEC)下屬的“動態影像專家組”(Moving Picture Experts Group,即MPEG)制定,第一版在1998年10月通過,第二版在1999年12月通過。MPEG-4格式的主要用途在於網上流、光碟、語音傳送(視訊電話),以及電視廣播。
  2. MPEG-4包含了MPEG-1及MPEG-2的絕大部份功能及其他格式的長處,並加入及擴充對虛擬現實模型語言(VRML , VirtualReality Modeling Language)的支援,物件導向的合成檔案(包括音效,視訊及VRML物件),以及數字版權管理(DRM)及其他互動功能。而MPEG-4比MPEG-2更先進的其中一個特點,就是不再使用巨集區塊做影像分析,而是以影像上個體為變化記錄,因此儘管影像變化速度很快、位元速率不足時,也不會出現方塊畫面。
  • MP4標準 MPEG-4碼流主要包括基本碼流和系統流,基本碼流包括音視訊和場景描述的編碼流表示,每個基本碼流只包含一種資料型別,並通過各自的解碼器解碼。系統流則指定了根據編碼視聽資訊和相關場景描述資訊產生互動方式的方法,並描述其互動通訊系統。

  • MP4也可以理解成一種視訊的封裝格式 視訊封裝格式,簡稱視訊格式,相當於一種儲存視訊資訊的容器,它裡面包含了封裝視訊檔案所需要的視訊資訊、音訊資訊和相關的配置資訊(比如:視訊和音訊的關聯資訊、如何解碼等等)。一種視訊封裝格式的直接反映就是對應著相應的視訊檔案格式。

常見的封裝格式有如下:

常見的封裝格式有如下

封裝格式:就是將已經編碼壓縮好的視訊資料 和音訊資料按照一定的格式放到一個檔案中.這個檔案可以稱為容器. 當然可以理解為這只是一個外殼.

通常我們不僅僅只存放音訊資料和視訊資料,還會存放 一下視訊同步的後設資料.例如字幕.這多種資料會不同的程式來處理,但是它們在傳輸和儲存的時候,這多種資料都是被繫結在一起的.

  • 常見的視訊容器格式:
  1. AVI: 是當時為對抗quicktime格式(mov)而推出的,只能支援固定CBR恆定定位元率編碼的聲音檔案
  2. MOV:是Quicktime封裝
  3. WMV:微軟推出的,作為市場競爭
  4. mkv:萬能封裝器,有良好的相容和跨平臺性、糾錯性,可帶外掛字幕
  5. flv: 這種封裝方式可以很好的保護原始地址,不容易被下載到,目前一些視訊分享網站都採用這種封裝方式
  6. MP4:主要應用於mpeg4的封裝,主要在手機上使用。
  • 視訊編解碼方式

視訊編解碼的過程是指對數字視訊進行壓縮或解壓縮的一個過程. 在做視訊編解碼時,需要考慮以下這些因素的平衡:視訊的質量、用來表示視訊所需要的資料量(通常稱之為位元速率)、編碼演算法和解碼演算法的複雜度、針對資料丟失和錯誤的魯棒性(Robustness)、編輯的方便性、隨機訪問、編碼演算法設計的完美性、端到端的延時以及其它一些因素。

  • 常見視訊編碼方式:
  • H.26X 系列,由國際電傳視訊聯盟遠端通訊標準化組織(ITU-T)主導,包括 H.261、H.262、H.263、H.264、H.265
  1. H.261,主要用於老的視訊會議和視訊電話系統。是第一個使用的數字視訊壓縮標準。實質上說,之後的所有的標準視訊編解碼器都是基於它設計的。
  2. H.262,等同於 MPEG-2 第二部分,使用在 DVD、SVCD 和大多數數字視訊廣播系統和有線分佈系統中。
  3. H.263,主要用於視訊會議、視訊電話和網路視訊相關產品。在對逐行掃描的視訊源進行壓縮的方面,H.263 比它之前的視訊編碼標準在效能上有了較大的提升。尤其是在低位元速率端,它可以在保證一定質量的前提下大大的節約位元速率。
  4. H.264,等同於 MPEG-4 第十部分,也被稱為高階視訊編碼(Advanced Video Coding,簡稱 AVC),是一種視訊壓縮標準,一種被廣泛使用的高精度視訊的錄製、壓縮和釋出格式。該標準引入了一系列新的能夠大大提高壓縮效能的技術,並能夠同時在高位元速率端和低位元速率端大大超越以前的諸標準。
  5. H.265,被稱為高效率視訊編碼(High Efficiency Video Coding,簡稱 HEVC)是一種視訊壓縮標準,是 H.264 的繼任者。HEVC 被認為不僅提升影像質量,同時也能達到 H.264 兩倍的壓縮率(等同於同樣畫面質量下位元率減少了 50%),可支援 4K 解析度甚至到超高畫質電視,最高解析度可達到 8192×4320(8K 解析度),這是目前發展的趨勢。
  • MPEG 系列,由國際標準組織機構(ISO)下屬的運動圖象專家組(MPEG)開發。
  1. MPEG-1 第二部分,主要使用在 VCD 上,有些線上視訊也使用這種格式。該編解碼器的質量大致上和原有的 VHS 錄影帶相當。
  2. MPEG-2 第二部分,等同於 H.262,使用在 DVD、SVCD 和大多數數字視訊廣播系統和有線分佈系統中。
  3. MPEG-4 第二部分,可以使用在網路傳輸、廣播和媒體儲存上。比起 MPEG-2 第二部分和第一版的 H.263,它的壓縮效能有所提高。
  4. MPEG-4 第十部分,等同於 H.264,是這兩個編碼組織合作誕生的標準。

可以把「視訊封裝格式」看做是一個裝著視訊、音訊、「視訊編解碼方式」等資訊的容器。一種「視訊封裝格式」可以支援多種「視訊編解碼方式」,比如:QuickTime File Format(.MOV) 支援幾乎所有的「視訊編解碼方式」,MPEG(.MP4) 也支援相當廣的「視訊編解碼方式」。當我們看到一個視訊檔名為 test.mov 時,我們可以知道它的「視訊檔案格式」是 .mov,也可以知道它的視訊封裝格式是 QuickTime File Format,

但是無法知道它的「視訊編解碼方式」。那比較專業的說法可能是以 A/B 這種方式,A 是「視訊編解碼方式」,B 是「視訊封裝格式」。比如:一個 H.264/MOV 的視訊檔案,它的封裝方式就是 QuickTime File Format,編碼方式是 H.264

在這裡機器人裡面錄製視訊時採用H.264/mp4,所以這裡我這邊實現的邊下邊播方案裡面也是針對的這種H.264視訊編解碼方式的mp4容器格式的視訊檔案。

H264最大的優勢,具有很高的資料壓縮比率,在同等影像質量下,H264的壓縮比是MPEG-2的2倍以上,MPEG-4的1.5~2倍.

原始檔案的大小如果為88GB,採用MPEG-2壓縮標準壓縮後變成3.5GB,壓縮比為25∶1,而採用H.264壓縮標準壓縮後變為879MB,從88GB到879MB,H.264的壓縮比達到驚人的102∶1

  • 正常我們機器人採集到視訊流資料後,經過H264硬編碼,或者FFmpeg處理的H264軟編碼方式,將YUV4:2:0的資料進行H264編碼後得到編碼後的H264流資料。
  • 我們在IOS播放時,其實也是拿到這種一幀一幀的H264流資料,然後進行硬解碼或FFmpeg軟解碼。(硬解碼在IOS裡面是有VideoToolBox框架裡面的API可以實現,軟解碼需要使用FFmpeg裡的H264解碼器)。解碼後我們得到原始裸資料YUV資料,然後我們將YUV資料轉換為RGB資料,藉助OpenGL ES或Metal 以紋理渲染的方式,將影像顯示在View的 Layer上。
  • 其實這些解碼,播放相關底層程式碼都被我們的AVFoundation框架裡面的AVPlayer 封裝了,沒有暴露這些細節給我,我們只需要傳遞一個URL 就可以實現視訊播放功能。

為了更好的理解播放視訊的原理,我這裡還簡單介紹一下H264編解碼的相關知識

6.2 H264簡介

  • H264的碼流結構:H264視訊壓縮後會成為一個序列幀.幀裡包含影像,影像分為很多片.每個片可以分為巨集塊.每個巨集塊由許多子塊組成,如下圖:

    H264的碼流結構
    H264結構中,一個視訊影像編碼後的資料叫做一幀,一幀由一個片(slice)或多個片組成,一個片由一個或多個巨集塊(MB)組成,一個巨集塊由16x16的yuv資料組成。巨集塊作為H264編碼的基本單位。

  • 場和幀:視訊的一場或一幀可用來產生一個編碼影像。在電視中,為減少大面積閃爍現象,把一幀分成兩個隔行的場。

  • :每個圖象中,若干巨集塊被排列成片的形式。片分為I片、B片、P片和其他一些片。

  1. I片只包含I巨集塊,P片可包含P和I巨集塊,而B片可包含B和I巨集塊。
  2. I巨集塊利用從當前片中已解碼的畫素作為參考進行幀內預測。
  3. P巨集塊利用前面已編碼圖象作為參考圖象進行幀內預測。
  4. B巨集塊則利用雙向的參考圖象(前一幀和後一幀)進行幀內預測。
  5. 的目的是為了限制誤碼的擴散和傳輸,使編碼片相互間是獨立的。
  • H264碼流分層結構圖:
    H264碼流分層結構圖

A Annex格式資料,就是起始碼+Nal Unit 資料 NAL Unit: NALU 頭+NALU資料 NALU 主體,是由切片組成.切片包括切片頭+切片資料 Slice資料: 巨集塊組成 PCM類: 巨集塊型別+pcm資料,或者巨集塊型別+巨集塊模式+殘差資料 Residual: 殘差塊.

  • NAL 單元是由一個NALU頭部+一個切片.切片又可以細分成"切片頭+切片資料".我們之間瞭解過一個H254的幀是由多個切片構成的.因為一幀資料一次有可能傳不完. 如下圖:

    NAL 單元

  • 切片與巨集塊的關係(Slice & MacroBlock) 每個切片都包括切片頭+切片資料. 那每個切片資料包括了很多巨集塊.每個巨集塊包括了巨集塊的型別,巨集塊的預測,殘差資料. 如下圖:

    切片與巨集塊的關係

而我們在一副壓縮的H264的幀裡,可以包含多個切片.至少有一個切片,如下圖:

H264切片

瞭解了上面關於H264碼流的一些基本概念後,我們就能更好的理解H264編碼解碼的原理,以及影像渲染,視訊播放器的實現原理。

在H264解碼的過程中會涉及到一幀幀的資料,這裡有I幀,P幀,B幀,三個概念。

  • I幀: 關鍵幀,採用幀內壓縮技術.

舉個例子,如果攝像頭對著你拍攝,1秒之內,實際你發生的變化是非常少的.1秒鐘之內實際少很少有大幅度的變化.攝像機一般一秒鐘會抓取幾十幀的資料.比如像動畫,就是25幀/s,一般視訊檔案都是在30幀/s左右.對於一些要求比較高的,對動作的精細度有要求,想要捕捉到完整的動作的,高階的攝像機一般是60幀/s.那些對於一組幀的它的變化很小.為了便於壓縮資料,那怎麼辦了?將第一幀完整的儲存下來.如果沒有這個關鍵幀後面解碼資料,是完成不了的.所以I幀特別關鍵.

  • P幀: 向前參考幀.壓縮時只參考前一個幀.屬於幀間壓縮技術.

視訊的第一幀會被作為關鍵幀完整儲存下來.而後面的幀會向前依賴.也就是第二幀依賴於第一個幀.後面所有的幀只儲存於前一幀的差異.這樣就能將資料大大的減少.從而達到一個高壓縮率的效果.

  • B幀: 雙向參考幀,壓縮時即參考前一幀也參考後一幀.幀間壓縮技術.
  1. B幀,即參考前一幀,也參考後一幀.這樣就使得它的壓縮率更高.儲存的資料量更小.如果B幀的數量越多,你的壓縮率就越高.這是B幀的優點,但是B幀最大的缺點是,如果是實時互動的直播,那時與B幀就要參考後面的幀才能解碼,那在網路中就要等待後面的幀傳輸過來.這就與網路有關了.如果網路狀態很好的話,解碼會比較快,如果網路不好時解碼會稍微慢一些.丟包時還需要重傳.對實時互動的直播,一般不會使用B幀.

我們實時播放視訊時,每次從伺服器請求一個Range範圍的視訊幀,實際上伺服器是返回一組組的H264幀資料,一組幀資料又稱為GOF(Group of Frame),GOF 表示:一個I幀到下一個I幀.這一組的資料.包括B幀/P幀. 如下圖所示:

Group of Frame

  • 在H264碼流中,我們使用SPS/PPS來儲存GOP的引數。

  • SPS 序列引數集 :全稱是Sequence Parameter Set,序列引數集存放幀數,參考幀數目,解碼影像尺寸,幀場編碼模式選擇標識等.

  • PPS 影像引數集:全稱是Picture Parameter Set,影像引數集.存放編碼模式選擇標識,片組數目,初始量化引數和去方塊濾波係數調整標識等.(與影像相關的資訊)

在一組幀之前我們首先收到的是SPS/PPS資料.如果沒有這組引數的話,我們是無法解碼. 之前WebRTC視訊的時候遇到的一個問題就是:IOS端有時候圖傳的時候黑屏,這個原因就是因為I幀缺少SPS/PPS資訊,導致解碼失敗,導致的黑屏。

  • 如果我們在解碼時發生錯誤,首先要檢查是否有SPS/PPS.如果沒有,是因為對端沒有傳送過來還是因為對端在傳送過程中丟失了. SPS/PPS資料,我們也把其歸類到I幀.這2組資料是絕對不能丟的.

  • 視訊花屏,卡頓的原因分析: 我們在觀看視訊時,會遇到花屏或者卡頓現象.那這個與我們剛剛所講的GOF就息息相關了

  1. 如果GOP分組中的P幀丟失就會造成解碼端的影像發生錯誤.解碼錯誤時,我們把解碼失敗的圖片用來展示了,就導致我們看到的花屏現象
  2. 為了避免花屏問題的發生,一般如果發現P幀或者I幀丟失.就不顯示本GOP內的所有幀.只到下一個I幀來後重新重新整理影像.
  3. 當這時因為沒有重新整理螢幕.丟包的這一組幀全部扔掉了.影像就會卡在哪裡不動.這就是卡頓的原因.
  4. 所以總結起來,花屏是因為你丟了P幀或者I幀.導致解碼錯誤. 而卡頓是因為為了怕花屏,將整組錯誤的GOP資料扔掉了.直達下一組正確的GOP再重新刷屏.而這中間的時間差,就是我們所感受的卡頓.
  • 軟編碼與硬編碼
  • 硬編碼: 使用非CPU進行編碼,例如使用GPU晶片處理
  1. 效能高,低位元速率下通常質量低於硬編碼器,但部分產品在GPU硬體平臺移植了優秀的軟編碼演算法(如X264)的,質量基本等同於軟編碼。
  2. 硬編碼,就是使用GPU計算,獲取資料結果,優點速度快,效率高.
  3. 在IOS平臺針對視訊硬編碼使用VideoToolBox框架,針對音訊硬編碼使用AudioToolBox 框架
  • 軟編碼: 使用CPU來進行編碼計算.
  1. 實現直接、簡單,引數調整方便,升級易,但CPU負載重,效能較硬編碼低,低位元速率下質量通常比硬編碼要好一點。
  2. 軟編碼,就是通過CPU來計算,獲取資料結果.
  3. 在IOS平臺針對視訊軟編碼一般使用FFmpeg,X264演算法把視訊原資料YUV/RGB編碼成H264。針對音訊使用fdk_aac 將音訊資料PCM轉換成AAC。

如果想更加深入的探索播放器的底層原理,可以參考這兩款開源的播放器: ijkplayer,kxmovie 他們都是基於FFmpeg框架封裝的

  • ijkplayer是bilibili出品的一款基於FFmpeg的視訊播放器,在git上面已經有25.7k的星星了,非常強大,值得深入研究,這個包含ios,和android端的。
    ijkplayer
  • kxmovie 在git上面也有2.7k的星星,這是實力的認證,值得學習,研究。
    kxmovie

6.3 MP4 格式

MP4(MPEG-4 Part 14)是一種常見的多媒體容器格式,它是在“ISO/IEC 14496-14”標準檔案中定義的,屬於MPEG-4的一部分,是“ISO/IEC 14496-12(MPEG-4 Part 12 ISO base media file format)”標準中所定義的媒體格式的一種實現,後者定義了一種通用的媒體檔案結構標準。MP4是一種描述較為全面的容器格式,被認為可以在其中嵌入任何形式的資料,各種編碼的視訊、音訊等都不在話下,不過我們常見的大部分的MP4檔案存放的AVC(H.264)或MPEG-4(Part 2)編碼的視訊和AAC編碼的音訊。MP4格式的官方檔案字尾名是“.mp4”,還有其他的以mp4為基礎進行的擴充套件或者是縮水版本的格式,包括:M4V,3GP,F4V等。

首先看一下軟體對於mp4檔案的解析如下圖所示:

圖6.3.1 mp4檔案格式
從上圖圖6.3.1 中可以看出這個視訊檔案第一層有4部分,每一部分都是一個box,分別為:ftype,moov,free,mdat。其實mp4檔案是有許多的box組成的。如下圖6.3.2 所示:
圖6.3.2 - mp4檔案格式

box的基本結構如下圖6.3.3所示,其中,size指明瞭整個box所佔用的大小,包括header部分,type指明瞭box的型別。如果box很大(例如存放具體視訊資料的mdat box),超過了uint32的最大數值,size就被設定為1,並用接下來的8位uint64來存放大小。

圖6.3.3 - Box結構

一個mp4檔案有可能包含非常多的box,在很大程度上增加了解析的複雜性,這個網頁上http://mp4ra.org/atoms.html記錄了一些當前註冊過的box型別。看到這麼多box,如果要全部支援,一個個解析,怕是頭都要爆了。還好,大部分mp4檔案沒有那麼多的box型別,下圖就是一個簡化了的,常見的mp4檔案結構如下圖6.3.4所示

圖6.3.4 - mp4包含的box結構
一般來說,解析媒體檔案,最關心的部分是視訊檔案的寬高、時長、位元速率、編碼格式、幀列表、關鍵幀列表,以及所對應的時戳和在檔案中的位置,這些資訊,在mp4中,是以特定的演算法分開存放在stbl box下屬的幾個box中的,需要解析stbl下面所有的box,來還原媒體資訊。下表是對於以上幾個重要的box存放資訊的說明:
圖6.3.5 - mp4 box型別說明

6.4 IOS 原始API實現 將mp4檔案的 moov的box移到前面

上面已經講解過使用FFmpeg裡面的 qt-faststart下載工具可以實現將mp4檔案的 moov的box移到前面,從而讓mp4檔案支援邊下邊播功能。下面將介紹一種通過IOS原始程式碼的方式實現將mp4檔案的moov的box從檔案最後面移到前面。

不過這種方式一般用不到,一是因為效率問題,而是一般實現邊下邊播,都是由伺服器端去完成這種事情。

具體程式碼如下:

- (NSData*)exchangestco:(NSMutableData*) moovdata{

int i, atom_size, offset_count, current_offset;

NSString*atom_type;

longlongmoov_atom_size = moovdata.length;

Byte*buffer = (Byte*)malloc(5);

buffer[4] =0;

Byte*buffer01 = (Byte*)malloc(moov_atom_size);

[moovdatagetBytes:buffer01 length:moov_atom_size];

for(i =4; i < moov_atom_size -4; i++) {

NSRangerange;

range.location= I;

range.length=4;

[moovdatagetBytes:buffer range:range];

atom_type = [selftosType:buffer];

if([atom_typeisEqualToString:@"stco"]) {

range.location= i-4;

range.length =4;

[moovdatagetBytes:bufferrange:range];

atom_size = [selftoSize:buffer];

if(i + atom_size -4> moov_atom_size) {

WBLog(LOG_ERROR,@"error i + atom_size - 4 > moov_atom_size");

returnnil;

}

range.location= I+8;

range.length=4;

[moovdatagetBytes:bufferrange:range];

offset_count = [selftoSize:buffer];

for(intj =0; j < offset_count; j++) {

range.location= i +12+ j *4;

range.length=4;

[moovdatagetBytes:bufferrange:range];

current_offset= [selftoSize:buffer];

current_offset += moov_atom_size;

buffer01[i +12+ j *4+0] = (Byte) ((current_offset >>24) &0xFF);

buffer01[i +12+ j *4+1] = (Byte) ((current_offset >>16) &0xFF);

buffer01[i +12+ j *4+2] = (Byte) ((current_offset >>8) &0xFF);

buffer01[i +12+ j *4+3] = (Byte) ((current_offset >>0) &0xFF);

}

i += atom_size -4;

}

elseif([atom_typeisEqualToString:@"co64"]) {

range.location= i-4;

range.length=4;

[moovdatagetBytes:bufferrange:range];

atom_size = [selftoSize:buffer];

if(i + atom_size -4> moov_atom_size) {

WBLog(LOG_ERROR,@"error i + atom_size - 4 > moov_atom_size");

returnnil;

}

range.location= I+8;

range.length=4;

[moovdatagetBytes:bufferrange:range];

offset_count = [selftoSize:buffer];

for(intj =0; j < offset_count; j++) {

range.location= i +12+ j *8;

range.length=4;

[moovdatagetBytes:bufferrange:range];

current_offset = [selftoSize:buffer];

current_offset += moov_atom_size;

buffer01[i +12+ j *8+0] = (Byte)((current_offset >>56) &0xFF);

buffer01[i +12+ j *8+1] = (Byte)((current_offset >>48) &0xFF);

buffer01[i +12+ j *8+2] = (Byte)((current_offset >>40) &0xFF);

buffer01[i +12+ j *8+3] = (Byte)((current_offset >>32) &0xFF);

buffer01[i +12+ j *8+4] = (Byte)((current_offset >>24) &0xFF);

buffer01[i +12+ j *8+5] = (Byte)((current_offset >>16) &0xFF);

buffer01[i +12+ j *8+6] = (Byte)((current_offset >>8) &0xFF);

buffer01[i +12+ j *8+7] = (Byte)((current_offset >>0) &0xFF);

}

i += atom_size -4;

}

}

NSData*moov = [NSDatadataWithBytes:buffer01length:moov_atom_size];

free(buffer);

free(buffer01);

returnmoov;

}

複製程式碼

參考:www.jianshu.com/p/0188ab038… www.jianshu.com/p/bb925a4a9… www.cnblogs.com/ios4app/p/6… www.jianshu.com/p/990ee3db0…

相關文章