淘寶大前端播放器 VideoX 如何回應(yīng)業(yè)務(wù)訴求

來源:大淘寶前端技術(shù)(taobaofed)
VideoX 是內(nèi)容前端團隊基于電商業(yè)務(wù)(以下簡稱大淘寶)背景打造的面向大終端場景的前端播放器。這篇文章談?wù)勎覍Σシ牌黝I(lǐng)域問題的認(rèn)識,以及當(dāng)下解決這些問題的思路。
大淘寶視頻播放的場景有哪些?

大淘寶視頻播放的第一業(yè)務(wù)場景,是在消費側(cè)。回想起來我最早在淘寶上看到視頻內(nèi)容,應(yīng)該是在商品的詳情頁。幾十秒的視頻內(nèi)容更形象地傳遞了商品的信息。商品有了視頻內(nèi)容,自然地在一些前置入口上也就可以透出它們了。比方說首頁上的「猜你喜歡」,搜索的結(jié)果頁等這些淘寶的基礎(chǔ)交易鏈路;16 年淘寶開始提出內(nèi)容化與社區(qū)的方向,引入了創(chuàng)作者的角色以及圖文類型的內(nèi)容,并推出了愛逛街、有好貨、淘寶頭條等內(nèi)容電商產(chǎn)品,短視頻在其中扮演著至關(guān)重要的角色。淘寶直播也在這期間橫空出世,實時類視頻登上淘寶歷史的舞臺。21 年淘寶持續(xù)推進內(nèi)容化,提出“生活在淘寶”的愿景,逐漸形成了以逛逛、點淘和淘寶直播的社交內(nèi)容產(chǎn)品矩陣;商家上傳的商品視頻和創(chuàng)作者發(fā)布的圖文視頻,又可以在前臺的導(dǎo)購場景中透出,不斷豐富淘內(nèi)的商品導(dǎo)購形式和信息消費形態(tài)。
應(yīng)該說,在內(nèi)容和交易日漸融合的趨勢下,在淘寶從交易走向消費的進程中,視頻已經(jīng)無處不在了。
視頻內(nèi)容的流轉(zhuǎn)涉及從生產(chǎn)到消費的完整鏈路,在生產(chǎn)側(cè)和平臺端的業(yè)務(wù)場景同樣存在視頻播放的訴求。在生產(chǎn)側(cè),創(chuàng)作時在親拍中需要播放模板視頻,發(fā)布時在逛逛需要預(yù)覽視頻上傳后的效果;在平臺端,在縱橫的體驗中心演示能力時需要對多種分辨率和編碼格式的視頻進行播放,在黃雀每天眾多的審核人員完成巨量的審核任務(wù)時需要播放視頻。

業(yè)務(wù)對于視頻播放的訴求有哪些?
由上可見,大淘寶視頻播放的業(yè)務(wù)場景是非常復(fù)雜的。盡管場景是復(fù)雜的,但業(yè)務(wù)對于視頻播放的訴求是可抽象的。我把它們抽象為以下幾點。
多端的播放能力

業(yè)務(wù)上對于視頻播放的首要訴求,還是視頻能不能播的問題。在大淘寶上,主要播放短視頻、視頻動畫、直播和回放、全景視頻等。依據(jù)實時性和交互方式的不同,通常將這幾類視頻分為點播(Vod)和直播(Live)。
為使得這些類型的視頻能夠在網(wǎng)絡(luò)上更好地進行傳輸和播放,則需要用到不同的視頻格式:

不同的視頻類型使用哪種視頻格式是由跨平臺兼容性、延時、可拓展性、使用成本等因素綜合決定的。
由于不同業(yè)務(wù)場景面向的客戶群體的差異,以及業(yè)務(wù)根據(jù)其性質(zhì)對用戶體驗和研發(fā)效率的權(quán)衡,業(yè)務(wù)上的終端場景也是千差萬別:

比方說行業(yè)小二給商家進行直播開課,商家是在千牛 PC 客戶端上進行觀看;店鋪為了做三方的開放技術(shù),很長的一段時間用的是小程序的方案;基礎(chǔ)鏈路要優(yōu)先保障穩(wěn)定性,用的是 Native 的方案,外投又需要適配 WebView……
播放器需要對這些的視頻類型、視頻格式和終端場景提供完備的視頻播放支持。

視頻交互能力
除了播放畫面和聲音,在觀看視頻時,業(yè)務(wù)還需要為用戶提供視頻交互能力。這些實現(xiàn)交互功能的控件是蓋在視頻之上的:

交互的目的通常是為了控制視頻的播放進度和效果,以及設(shè)置視頻的可見性。這些交互能力我歸類總結(jié)了一下,有以下幾種:
播放狀態(tài)和進度控制:播放狀態(tài)控制,例如通過點擊按鈕播放、暫停或重播該視頻;播放進度控制,例如通過點擊前進/后退按鈕跳過一段內(nèi)容,或通過點擊或拖拽進度條切換播放時刻; 播放效果控制:例如切換靜音/有聲,調(diào)節(jié)音量,設(shè)置播放倍數(shù),切換視頻清晰度等;在一些長視頻網(wǎng)站上,還有當(dāng)存在多語言時可切換音軌和字幕的能力。不過目前在淘內(nèi)沒有這類訴求; 可見性控制:即控制視頻在屏幕上的可見范圍。通常的業(yè)務(wù)訴求是全屏和小窗的能力;移動上在寬高比合適的情況下自動橫屏的訴求;PC 上部分業(yè)務(wù)有寬屏和滿屏的訴求; 更多能力:例如淘寶直播上的彈幕能力、逛逛短視頻的商品卡片及互動彈層等。

交互定制能力

