大家好,我是 NewPan,這次我們來講解 JPVideoPlayer 3.0 實現上的細節。
如果你沒有了解實現原理的需求,請直接看另外一篇介紹如何使用的文章:[iOS]JPVideoPlayer 3.0 使用介紹。
01. 漫長的選擇
從去年發了 2.0 版本以後,越來越多的同學使用這個框架, issue 也越來越多,一度有 90 多個,但是絕大多數是說使用這個框架並不能實現變下邊播,而是要下載完才能播。當時我也是頭大,我看了整個 AVFoundation
關於視訊播放的文件,蘋果除了留出了一個攔截 AVPlayer
的請求的介面,另外沒有任何關於對請求的處理的介紹。
文件沒有結果,就去 Google 上搜,網上的結果大致有三類,第一類就是說不可能基 AVPlayer
實現變下邊播;第二類是就是 2.0 版本時候的樣子,只能支援某些視訊的邊下邊播;第三類是說使用本地代理實現對埠的請求的攔截。我自己考慮還可以使用 ijkPlayer
實現邊下邊播。
我最先研究的是 ijkPlayer
,因為 FFmpeg
是開源的,只要從底層開始將播放器拿到請求資料回撥到上層,就能實現視訊資料的快取。當時看了差不多一週的 ijkPlayer
原始碼,整個 ijkPlayer
大概有四層封裝,最後才能看到 OC 的介面,我從最上層往下看,依次是 OC 層,iOS 平臺層,iOS和安卓共用層,最後才是 FFmepg,我看到第二層,到後面越來越難,而且隨著除錯的深入,發現在平臺特性上,記憶體、啟動時間、優化、效能真的不如 AVPlayer
,而且還有一點,現在很多 APP 都有直播功能,直播 SDK 都是使用 FFmpeg
,如果直接基於ijkPlayer
,會出現識別符號重複,很多人都沒辦法使用。於是選擇研究其他方案。
接下來開始研究使用本地代理實現對埠的請求的攔截,要實現對埠的攔截,GitHub 上有一個很有名的基於 GCD 的框架可以實現 GCDWebServer。這個框架的作者當時是為了做局網內實現 iPad 本地資料投屏到電視還是什麼鬼的做了這樣一個框架。這個框架的原理是對指定的埠進行攔截,然後讓 AVPlayer
往這個埠請求,然後就能攔截到 AVPlayer
的所有請求,然後把這個請求轉發給使用者,使用者可以響應本地的視訊資料,然後 AVPlayer
就可以開始播放了。這個很典型的使用場景就是,在局網內把 iPhone 或是 iPad 的本地資料分享給別的終端。這個想法挺棒的,我看到安卓有一個很棒的開源專案就是基於這個思路給安卓官方的播放器做的本地快取。所以去年過年那幾天都在研究這個框架。
但是這個GCDWebserver
的作者只做了本地資料的響應,也就是說,我本地有一個資料,其他地方來請求,我把這個本地資料一片一片的讀出來寫到 socket 裡,然後請求者就能拿到資料了,等這個資料寫完以後,這個 socket 就斷開了。但是我們現在要做的事情,我們的視訊資料不在本地,我們拿到請求以後還要去網路上請求資料才能響應資料給 socket,這個框架不符合我們的使用場景。所以我要做的就是,自己基於這個框架寫一個我們要用的功能。然後吭哧吭哧寫了好幾天,發現這些底層的寫起來真的很費勁,而且除錯也不容易。寫高階語言習慣了,已經不會寫底層了。
一次偶然逛 GitHub,看到了 AVPlayerCacheSupport 這個框架,發現這位作者實現了支援 seek 的快取,趕緊下載了原始碼下來看了一下,發現原來 JPVideoPlayer
2.0 有些視訊播不了是因為我對請求佇列的管理出了問題,所以我後來聯絡了這個框架的作者,請他授權我在我的框架中使用他部分原始碼,他慷慨答應,但是要加他微信,他就沒回我了。
到了這裡,我把之前基於埠攔截的方案給停了,因為從底層開始寫,真的效率太低了。而且既然 AVPlayer
提供了請求攔截的入口,我就沒必要自己再基於埠進行攔截了。
於是方案終於敲定,也就到了年後開工的時候了,現在想想,這個方案真的花了差不多半年的時間,也是挺不容易的。
02. 大致結構
接下來我們就把下面這張結構圖講清楚就可以了。
現在框架支援下面三種型別的視訊路徑的播放:
-
- 本地視訊,就是上圖綠色的
local file, play video
。這個是最簡單的,檢查一下 URL,如果是本地 URL,初始化一個JPVideoPlayer
,把路徑塞給它,立馬就開始播放了,沒什麼好講的。
- 本地視訊,就是上圖綠色的
-
- 一個全新的沒有任何快取的 URL,也就是紫色部分
network result, play video
。這個就複雜點,初始化一個播放器以後,攔截它的請求,然後把這個請求封裝成為自己內部的請求,然後去網路上下載視訊,下載下來響應播放器,同時也快取到本地。
- 一個全新的沒有任何快取的 URL,也就是紫色部分
-
- 一個已經播過一部分,有部分快取的 URL,上圖
disk result, play video
,和上面一樣要初始化播放器,然後攔截播放器請求,然後要先去快取中查一下這個請求,有哪些資料已經快取到本地了,那些已經快取到本地的資料就不用再去下載了,直接從磁碟中讀出來就可以了,那些沒快取的就按照第二點的思路去下載。然後整個過程就串起來了。
- 一個已經播過一部分,有部分快取的 URL,上圖
接下來熟悉一下整個類目結構:
我現在按照我當時寫的循序,從最低層開始,一點一點往上封裝,直到最後使用者看到的只有一個簡單的介面。
- 01.
AVPlayer
請求的截獲 - 02.
AVPlayer
請求佇列的管理 - 03.基於
JPResourceLoadingRequestTask
封裝本地和網路請求 - 04.
JPVideoPlayerCacheFile
如何管理斷點續傳 - 05.
JPResourceLoadingRequestTask
和JPVideoPlayerCacheFile
- 06.前後臺狀態管理
- 07.
UIView+WebVideoCache
介面如何封裝 - 08.
JPVideoPlayerControlViews
怎麼和播放業務完全解耦 - 09.假橫屏佈局有何問題
- 10.等高和不等高 cell 兩種情況兩種策略
03. AVPlayer
請求的截獲
// 獲取到新的請求
- (BOOL)resourceLoader:(AVAssetResourceLoader *)resourceLoader
shouldWaitForLoadingOfRequestedResource:(AVAssetResourceLoadingRequest *)loadingRequest;
// 取消請求
- (void)resourceLoader:(AVAssetResourceLoader *)resourceLoader
didCancelLoadingRequest:(AVAssetResourceLoadingRequest *)loadingRequest;
複製程式碼
一切都從這裡開始,我們從這裡拿到 AVPlayer
想要獲取的資料的請求,然後將這個請求儲存到陣列中,然後發起網路請求,拿回這個請求的資料,然後將這個資料傳回給播放器,視訊播放就開始了。
同時,播放器也可能會呼叫取消請求的回撥,告訴我們某個請求已經取消掉了,不用在請求資料了,此時我們就應該將這個請求從請求陣列中移除掉。
04. AVPlayer
請求佇列的管理
上面有說過,2.0 版本時對這個請求佇列的管理有問題導致有些視訊播不了。之前的處理方式是,一旦AVPlayer
有新的請求過來,就立馬將之前內部請求停止掉,然後發起新的請求。這樣導致的問題是,如果這個視訊的metadata
在視訊資料的最前面,那麼立馬可以拿到這個後設資料,就可以一個請求從頭播到尾。但是並不是所有的視訊在編碼的時候都把metadata
放在最前面,metadata
可能編碼在視訊資料的任何位置,就像下面這張圖一樣。這就是 2.0 為什麼有些視訊能播,有些視訊卻要下載完再播的原因。
在生產環境,大多都不是metadata
在視訊資料的最前面,所以AVPlayer
會不斷地調整請求的 range 來獲得視訊的metadata
。因為要播放一個視訊,必須要有metadata
,metadata
就是這個視訊的身份證資訊。下面我列出了使用青花瓷攔截一次視訊播放的請求。
Range: bytes=0-1 // 獲取請求的 contenttype,只響應視訊或音訊
Range: bytes=0-34611998 // 嘗試從頭到尾請求
Range: bytes=34537472-34611998 // 上次請求沒有拿到 metadata, 調整請求range
Range: bytes=34548960-34611998 // 上次請求沒有拿到 metadata, 調整請求range
Range: bytes=34603008-34611998 // 上次請求沒有拿到 metadata, 調整請求range
Range: bytes=34601692-34603007 // 上次請求沒有拿到 metadata, 調整請求range
Range: bytes=1388-34537471 // 獲取 metadata 成功,視訊開始播放
...
複製程式碼
可以看到,AVPlayer
的請求有一定的套路。第一次請求是拿到伺服器的響應,看這個 URL 是不是一個視訊或者音訊,如果不是視訊或者音訊,播放器就直接丟擲播放失敗的錯誤。如果是一個可播放的 URL,那麼接下來第一步,會假設這個視訊的metadata
在頭部,如果不在頭部,再一次,會假設在尾部,尾部還沒有就可能編碼在任何位置了,只能通過不斷地嘗試來獲取到這個metadata
。所以如果你要在手機端錄製視訊,最能快速播放這個視訊的方式就是把這個metadata
編碼在視訊頭部,這樣,別人播放時的時候就能一個請求百步穿楊,大大減少了這個視訊看到首幀的時間,進而提高使用者體驗。而這個視訊的資料編碼在Range: bytes=1388-34537471
的範圍內,所以要多請求很多次。
知道了這些以後我們的請求佇列就應該更改成為,不要進來一個請求就將之前的請求取消掉,而是應該將這些請求編隊,讓它們遵行 FIFO(先進先出),如果播放器明確要求將某個請求取消的時候,在將對應的請求 cancel 掉,然後移出佇列。
上面兩點都是JPVideoPlayerResourceLoader
所做的事情的一部分,簡單來說就是攔截請求,然後管理這些攔截到的請求。
05. 基於JPResourceLoadingRequestTask
封裝本地和網路請求
由於我們要做斷點續傳,所以不可能直接截獲AVPlayer
就拿這個請求的 range 進行請求。因為有些資料可能已經快取在磁碟裡了,不需要再次從網路上重複下載,而有些確實需要通過網路請求獲取,就像下面這樣。
所以當我們拿到一個AVPlayer
請求的時候,先要和本地已有的快取進行比對,然後按照規則:沒有的從網路上下載,有的直接取本地。這樣以後,每個AVPlayer
請求就會拆分為多個內部的本地和網路請求,而JPResourceLoadingRequestTask
就是內部請求。
JPResourceLoadingRequestTask
是一個抽象模板類,不能直接被使用,需要繼承並且實現它定義的方法,才可以使用。因為我們的使用場景就是本地和網路請求,所以框架中實現了網路和本地請求兩個子類,分別對應的負責對應的請求,並在獲得資料以後回撥給它的代理。
到此為止,我們攔截到的請求就已經全部封裝成為框架內部的請求了。
06. JPVideoPlayerCacheFile
如何管理斷點續傳
對視訊資料檔案的增刪改查絕對是這個框架的核心,這個檔案是 AVPlayerCacheSupport的作者寫的,我只是在他檔案的基礎上改了 bug,讓這個類能正常工作。
這個檔案持有兩個檔案控制程式碼NSFileHandle
,一個負責寫檔案,一個負責讀檔案。每當一個視訊第一次播放的時候,播放器肯定會先請求前兩個位元組的資料,其實就是為了拿到這個 URL 的contentType
,當拿到這個響應資訊的時候,當前這個類也會把contentType
資訊快取到本地。然後每次視訊資料一片一片回來的時候,這個類拿到資料,就會使用檔案控制程式碼寫到本地,然後每次寫完資料也會將當前這一片資料的 range 儲存起來。同時也會將這個 range 和已有的 range 進行比對,當這個 range 和已有的 range 有交集,或者前後銜接的時候,就將這兩個 range 合成一個 range。
想象一下,按照這個規則一直迴圈下去,最後當這個檔案快取完全的時候,這些 range 最後會合併成一個 range,而這個 range 就是檔案的長度。這樣,我們就實現了檔案的斷點續傳。
而讀檔案就相對簡單了。但是讀檔案有一點需要注意,我們不應該將視訊檔案一次性全部讀出來,假如一個視訊有 1 GB,那記憶體會突然爆掉。所以我們應該採取的策略是一點一點讀,比方說,每次讀出 32 Kb 寫給播放器,寫完以後再讀 32 Kb,這樣迴圈,直到資料讀完。
07. 前後臺狀態管理
對前後臺的管理可能在不同的產品中有不同的形式,比方說使用者將 APP 推入後臺和使用者滑出通知中心可能有不同的處理。而在 iPhone 裝置上前後臺總共分為“通知中心,控制中心,全域性警告,雙擊 home 鍵,跳去其他 APP 分享,進入後臺,鎖屏”。而這些,都不用你操心,框架中有一個JPApplicationStateMonitor
類,專門負責監聽 APP 狀態。你只需要成為代理就能輕鬆應對這些狀態。
- (BOOL)shouldPausePlaybackWhenApplicationWillResignActiveForURL:(NSURL *)videoURL;
- (BOOL)shouldPausePlaybackWhenApplicationDidEnterBackgroundForURL:(NSURL *)videoURL;
- (BOOL)shouldResumePlaybackWhenApplicationDidBecomeActiveFromResignActiveForURL:(NSURL *)videoURL;
- (BOOL)shouldResumePlaybackWhenApplicationDidBecomeActiveFromBackgroundForURL:(NSURL *)videoURL;
複製程式碼
08.UIView+WebVideoCache
介面如何封裝
考慮到列表播放視訊的場景,一個是在列表中播放視訊,還有就是從視訊列表頁跳轉視訊詳情頁面,另外一個就是詳情頁懸停的介面。框架為這三個場景封裝了專門的 API,如果在使用中還有其他的場景,可以基於最基礎的視訊播放 API 進行封裝。
08.1 列表中播放視訊
列表中播放視訊,像新浪微博、Facebook、Twitter 這樣的 APP,都只有一個緩衝動畫和播放&緩衝進度指示器,框架也使用了一樣的思路進行了類似的封裝。
08.2 視訊詳情播放視訊
在詳情頁播放視訊,上面的緩衝動畫和播放&緩衝進度指示器都得有,而且還需要一套和使用者互動控制視訊播放的介面。
08.1 懸停播放視訊
懸停的時候比較簡單,就是單獨一個視訊視窗。
基於以上三個場景,封裝了以下三個方法:
- (void)jp_playVideoMuteWithURL:url
bufferingIndicator:nil
progressView:nil
configurationCompletion:nil;
- (void)jp_playVideoWithURL:url
bufferingIndicator:nil
controlView:nil
progressView:nil
configurationCompletion:nil;
- (void)jp_playVideoWithURL:url
options:kNilOption
configurationCompletion:nil;
複製程式碼
當然還有一種必不可少的場景就是,比方說使用者從列表頁跳轉到詳情頁,這個時候如果使用以上的介面,就會出現到了詳情頁以後視訊重新播放,這樣很影響使用者體驗。所以框架裡對這種情況也進行了封裝,就是使用包含resume
的介面,這樣就能實現連貫的播放了。就像下面這樣:
09. JPVideoPlayerControlViews
怎麼和播放業務完全解耦
考慮到使用者後期需要定製自己的介面,所以業務層和介面層必須完全解耦。框架裡使用了面向協議的方式進行解耦。抽取了三個不同的協議,要定製不同的介面只需要實現指定的協議方法就可以根據播放狀態更新 UI。
- 緩衝動畫指示器:
<JPVideoPlayerBufferingProtocol>
- 播放和緩衝進度指示器:
<JPVideoPlayerProtocol>
- 控制介面:
<JPVideoPlayerProtocol>
同時框架還根據對應的協議實現了對應的模板類,如果沒有定製 UI 的需求,可以直接使用模板類,就能快速實現對應的介面。同時也可以繼承模板類替換 UI 素材快速定製 UI。
10. 假橫屏佈局有何問題
包括騰訊視訊、優酷視訊、嗶哩嗶哩等 APP 都是採用假橫屏來實現視訊橫屏,那究竟什麼是假橫屏?下面這張圖演示了什麼是假橫屏。將視訊新增到 window 上,然後將視訊順時針旋轉 90°,這樣就是假橫屏。
橫屏程式碼如下:
- (void)executeLandscape {
UIView *videoPlayerView = ...;
CGRect screenBounds = [[UIScreen mainScreen] bounds];
CGRect bounds = CGRectMake(0, 0, CGRectGetHeight(screenBounds), CGRectGetWidth(screenBounds));
CGPoint center = CGPointMake(CGRectGetMidX(screenBounds), CGRectGetMidY(screenBounds));
videoPlayerView.bounds = bounds;
videoPlayerView.center = center;
videoPlayerView.transform = CGAffineTransformMakeRotation(M_PI_2);
}
複製程式碼
這樣橫是橫過來了,但是這個videoPlayerView
的子view
都沒有橫過來,而且就算是這些子view
是使用 autolayout 佈局的,也沒有對應的更改約束。
下面是 frame 的文件說明:
The frame rectangle is position and size of the layer specified in the superlayer’s coordinate space. For layers, the frame rectangle is a computed property that is derived from the values in thebounds, anchorPoint and position properties. When you assign a new value to this property, the layer changes its position and bounds properties to match the rectangle you specified. The values of each coordinate in the rectangle are measured in points.
複製程式碼
意思就是子view
是相對父view
進行佈局的,現在我們直接更改videoPlayerView
的bounds
和center
屬性,而沒有更改frame
屬性,這樣就會導致子view
佈局出現問題,所以我們在更改完bounds
和center
以後,也要講對應的frame
屬性也進行更正。這樣更正以後,使用 autolayout 佈局的子view
佈局就正常了。但是直接使用frame
佈局的子view
還是會有橫豎屏相容的問題,所以框架裡專門抽取了一個佈局的方法給子類複寫。
- (void)layoutThatFits:(CGRect)constrainedRect
nearestViewControllerInViewTree:(UIViewController *_Nullable)nearestViewController
interfaceOrientation:(JPVideoPlayViewInterfaceOrientation)interfaceOrientation;
複製程式碼
這個方法把父檢視的大小傳了過來,同時也把當前檢視對應的控制器也傳了過來,同時還把當前的視訊的方向也傳了過來,這樣,就可以根據不同的螢幕方向進行不同的佈局了。
11. 等高和不等高 cell 兩種情況兩種策略
不同的產品中可能同時存在等高和不等高的 cell 來播放視訊,上個版本就只支援等高 cell 的滑動播放,其實滑動播放的策略是不分等高和不等高的,只要稍加修改就可以了。
這次不僅支援不等高 cell 的滑動播放,還支援在計算離 tableView 可見區域中心最近時,可以使用 cell 進行計算,也可以使用播放視訊的 view 來進行計算。為此我專門畫了一幅圖來說明這個區別:
我的文章集合
下面這個連結是我所有文章的一個集合目錄。這些文章凡是涉及實現的,每篇文章中都有 Github 地址,Github 上都有原始碼。