你們好,我是 NewPan,此次咱們來說解 JPVideoPlayer 3.0 實現上的細節。git
若是你沒有了解實現原理的需求,請直接看另一篇介紹如何使用的文章:[iOS]JPVideoPlayer 3.0 使用介紹。github
從去年發了 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
提供了請求攔截的入口,我就不必本身再基於端口進行攔截了。socket
因而方案終於敲定,也就到了年後開工的時候了,如今想一想,這個方案真的花了差很少半年的時間,也是挺不容易的。ide
接下來咱們就把下面這張結構圖講清楚就能夠了。
如今框架支持下面三種類型的視頻路徑的播放:
local file, play video
。這個是最簡單的,檢查一下 URL,若是是本地 URL,初始化一個JPVideoPlayer
,把路徑塞給它,立馬就開始播放了,沒什麼好講的。network result, play video
。這個就複雜點,初始化一個播放器之後,攔截它的請求,而後把這個請求封裝成爲本身內部的請求,而後去網絡上下載視頻,下載下來響應播放器,同時也緩存到本地。disk result, play video
,和上面同樣要初始化播放器,而後攔截播放器請求,而後要先去緩存中查一下這個請求,有哪些數據已經緩存到本地了,那些已經緩存到本地的數據就不用再去下載了,直接從磁盤中讀出來就能夠了,那些沒緩存的就按照第二點的思路去下載。而後整個過程就串起來了。接下來熟悉一下整個類目結構:
我如今按照我當時寫的循序,從最低層開始,一點一點往上封裝,直到最後用戶看到的只有一個簡單的接口。
AVPlayer
請求的截獲AVPlayer
請求隊列的管理JPResourceLoadingRequestTask
封裝本地和網絡請求JPVideoPlayerCacheFile
如何管理斷點續傳JPResourceLoadingRequestTask
和JPVideoPlayerCacheFile
UIView+WebVideoCache
接口如何封裝JPVideoPlayerControlViews
怎麼和播放業務徹底解耦AVPlayer
請求的截獲// 獲取到新的請求
- (BOOL)resourceLoader:(AVAssetResourceLoader *)resourceLoader
shouldWaitForLoadingOfRequestedResource:(AVAssetResourceLoadingRequest *)loadingRequest;
// 取消請求
- (void)resourceLoader:(AVAssetResourceLoader *)resourceLoader
didCancelLoadingRequest:(AVAssetResourceLoadingRequest *)loadingRequest;
複製代碼
一切都從這裏開始,咱們從這裏拿到 AVPlayer
想要獲取的數據的請求,而後將這個請求保存到數組中,而後發起網絡請求,拿回這個請求的數據,而後將這個數據傳回給播放器,視頻播放就開始了。
同時,播放器也可能會調用取消請求的回調,告訴咱們某個請求已經取消掉了,不用在請求數據了,此時咱們就應該將這個請求從請求數組中移除掉。
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
所作的事情的一部分,簡單來講就是攔截請求,而後管理這些攔截到的請求。
JPResourceLoadingRequestTask
封裝本地和網絡請求因爲咱們要作斷點續傳,因此不可能直接截獲AVPlayer
就拿這個請求的 range 進行請求。由於有些數據可能已經緩存在磁盤裏了,不須要再次從網絡上重複下載,而有些確實須要經過網絡請求獲取,就像下面這樣。
因此當咱們拿到一個AVPlayer
請求的時候,先要和本地已有的緩存進行比對,而後按照規則:沒有的從網絡上下載,有的直接取本地。這樣之後,每一個AVPlayer
請求就會拆分爲多個內部的本地和網絡請求,而JPResourceLoadingRequestTask
就是內部請求。
JPResourceLoadingRequestTask
是一個抽象模板類,不能直接被使用,須要繼承而且實現它定義的方法,纔可使用。由於咱們的使用場景就是本地和網絡請求,因此框架中實現了網絡和本地請求兩個子類,分別對應的負責對應的請求,並在得到數據之後回調給它的代理。
到此爲止,咱們攔截到的請求就已經所有封裝成爲框架內部的請求了。
JPVideoPlayerCacheFile
如何管理斷點續傳對視頻數據文件的增刪改查絕對是這個框架的核心,這個文件是 AVPlayerCacheSupport的做者寫的,我只是在他文件的基礎上改了 bug,讓這個類能正常工做。
這個文件持有兩個文件句柄NSFileHandle
,一個負責寫文件,一個負責讀文件。每當一個視頻第一次播放的時候,播放器確定會先請求前兩個字節的數據,其實就是爲了拿到這個 URL 的contentType
,當拿到這個響應信息的時候,當前這個類也會把contentType
信息緩存到本地。而後每次視頻數據一片一片回來的時候,這個類拿到數據,就會使用文件句柄寫到本地,而後每次寫完數據也會將當前這一片數據的 range 保存起來。同時也會將這個 range 和已有的 range 進行比對,當這個 range 和已有的 range 有交集,或者先後銜接的時候,就將這兩個 range 合成一個 range。
想象一下,按照這個規則一直循環下去,最後當這個文件緩存徹底的時候,這些 range 最後會合併成一個 range,而這個 range 就是文件的長度。這樣,咱們就實現了文件的斷點續傳。
而讀文件就相對簡單了。可是讀文件有一點須要注意,咱們不該該將視頻文件一次性所有讀出來,假如一個視頻有 1 GB,那內存會忽然爆掉。因此咱們應該採起的策略是一點一點讀,比方說,每次讀出 32 Kb 寫給播放器,寫完之後再讀 32 Kb,這樣循環,直到數據讀完。
對先後臺的管理可能在不一樣的產品中有不一樣的形式,比方說用戶將 APP 推入後臺和用戶滑出通知中心可能有不一樣的處理。而在 iPhone 設備上先後臺總共分爲「通知中心,控制中心,全局警告,雙擊 home 鍵,跳去其餘 APP 分享,進入後臺,鎖屏」。而這些,都不用你操心,框架中有一個JPApplicationStateMonitor
類,專門負責監聽 APP 狀態。你只須要成爲代理就能輕鬆應對這些狀態。
- (BOOL)shouldPausePlaybackWhenApplicationWillResignActiveForURL:(NSURL *)videoURL;
- (BOOL)shouldPausePlaybackWhenApplicationDidEnterBackgroundForURL:(NSURL *)videoURL;
- (BOOL)shouldResumePlaybackWhenApplicationDidBecomeActiveFromResignActiveForURL:(NSURL *)videoURL;
- (BOOL)shouldResumePlaybackWhenApplicationDidBecomeActiveFromBackgroundForURL:(NSURL *)videoURL;
複製代碼
UIView+WebVideoCache
接口如何封裝考慮到列表播放視頻的場景,一個是在列表中播放視頻,還有就是從視頻列表頁跳轉視頻詳情頁面,另一個就是詳情頁懸停的界面。框架爲這三個場景封裝了專門的 API,若是在使用中還有其餘的場景,能夠基於最基礎的視頻播放 API 進行封裝。
列表中播放視頻,像新浪微博、Facebook、Twitter 這樣的 APP,都只有一個緩衝動畫和播放&緩衝進度指示器,框架也使用了同樣的思路進行了相似的封裝。
在詳情頁播放視頻,上面的緩衝動畫和播放&緩衝進度指示器都得有,並且還須要一套和用戶交互控制視頻播放的界面。
懸停的時候比較簡單,就是單獨一個視頻窗口。
基於以上三個場景,封裝瞭如下三個方法:
- (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
的接口,這樣就能實現連貫的播放了。就像下面這樣:
JPVideoPlayerControlViews
怎麼和播放業務徹底解耦考慮到用戶後期須要定製本身的界面,因此業務層和界面層必須徹底解耦。框架裏使用了面向協議的方式進行解耦。抽取了三個不一樣的協議,要定製不一樣的界面只須要實現指定的協議方法就能夠根據播放狀態更新 UI。
<JPVideoPlayerBufferingProtocol>
<JPVideoPlayerProtocol>
<JPVideoPlayerProtocol>
同時框架還根據對應的協議實現了對應的模板類,若是沒有定製 UI 的需求,能夠直接使用模板類,就能快速實現對應的界面。同時也能夠繼承模板類替換 UI 素材快速定製 UI。
包括騰訊視頻、優酷視頻、嗶哩嗶哩等 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;
複製代碼
這個方法把父視圖的大小傳了過來,同時也把當前視圖對應的控制器也傳了過來,同時還把當前的視頻的方向也傳了過來,這樣,就能夠根據不一樣的屏幕方向進行不一樣的佈局了。
不一樣的產品中可能同時存在等高和不等高的 cell 來播放視頻,上個版本就只支持等高 cell 的滑動播放,其實滑動播放的策略是不分等高和不等高的,只要稍加修改就能夠了。
此次不只支持不等高 cell 的滑動播放,還支持在計算離 tableView 可見區域中心最近時,可使用 cell 進行計算,也可使用播放視頻的 view 來進行計算。爲此我專門畫了一幅圖來講明這個區別:
下面這個連接是我全部文章的一個集合目錄。這些文章凡是涉及實現的,每篇文章中都有 Github 地址,Github 上都有源碼。