上面列舉了全量的交互能力。但不同的業(yè)務(wù)場景需要的交互能力是各不相同的。這就需要播放器提供交互的定制能力。對相關(guān)的定制能力進行歸納總結(jié),有以下幾種:
控件的定制:最常見的是不同的業(yè)務(wù)場景下要顯示的控件均有細(xì)微差別;其次是菜單類型的控件(例如倍數(shù))選項列表需要可以被業(yè)務(wù)所指定;最后是不同的業(yè)務(wù)對控件的交互行為也有不同的偏好,例如點擊視頻是否切換播放/暫停狀態(tài),雙擊視頻是否全屏,播放開始后是否隱藏所有控件等等; 樣式的定制:控件的排布順序和位置經(jīng)常在不同的產(chǎn)品下有所不同;控件的大小、顏色會在一些大的業(yè)務(wù)線下所有不同;控件使用的圖標(biāo)在一些獨立 App 下有所不同;

還有一種是控件語言的定制,允許業(yè)務(wù)設(shè)置控件內(nèi)使用的文本語言。例如業(yè)務(wù)可根據(jù)用戶所在地區(qū)設(shè)置播放器使用的語言。目前在淘內(nèi)沒有這類訴求。
多視頻管理能力

管理能力可以概況為以下幾種:
控制每個視頻的加載時機:例如在幻燈片(Slider)場景下,只有當(dāng)前顯示的那個幻燈片才需要加載視頻資源。切換幻燈片后播放當(dāng)前幻燈片下的視頻,暫停或銷毀之前的視頻; 選擇最佳位置的視頻進行播放:例如在滾動(Scroll)場景下,要選擇離屏幕視覺中心點最近的視頻進行播放,該視頻播放時,暫停或銷毀上一個正在播放的視頻。
淘內(nèi)的交互場景還有很多。比方說:
選項卡(Tab)場景下,切換 Tab 后選擇當(dāng)前 Tab 下面最佳位置的視頻進行播放,暫停或銷毀上一個 Tab 下的視頻; 或者版頭是 Slider 且整個頁面可 Scroll 的場景下,需要優(yōu)先播放版頭的視頻,版頭滾出可視區(qū)域后暫停或銷毀版頭內(nèi)的視頻,選擇滾動區(qū)域內(nèi)最佳位置的視頻進行播放;再次回到版頭后,接著上一次被播放的視頻繼續(xù)播放。

播控服務(wù)能力
通常來說,業(yè)務(wù)播放視頻,只需要傳遞一個視頻資源 URL 就足夠了。但部分業(yè)務(wù)為了權(quán)衡視頻投入的支出和前臺用戶的體驗,就需要使用到播控服務(wù)能力。播放器會請求一個播控的服務(wù),來實現(xiàn):
視頻資源下發(fā):服務(wù)端下發(fā)不同檔位分辨率的視頻資源 URL,業(yè)務(wù)決定優(yōu)先播放哪個檔位的視頻;服務(wù)端下發(fā)不同編碼格式的視頻資源 URL ,業(yè)務(wù)決定優(yōu)先播放哪種視頻編碼;服務(wù)端下發(fā)不同投影方式的視頻資源 URL,業(yè)務(wù)決定優(yōu)先播放哪種投影的視頻; 播放策略下發(fā):包括首次加載視頻時的預(yù)加載大小、播放時的緩沖區(qū)大小、是否開啟資源本地化緩存等策略。這些策略通常使用的是默認(rèn)值,但業(yè)務(wù)也會基于自身的視頻資源類型和終端用戶環(huán)境來進行調(diào)整; 更多:例如視頻版權(quán)管理(DRM)、視頻廣告投放(Ads)等能力,都需要借助播控服務(wù)來實現(xiàn),不過目前在淘內(nèi)沒有這類訴求。

數(shù)據(jù)化能力

業(yè)務(wù)需要了解視頻的投放效果和用戶的觀看體驗,就需要數(shù)據(jù)化能力。視頻播放評估指標(biāo)可以分為兩類:
一個是視頻體驗質(zhì)量(QoE: Quality of Experience) ,該指標(biāo)用來衡量最終的業(yè)務(wù)效果,反映在業(yè)務(wù)中用戶使用視頻產(chǎn)品的情況(例如對視頻是否喜愛)。具體的指標(biāo)值包括播放次數(shù)、播放時長、有效播放率等等; 一個是視頻服務(wù)質(zhì)量(QoS: Quality of Service) ,該指標(biāo)用來衡量技術(shù)提供視頻服務(wù)的效果,反映線上視頻播放技術(shù)的運作情況(例如性能和穩(wěn)定性)。具體的指標(biāo)值包括視頻秒開率、視頻卡頓率、視頻播放成功率等等。
業(yè)務(wù)需要我們定義這些指標(biāo)及其計算口徑,并提供其業(yè)務(wù)域內(nèi)的實時和離線數(shù)據(jù)。

播放管控能力
視頻的引入給業(yè)務(wù)在終端帶去了全新的用戶體驗,但是如果使用不當(dāng),輕則造成突然播放聲音影響用戶體驗,重則在移動端浪費流量造成用戶經(jīng)濟上的損失。因此,一方面業(yè)務(wù)既期望我們能夠提供便捷的定制能力,另一方面也期望我們能有「兜底」的能力,在發(fā)生問題時能夠及時止損。這就是播放器的播放管控能力。
例如:
權(quán)限類的:是否允許自動播放、是否允許后臺播放、是否允許流量播放。這些配置還與用戶的手淘配置相關(guān),最終影響到用戶的流量使用; 優(yōu)化類的:是否啟用硬解、是否啟用緩存、是否啟用數(shù)據(jù)埋點。這些配置設(shè)定目的是對用戶終端硬件的消耗及播放性能之間進行權(quán)衡。

技術(shù)如何滿足這些訴求?

我們?yōu)闈M足業(yè)務(wù)視頻接入的訴求,提供了一套完整的視頻接入方案,覆蓋業(yè)務(wù)從視頻發(fā)布到視頻播放的完整鏈路。從端視角來看,方案中包含了視頻上傳SDK,視頻服務(wù),視頻播放器三大模塊。鏈路中通過業(yè)務(wù)標(biāo)識和視頻標(biāo)識來保障業(yè)務(wù)域內(nèi)視頻流轉(zhuǎn)的可追溯。
業(yè)務(wù)發(fā)布頁使用視頻上傳SDK:
const uploader = await createUploader({ bizCode: 'foo' });
const fileInfo = await uploader.startUpload(file);
業(yè)務(wù)主頁使用視頻服務(wù)接口獲取 videoId:
const videoIds = await request('//service.taobao.com/foo', { bizCode, from });
業(yè)務(wù)詳情頁使用視頻播放器:
<Videox
sourceProvider={{
bizCode: 'foo', // 業(yè)務(wù)標(biāo)識
from: 'common', // 播放場景
src: '123456', // 視頻標(biāo)識(videoId)
}}
/>
備注:
業(yè)務(wù)主頁也可能直接使用視頻播放組件; 業(yè)務(wù)通常使用短視頻全屏頁而非建詳情頁來實現(xiàn)視頻播放和互動。
播放器架構(gòu)
基于業(yè)務(wù)的訴求和當(dāng)下的生產(chǎn)關(guān)系,播放器的整體架構(gòu)遵循兩個大的原則:一是要薄,讓業(yè)務(wù)有選擇權(quán),基于能力組合進行定制;二是要白盒,讓業(yè)務(wù)有發(fā)現(xiàn)和定位問題的能力而不是強依賴底層。
因此播放器架構(gòu)遵循關(guān)注點分離的原則,分為以下幾層,每一層解決一個領(lǐng)域內(nèi)的問題(業(yè)務(wù)訴求點),使用特定的技術(shù)棧(技術(shù)發(fā)力點):
播放能力層:這一層解決視頻能不能播的問題。解決這個問題需要音視頻技術(shù)能力。架構(gòu)上把這一層封裝為獨立的模塊,稱之為播放器內(nèi)核; 業(yè)務(wù)接入層:這一層解決如何能夠在業(yè)務(wù)系統(tǒng)內(nèi)基于底層能力快速定制專屬播放器的問題。解決這個問題需要大前端技術(shù)能力。架構(gòu)上把這一層封裝為獨立的模塊,稱之為播放器組件; 體驗保障層:這一層解決如何保證開發(fā)者體驗和用戶體驗的問題。解決這個問題需要軟件工程能力。架構(gòu)上把這一層作為一個縱向建設(shè),稱之為播放器配套設(shè)施。

基于這個分層產(chǎn)出播放器整體架構(gòu)圖:

播放器規(guī)范:包括了統(tǒng)一術(shù)語定義、視頻播放評估指標(biāo)定義、播放器 API 和事件定義等。通過一個類型包來 videox-types進行承載,使得在播放器體系內(nèi)名詞得到統(tǒng)一;播放器內(nèi)核:包括了面向 Web 場景使用前端技術(shù)棧的播放內(nèi)核 videox-core,以及面向移動端場景使用原生技術(shù)棧的播放內(nèi)核native-core。前者可以在前端領(lǐng)域被直接使用,后者可以在原生領(lǐng)域直接使用,也可以通過 SDK 集成的方式在 Weex/MiniApp/PHA 等容器下被使用;播放器組件:包括了面向 React 技術(shù)棧的播放器組件 react-videox,適用于 Web 場景。以及面向 Rax 技術(shù)棧的播放器組件rax-videox,使用于多端場景(適配了 PHA/Weex/MiniApp 容器)。兩者一些共用的能力則通過插件形式承載,在業(yè)務(wù)接入時通過組合的方式進行選配;播放器配套設(shè)施:包括了保障視頻播放體驗的測試方案、日志方案、數(shù)據(jù)埋點方案等,通過工具庫 videox-utils承載;以及保障業(yè)務(wù)接入效率的教程文檔及代碼示例,通過官網(wǎng)videox-site承載。
播放器內(nèi)核
播放內(nèi)核解決視頻能不能播的問題,并提供一組可控的 API。根據(jù)大淘寶業(yè)務(wù)對多端視頻播放的訴求,結(jié)合多端場景下穩(wěn)定性和性能最優(yōu)解的權(quán)衡,播放內(nèi)核有面向 Web 場景使用前端技術(shù)棧的播放內(nèi)核 videox-core ,以及面向移動端場景使用原生技術(shù)棧的播放內(nèi)核 native-core。
面向 Web 場景
瀏覽器已經(jīng)提供了 <video /> 標(biāo)簽用于播放視頻,為什么還需要自研播放內(nèi)核呢?主要原因是瀏覽器對于流媒體格式以及視頻編碼的支持度有限。針對大淘寶的視頻播放訴求來說,F(xiàn)LV 及 HLS 協(xié)議、 H.265 編碼格式及全景視頻類型在主流瀏覽器普遍不支持。
Web 播放內(nèi)核 videox-core 的實現(xiàn)原則是 API 與原生 <video /> 標(biāo)簽對齊,遵循 W3C 規(guī)范。因此,實現(xiàn)自研內(nèi)核需要熟悉 W3C 規(guī)范的內(nèi)容,理解瀏覽器視頻播放的工作過程,充分了解流媒體協(xié)議(FLV/HLS)和媒體格式(MP4/HEVC)的知識以及媒體處理工具(FFmpeg)的使用,并運用最新的瀏覽器相關(guān)性技術(shù)(MSE/WebGL/WebAssembly...)來完成。
下面概述一下內(nèi)部的實現(xiàn)原理。
通常前端播放視頻,使用 <video /> 就完事了:

但在大淘寶的業(yè)務(wù)場景中,最常遇到的是瀏覽器不支持的容器格式的情況。對于非標(biāo)容器格式通常的處理流程如下(以 TS 為例):

基本思路都是使用 JS 請求視頻資源數(shù)據(jù)并進行解封裝,再將其轉(zhuǎn)換成 MP4 格式傳遞給 <video /> 播放,這種字節(jié)拼裝的方案需要借助 MSE API 的能力。相應(yīng)的處理模塊及其輸入輸出:
Loader(加載器): 負(fù)責(zé)根據(jù)請求方案(XHR/Fetch/WebSocket)從網(wǎng)絡(luò)獲取視頻資源數(shù)據(jù)。在這個示例中,輸入的是視頻資源地址(URL),輸出的是 TS 視頻字節(jié)流(ArrayBuffer); Demuxer(解封裝器): 負(fù)責(zé)解析視頻的容器格式并進行解封裝操作獲得碼流。在這個示例中,輸入的是視頻字節(jié)流(ArrayBuffer),輸出的是 Annex 格式的碼流(Packet); Remuxer(復(fù)用器): 負(fù)責(zé)將碼流重新包裝為另一種容器格式。在這個示例中,輸入的是 Annex 格式的碼流,輸出的是轉(zhuǎn)換為 AVCC 碼流格式的 fMP4(流式 MP4)視頻字節(jié)流; Renderer(渲染器): 負(fù)責(zé)將視頻最終渲染播放,渲染過程通過校準(zhǔn) DTS/CTS 保證音畫同步。在這個示例中,輸入的是 AVCC 碼流格式的 fMP4 視頻字節(jié)流,內(nèi)部交給 MediaSource 進行處理,最后通過 createObjectURL 交由 video 標(biāo)簽進行播放,輸出畫面和聲音。
另一種是不支持的編碼格式的情況。對于非標(biāo)編碼格式處理流程如下(以 MP4 為例):

與上面的流程相比,后置的處理模塊有所不同。這兩個模塊的作用及其輸入輸出:
Decoder(解碼器): 負(fù)責(zé)解析碼流并輸出視頻像素數(shù)據(jù)。Decoder 中使用 WebAssembly 能力封裝 FFmpeg 解碼器來進行解碼,支持了多線程模式及切換解碼器(H.265/H.264)的能力。Decoder 輸入的是碼流數(shù)據(jù)(Packet),輸出的是 YUI 格式的視頻像素數(shù)據(jù) 和 PCM 格式的音頻采樣數(shù)據(jù)。 Renderer(渲染器): 負(fù)責(zé)將視頻最終渲染播放。由于 YUV 占用較少的帶寬,所以視頻采用 YUV 傳輸。而顯示器又是使用 RGB 發(fā)光,所以在渲染器內(nèi)需要將 YUV 格式轉(zhuǎn)換成 RGB 格式再通過 WebGL 繪制渲染到顯示器上。PCM 音頻則使用 AudioContext 進行播放。渲染過程中手動同步音視頻的 PTS,以音頻事件為準(zhǔn),視頻靠攏音頻實現(xiàn)音畫同步。
結(jié)合標(biāo)準(zhǔn)支持情況和非標(biāo)的容器及編碼格式支持情況,videox-core 內(nèi)部視頻播放的整體處理流程如下:

上面還只是一種容器格式和一種編碼格式的場景。在大淘寶的業(yè)務(wù)場景中,需要支持多種容器格式和多種編碼格式。所以 Demuxer 內(nèi)部有多個容器解析器(Parser),Docoder是由渲染控制器(Controller)調(diào)度的。整體內(nèi)部程序設(shè)計類圖如下:

播放內(nèi)核作為單獨 npm 包對外提供使用:
<div id="container" />
<script module>
import Videox from '@internal/videox-core';
const containerEl = document.getElementById('container');
const videox = new Videox({
container: containerEl,
src: '//example.com/video.mp4',
});
videox.play();
</script>
videox-core 程序設(shè)計的思考:
為什么不全部根據(jù)「不支持」來實現(xiàn)整體流程?是基于運行性能上的考慮: 當(dāng)格式不支持+編碼支持時,使用 MSE 硬解性能更優(yōu); 當(dāng)格式支持+編碼支持時 <video />原生性能更優(yōu)。為什么 Demuxer 不用 FFmpeg+WASM 來實現(xiàn)? Demuxer 使用 FFmpeg 打包后的體積特別大(少則幾M,多則十幾M); 萬一出現(xiàn) Bug,調(diào)試 FFmpeg 的源碼效率特別低,也不可能去更新 FFmpeg 的源碼; 同時 WASM 在移動端兼容性是比較差的,播放器應(yīng)盡最大可能提升整體方案的兼容性。 包大小、代碼可維護性和移動端兼容性方面的考慮。 架構(gòu)靈活性的考慮。Demuxer 下不同封裝格式使用不同的 Parser 實現(xiàn),未來可以單獨基于組合的方式實現(xiàn)面向單個封裝格式的播放器。 不支持的容器格式處理為什么不基于或集成 flv.js / hls.js 等庫來實現(xiàn)? Decoder 需要接收的是碼流,flv/hls 輸出的是視頻流,因此無法直接復(fù)用; 兩者都是功能完備的播放器實現(xiàn),無法在多種容器格式和編碼格式訴求的處理流程中單獨被引用。
社區(qū)實現(xiàn) FLV 和 HLS 支持 H.265 普遍做法也是基于這兩個庫來復(fù)寫部分邏輯。
面向 Native 場景

面向 Native 場景,客戶端提供了兩個 SDK 供業(yè)務(wù)進行接入:
TBMediaPlayer: 類比 videox-core,提供通用播放能力;DWInteractiveSDK: 包含了播控、播管和視頻交互能力。
在移動端下,這些 SDK 即可以被使用原生技術(shù)棧的業(yè)務(wù)進行接入,也可以被集成到 MiniApp、PHA/WindVane、Weex 等渲染容器內(nèi)。
播放器組件
播放內(nèi)核滿足了業(yè)務(wù)多端視頻播放的訴求,但業(yè)務(wù)還有視頻交互、交互定制、多視頻管理等訴求的需要,同時播放內(nèi)核在業(yè)務(wù)前端系統(tǒng)進行集成接入的效率也不高。VideoX 提供了播放器組件來解決這兩個問題。
在大淘寶,業(yè)務(wù)前端面向 Web 場景以 React 框架進行項目開發(fā),面對多端場景以 Rax 框架進行項目開發(fā)。因此,VideoX 提供了 react-videox 和 rax-videox 兩個組件以滿足業(yè)務(wù)接入的需要,并將上訴業(yè)務(wù)訴求以插件的形式供業(yè)務(wù)進行選配集成。
面向 React 項目

react-videox 用于在 React 項目中集成。主要包括以下能力:
通過播放內(nèi)核 API 實現(xiàn)播放器控件及提供控件定制化能力; 通過 HOC 提供拓展能力,包括:多視頻管理、播控服務(wù)、播放管控等。
react-videox 內(nèi)部的主要模塊包括:
Context: 同步播放內(nèi)核 API 的 PlayerProvider和存放圖標(biāo)信息的IconProvider;控件層: 控件管理器組件 ControlWrap以及一系列實現(xiàn)視頻交互能力的默認(rèn)控件;樣式層: 參考了 infima,使用 CSS 變量和預(yù)設(shè)的樣式類來進行構(gòu)建。
Context
PlayerProvider 是播放內(nèi)核 API 的封裝,可以同步或更新播放內(nèi)核狀態(tài) ,供內(nèi)部控件進行消費,同時對外導(dǎo)出 Ref:
播放器內(nèi)部的控件組件使用 PlayerProvider導(dǎo)出的 Hooks 。以播放/暫停切換控件為例:
import React, { memo, useCallback } from 'react';
import { usePlayerActions, usePlayerState } from 'src/context/player';
import { Icon } from 'src/components/common/Icon';
import { Button } from 'src/components/common/Button';
export const PlayToggle = memo(() => {
const { paused } = usePlayerState();
const actions = usePlayerActions();
const handleClick = useCallback(() => {
actions.togglePlay();
}, []);
return (
<Button
onClick={handleClick}
title={paused ? '播放' : '暫停'}
>
<Icon type={paused ? 'play' : 'pause'} />
</Button>
);
});
業(yè)務(wù)使用播放器組件時可以通過 Ref 的方式獲取到播放內(nèi)核的 API:
import React, { useRef, useCallback } from 'react';
import { Videox } from '@internal/react-videox';
function App() {
const videoxRef = useRef();
const handlePlay = useCallback(() => videoxRef.current.play(), []);
const handlePause = useCallback(() => videoxRef.current.pause(), []);
return (
<>
<Videox
ref={videoxRef}
src="http://example.com/video.mp4"
/>
<div>
<button onClick={handlePlay}>播放</button>
<button onClick={handlePause}>暫停</button>
</div>
</>
);
}
圖標(biāo)信息存儲為 IconProvider,聯(lián)合 Icon 組件,提供播放器內(nèi)圖標(biāo)的展示和定制能力:
IconProvider的實現(xiàn):
import React, { ReactNode, createContext, useContext, memo } from 'react';
const IconContext = createContext(null);
const iconConfig = {
scriptUrl: '//at.alicdn.com/font_foo.js',
prefix: 'videox-',
};
export function useIcon() {
return useContext(IconContext);
}
export interface IconProviderProps {
/**
* Symbol 代碼地址
*/
iconScriptUrl?: string;
/**
* Symbol 前綴
*/
iconPrefix?: string;
children: ReactNode;
}
export const IconProvider = memo((props: IconProviderProps) => {
const { children, iconScriptUrl = iconConfig.scriptUrl, iconPrefix = iconConfig.prefix } = props;
return (
<IconContext.Provider value={{ iconScriptUrl, iconPrefix }}>
{children}
</IconContext.Provider>
);
});
Icon組件消費 Provider 數(shù)據(jù):
import React, { memo, useMemo } from 'react';
import { createFromIconfontCN } from '@ant-design/icons';
import { useIcon } from 'src/context/icon';
export const Icon = memo((props: { type: string; }) => {
const { type } = props;
const { iconScriptUrl, iconPrefix } = useIcon();
const IconFont = useMemo(() => createFromIconfontCN({ scriptUrl: iconScriptUrl }), [iconScriptUrl]);
const iconPath = `${iconPrefix}${type}`;
return (
<IconFont type={iconPath} />
);
});
業(yè)務(wù)使用播放器組件時可通過傳參的方式定制圖標(biāo)
import React from 'react';
import { Videox } from '@internal/react-videox';
export default function App() {
return (
<Videox
src="http://example.com/video.mp4"
iconScriptUrl="http://at.alicdn.com/font_custom.js" // Symbol 代碼地址
iconPrefix="icon-" // Symbol 前綴
/>
);
}
控件層
控件層控件層解決視頻交互和交互定制的問題,包括了:
一系列實現(xiàn)視頻交互能力的默認(rèn)控件,例如進度條、倍數(shù)菜單、全屏切換等。這些控件各自是獨立的,通過 PlayerProvider同步整體狀態(tài)。以靜音/有聲切換控件為例,控件的內(nèi)部實現(xiàn)如下:
import React, { memo, useCallback } from 'react';
import classNames from 'classnames';
import { Icon } from 'src/components/common/Icon';
import { Button } from 'src/components/common/Button';
import { usePlayerActions, usePlayerState } from 'src/context/player';
import { ControlProps } from 'src/components/interfaces';
export const VolumeToggle = memo((props: ControlProps) => {
const { className } = props;
const { muted, volume } = usePlayerState();
const actions = usePlayerActions();
const handleClick = useCallback(() => {
actions.toggleMuted();
}, []);
const isMuted = muted || volume === 0;
return (
<Button
className={classNames(
className,
{ muted: isMuted, },
)}
onClick={handleClick}
title={isMuted ? '有聲' : '靜音'}
>
<Icon type={isMuted ? 'sound-off' : 'sound-on'} />
</Button>
);
});
控件管理器組件 ControlWrap負(fù)責(zé)控件層交互(onClick/onDoubleClick/onMouseEnter...)定制,并加載內(nèi)部默認(rèn)的控件且允許禁用和排序、插入子組件等:
import React, { Children, ReactNode, useCallback } from 'react';
import { usePlayerActions, usePlayerState } from 'src/context/player';
import { BigPlay } from 'src/components/BigPlay';
import { ControlBar } from 'src/components/ControlBar';
import { ErrorControl } from 'src/components/ErrorControl';
import { LoadingControl } from 'src/components/LoadingControl';
import { Poster } from 'src/components/Poster';
function getDefaultControls(options) {
const { controls } = options;
return controls ?
[
<Poster key="Poster" order={0} />,
<BigPlay key="BigPlay" order={1} />,
<ControlBar key="ControlBar" order={2} />,
<LoadingControl key="LoadingControl" order={3} />,
<ErrorControl key="ErrorControl" order={4} />,
] :
[];
}
function getControls(originalChildren: ReactNode, options) {
const children = Children.toArray(originalChildren);
const defaultChildren = getDefaultControls(options);
return mergeAndSortChildren(children, options, defaultChildren);
}
interface ControlsWrapProps {
children?: ReactNode;
/**
* 單擊視頻容器切換播放/暫停
*/
clickToPlay?: boolean;
/**
* 雙擊視頻容器切換全屏/非全屏
*/
doubleClickToRequestFullscreen?: boolean;
/**
* 是否顯示控件
*/
controls?: boolean;
}
export function ControlWrap(props: ControlsWrapProps) {
const { children, clickToPlay, doubleClickToRequestFullscreen, ...options } = props;
const { isFullscreen } = usePlayerState();
const actions = usePlayerActions();
const handleClick = useCallback(() => {
clickToPlay && actions.togglePlay();
}, [clickToPlay]);
const handDoubleClick =useCallback(() => {
if (doubleClickToRequestFullscreen) {
isFullscreen ? actions.exitFullscreen() : actions.requestFullscreen();
}
}, [isFullscreen, doubleClickToRequestFullscreen]);
return (
<div
onClick={handleClick}
onDoubleClick={handDoubleClick}
>
{getControls(children, options)}
</div>
);
}
mergeAndSortChildren函數(shù)負(fù)責(zé)加載子組件,履約禁用(disable)、排序(order)等控件參數(shù),執(zhí)行參數(shù)合并等操作。
基于此實現(xiàn),在業(yè)務(wù)使用 react-videox 時,可以:
配置控件層交互:
function App() {
return (
<Videox
src="http://example.com/video.mp4"
clickToPlay={true} // 點擊切換視頻播放狀態(tài)
doubleClickToRequestFullscreen={true} // 雙擊切換視頻全屏狀態(tài)
/>
);
}
配置默認(rèn)控件:
function App() {
return (
<Videox src="http://example.com/video.mp4">
{/* 禁用控件層的控件 */}
<BigPlay disabled />
<ControlBar>
{/* 禁用控制欄的控件 */}
<VolumeControl disabled />
{/* 將全屏切換按鈕放到最左邊 */}
<FullscreenToggle order={0} />
</ControlBar>
</Videox>
);
}
新增定制控件
import React from 'react';
import { Videox, ControlBar, usePlayer } from '@internal/react-videox';
function CustomControl() {
const { state, actions } = usePlayer(); // 返回播放器的 API
// const state = usePlayerState(); // 返回播放器的最新屬性
// const actions = usePlayerActions(); // 返回播放器的方法
return (
<div style={style}>
是否正在播放:{`${!state.paused}`}
<button onClick={() => actions.togglePlay()}>
{state.paused ? '播放' : '暫停'}
</button>
</div>
);
}
export default () => {
return (
<Videox src="http://example.com/video.mp4">
{/* 添加定制控件到控件層 */}
<CustomControl />
<ControlBar>
{/* 添加定制控件到控制欄 */}
<div style={{ backgroundColor: 'blue' }}>
Tag
</div>
</ControlBar>
</Videox>
);
}
面向 Rax 項目

rax-videox 用于在 Rax 項目中集成。主要包括以下能力:
多容器渲染下播放內(nèi)核 的適配,對外 API 向 WebView 對齊,降低業(yè)務(wù)接入成本; PHA/WindVane 下利用同層渲染能力對接 native-core,WebView 下使用videox-core,增強播放能力;
在大淘寶下的多端場景下,交互定制能力的訴求并不強烈,因此視頻交互能力當(dāng)前渲染容器下的
<video />標(biāo)簽提供。
react-videox 內(nèi)部的主要模塊包括:
API 適配層:包含了 WindVane/PHA、WebView、Weex、MiniApp 渲染容器的 API 適配; 同層渲染對接層:將 Rax 組件語法翻譯成同層渲染組件語法,實現(xiàn)高性能的 API 轉(zhuǎn)換。 通過 HOC 提供拓展能力,包括:多視頻管理、播控服務(wù)、播放管控等。
API 適配層
API 適配的實現(xiàn)思路大致如下:
由不同的組件實現(xiàn)各自渲染容器下的參數(shù)和 API 適配:
.
├── index.tsx // 入口文件
├── empty // 空組件實現(xiàn)
├── miniapp-native // 小程序語法的組件實現(xiàn)
├── miniapp-runtime // Rax 小程序運行時語法的組件實現(xiàn)
├── webview // WebView 下的組件實現(xiàn)
├── weex // Weex 下的組件實現(xiàn)
└── windvane // PHA/WindVane 下的組件實現(xiàn)
以 Weex 下組件適配為例,實現(xiàn)的示例代碼大意如下:
import { useRef, forwardRef, useImperativeHandle, memo, useMemo, useCallback } from 'rax';
import setNativeProps from 'rax-set-native-props';
import create from 'lodash.create';
import { ScreenModeMap, contentModeMap } from 'src/common/constant';
export const Videox = memo(forwardRef((props, ref) => {
/**
* 參數(shù)適配
*/
const {
live,
src,
controls,
style = {},
objectFit = 'contain',
orientation = 'vertical',
onPlay,
onPlaying,
onPrepared,
onLoadedMetadata,
// 更多參數(shù)...
...otherProps
} = props;
const videoRef = useRef(null);
const VideoPlusComponent = useMemo(() => createVideoPlusComponent(live), [live, src]);
/**
* API 適配
*/
useImperativeHandle(ref, () => {
return create(videoRef.current, {
requestFullscreen: (direction?: number) => {
let landscape = false;
if (direction === 90 || direction === -90) {
landscape = true;
}
setNativeProps(videoRef.current, { screenMode: ScreenModeMap.fullScreen, landscape, });
},
exitFullscreen: () => setNativeProps(videoRef.current, { screenMode: ScreenModeMap.inlineScreen, landscape: false, }),
// 更多 API...
});
}, [VideoPlusComponent]);
/**
* 事件適配
*/
const handlePrepared = useCallback(() => {
onPrepared?.();
onLoadedMetadata?.();
}, [onPrepared, onLoadedMetadata]);
const handlePlaying = useCallback(() => {
onPlay?.();
onPlaying?.();
}, []);
// 更多事件...
return (
<VideoPlusComponent
src={src}
hideControl={!controls}
controlsViewHidden={!controls}
contentMode={contentModeMap[objectFit]}
size={objectFit}
landscape={!(orientation === 'vertical')}
type={ live ? 'live' : 'video' }
{...otherProps}
onPlaying={handlePlaying}
onPrepared={handlePrepared}
ref={videoRef}
className="videox-video"
style={style}
/>
);
}));
function callbackToPromise(fn) {
return new Promise((resolve) => fn((e) => resolve(e.result)));
}
function createVideoPlusComponent(live: boolean) {
return forwardRef(function(props, ref) {
return live ? <video {...props} ref={ref} /> : <videoplus {...props} ref={ref} />
});
}
在入口文件處根據(jù)環(huán)境在運行時判斷使用的組件:
import { isWeb, isMiniApp, isWeex, isWindVane } from 'universal-env';
import { supportNativeView } from '@internal/rax-composite-view-factory';
let exports;
if (isWindVane && ( supportNativeView('wvvideo') || supportNativeView('wvlivevideo') )) {
exports = require('./windvane');
} else if (isMiniApp) {
exports = require('./miniapp-runtime');
} else if (isWeb) {
exports = require('./webview');
} else if (isWeex) {
exports = require('./weex');
} else {
exports = require('./empty');
}
const Videox = exports?.default || exports;
export default Videox;
聲明組件的包導(dǎo)出配置
{
"name": "@internal/rax-videox",
"version": "0.3.0",
"main": "lib/index.js",
"module": "es/index.js",
"miniappConfig": {
"main": "lib/miniapp-native/index"
},
"exports": {
".": {
"weex": "./es/weex/index.js",
"web": "./es/index.js",
"miniapp": "./es/miniapp-runtime/index.js",
"default": "./es/index.js"
},
"./*": "./*"
},
"files": [ "es", "lib", "dist" ]
}
exports字段使得 Rax 項目引用在打包特定容器下 bundle 時只加載組件的特定容器下實現(xiàn)代碼。
同層渲染對接層
同層渲染是允許將 Native 組件和 WebView DOM 元素混合在一起進行渲染的技術(shù),能夠保證 Native 組件和 DOM 元素體感一致,渲染層級、滾動感受、觸摸事件等方面幾乎沒有區(qū)別。
在 PHA/WindVane 下已完成同層渲染的接入,同時 native-core 通過與容器對接,在 PHA/WindVane 下注冊了 wvvideo 和 wvlivevideo 兩個同層渲染組件供前端進行使用,前端可通過 <object> 引入:
<object id="my_map" type="application/view" width="200" height="100">
<param name="viewType" value="wvvideo"/>
<param name="bridgeId" value="my_wvvideo_0"/>
<param name="data" value="origin value"/>
</object>
其中:
<object>的 type 必須為application/view;使用 name="viewType"的 param 來標(biāo)識同層渲染組件的類型;使用 name="bridgeId"的 param 作為同層渲染組件的標(biāo)識(僅限 Android);使用其它 param 來傳遞組件需要使用的參數(shù)。
除了傳遞參數(shù),同層渲染組件還可以監(jiān)聽事件、調(diào)用方法,但方式與普遍的 DOM 元素有所不同,在不同的端上(iOS/Android)還有所差異。當(dāng)頁面上有多個同層渲染組件時,還需要保證 bridgeId 的唯一性。
如此之下,同層渲染組件的使用成本還是比較高的,且普通的 Rax 組件相比有明顯的不同,不符合 Rax 體系內(nèi)開發(fā)者的使用習(xí)慣。因此我們通過 @internal/rax-composite-view-factory 這個庫來更好地管理和橋接同層渲染組件。使用方式:
import { useRef, useCallback, useEffect } from 'rax';
import { createCompositeComponent } from '@internal/rax-composite-view-factory';
const videoConfig = {
properties: {
src: {
type: String,
default: '',
},
},
events: [
'playing',
],
methods: [
'play',
'pause',
],
};
const Video = createCompositeComponent('wvvideo', videoConfig, 'video');
function App() {
const videoRef = useRef(null);
const handlePlaying = useCallback(() => {
// dosomthing...
}, []);
useEffect(() => {
videoRef.current.play();
}, []);
return (
<Video
src="http://example.com/video.mp4"
onPlaying={handlePlaying}
ref={videoRef}
/>
);
};
插件化能力
類似多視頻管理、播控服務(wù)、數(shù)據(jù)埋點等能力,一方面是選配的,兩一方面在 rax-videox 和 react-videox 上有訴求,因此在播放器組件中,是通過 HOC 的形式來實現(xiàn)的。這樣業(yè)務(wù)就可以各取所需,同時播放器的維護成本也得以控制。
以播控服務(wù)為例,使用方式是:
import Videox, { withSourceProvider } from '@internal/rax-videox';
const VideoxWithSourceProvider = withSourceProvider(Videox);
function App() {
return (
<VideoxWithSourceProvider
sourceProvider={{
vendor: 'taobao', // 播控服務(wù)商標(biāo)識
bizCode: 'guangguang', // 業(yè)務(wù)標(biāo)識
from: 'list', // 播放場景標(biāo)識
src: '123456', // 視頻標(biāo)識(videoId)
}}
/>
);
}
在播放器組件中的 HOC 是基于公共的 npm 生成屬于 Rax 或 React 的 HOC:
import Rax from 'rax';
import { createHOC } from '@internal/videox-source-provider';
export const withSourceProvider = createHOC(Rax);
亦或是單獨作為工具庫進行調(diào)用:
import { query } from '@internal/videox-source-provider';
const data = await query( {
vendor: 'taobao', // 播控服務(wù)商標(biāo)識
bizCode: 'guangguang', // 業(yè)務(wù)標(biāo)識
from: 'list', // 播放場景標(biāo)識
src: '123456', // 視頻 ID
});
const { sources } = data;
const source = sources[0];
// 播放策略
playerSettings.autoplay; // 自動播放
playerSettings.muted; // 靜音
// 資源信息
source.src; // 播放地址
source.bitrate; // 比特率
source.quality; // 清晰度
其他 HOC 的實現(xiàn)也基本類似。
播放器配套設(shè)施

播放器配套設(shè)施有:
保障視頻播放體驗的:測試方案、日志方案、數(shù)據(jù)埋點方案等,在播放器整體架構(gòu)中稱之為 videox-utils;保障開發(fā)者體驗提升業(yè)務(wù)接入效率的的:教程文檔及代碼示例,通過官網(wǎng) videox-site承載。
Utils
測試方案:
測試類型:針對播放器 API 的 E2E 測試(黑盒),作用于播放內(nèi)核( videox-core);針對播放器內(nèi)部實現(xiàn)的單元測試(白盒),作用于內(nèi)部模塊(demuxer/decoder/remuxer等);測試工具鏈:Karma(構(gòu)建工具) 、 (Mocha)測試框架、Chai(斷言庫)、Sinon(Mock庫); 測試資源集: videox-assets,是一組用于 E2E 測試的輸入集合,包含各種情況的視頻資源 URL。比如不同的容器格式、編碼格式的視頻。不僅包括正常的資源,也包含異常的音視頻資源,比如無音頻,PTS 有問題等致命和非致命的異常情況。測試資源單獨在局部網(wǎng)域內(nèi)部署成媒體服務(wù),可對于網(wǎng)絡(luò)訪問做一定的控制用于模擬各種情況。測試資源越豐富,播放器的健壯性和兼容性就越容易得到保障。
日志方案:能夠記錄播放器在運行時的日志,并在播放器發(fā)生錯誤時主動進行上報,并提供日志下載的途徑供用戶反饋問題時提交。
數(shù)據(jù)埋點方案:基于播放器事件的進行數(shù)據(jù)采集,并產(chǎn)出 QoS 和 QoE 數(shù)據(jù)。
自動化采集裝置: videox-tracker
import VideoxTracker from '@internal/videox-tracker';
const video = document.getElementById('my-video');
const videoxTracker = new VideoxTracker();
videoxTracker.start(video);
數(shù)據(jù)處理的相關(guān)性 SQL:通過 ODPS 離線任務(wù)對播放器事件日志進行分析,產(chǎn)出數(shù)據(jù)統(tǒng)計表。業(yè)務(wù)根據(jù)自身需要通過阿里內(nèi)部的大數(shù)據(jù)分析和可視化平臺對數(shù)據(jù)進行篩選并制作前臺數(shù)據(jù)報表。
Site
官網(wǎng)包括以下部分的內(nèi)容:
教程文檔:從 0 到 1 指導(dǎo)開發(fā)者如何使用播放器,并提供播放器各個功能的使用說明。 API 文檔:使用代碼注釋借助 TypeDoc 工具生成,并跟隨播放器發(fā)布而更新,能夠保證實時性。 代碼示例:包括可以實時編輯的 Sandbox 類示例,以及使用 ice.js / rax-app 框架開發(fā)的線上示例應(yīng)用。
下一步演進路徑
VideoX 不是一個全新的技術(shù),實際上它已經(jīng)發(fā)展了很多年。但最近一年我們才真正把它當(dāng)做一個技術(shù)產(chǎn)品在運作和對外提供服務(wù)。未來 VideoX 需要持續(xù)修好內(nèi)功,為大淘寶業(yè)務(wù)提升全鏈路的畫質(zhì)等視頻播放體驗。落在的具體技術(shù)指標(biāo)上,就是提升播放內(nèi)核的穩(wěn)定性、性能和架構(gòu)開放性,重點是直播的穩(wěn)定性、 H.265 Seek 的性能和 ARTC 協(xié)議的支持。相應(yīng)的需要對播放器配套設(shè)施需要升級,包括測試方案及其覆蓋度的完善、日志能力的完備、體驗評價體系及全鏈路畫質(zhì)及質(zhì)量監(jiān)控的建設(shè)。
如果大家有相類似的場景、相關(guān)的經(jīng)驗,歡迎與我們進行交流。
祝 您:2022 年暴富!萬事如意!
點贊和在看就是最大的支持,
比心??
