7 月 17 日下午,在前端專場巡迴沙龍北京站中,聲網Agora跨平臺開發工程師盧旭輝帶來了《Flutter2 渲染原理和如何實現視訊渲染》 的主題分享,本文是對演講內容的整理。
本次分享主要包括 3 個部分:
- Flutter2 概覽。
- Flutter2 視訊渲染外掛的實踐。
- Flutter2 渲染原理(原始碼)。
前言
其實 Flutter1 在國內的佔有率並不算高,很多開發者可能知道 Flutter 的上層語言是基於 Google 的 Dart (一個曾經企圖取代 JavaScript 的語言,但最後以失敗告終),而Dart語言也是很多開發者不太能接受Flutter的點。國內很多公司可能還是選用 ReactNative 或者堅持原生開發,不過伴隨著 Flutter2 的問世(全平臺支援),以及阿里的北海框架(基於 Flutter Engine 的渲染能力實現的上層使用 JavaScript 的跨平臺框架),我相信 Flutter2 未來可期。考慮到很多讀者可能是前端開發者,所以在第三部分我會以 Web 的視角切入,大家會看到很多熟悉又陌生的內容,是不是 Flutter 開發者或者是否瞭解 Flutter 都不重要,重要的是 Flutter 的設計思想,希望對大家有所幫助。
Flutter2 概覽
Flutter2 是 Google 在 2021 年 3 月份釋出的 Flutter 最新版本,它基於 Dart1.12 支援了 Null-Safety (空安全檢查),大家可以類比TypeScript的"?",編譯器會要求你對可能為空的資料進行校驗,這樣可以在開發過程中避免一些空指標的問題。而更為重要的就是對 Web 端提供了穩定版的支援,對桌面端的支援也已經合入。
下面我們一起看下 Flutter2 的整體架構:
Flutter2 的 Web 部分包括 Framework 層和 Browser 層,其中 Framework 層涵蓋渲染、繪製、手勢處理等,Browser 層涵蓋 CSS、HTML、Canvas、WebGL 等(畢竟還是在瀏覽器上執行),而最後的 WebAssembly 是為了使用 C 和 C++ 從而排程 Skia 渲染引擎,這個我們在第三部分也會詳細介紹。
Native 部分除了通用的 Framework 層,還包括 Engine 層 和Embedder 層,其中 Engine 層主要包括 Dart 虛擬機器、Isolate 的初始化,還有圖層合成、GPU 渲染、平臺通道、文字佈局等,而 Embedder 層主要用於不同平臺的特性適配。
乍一看,Web 和 Native 的差異還是挺大的,但其實 Web 這邊也有一個基於 Dart 開發的 Engine 層,叫作 web_ui,主要用來處理 Web 上的 Composition 和 Rendering 等。
接下來簡單看一下 Flutter2 的平臺差異,如上圖所示。目前 Flutter2 支援 6 個主流平臺,分別是 Web、Android、iOS、Windows、macOS 和 Linux。對比其他的跨平臺框架,比如 ReactNative 和 Electron (分別是移動端和桌面端的代表),Flutter2 有著更為豐富的平臺支援,雖然 ReactNative 也有微軟貢獻的桌面端支援,以及 expo 對 Web 的支援,但還不夠統一。
對於一些構建工具或包管理工具, Flutter2 使用了各個平臺比較標準的方式,比如 Web 還是基於 JavaScript,這得利於 dart2js 將 Dart 編譯為 JavaScript;在 Android 中還是基於 Gradle 體系;在 iOS 和 macOS 中是基於 CocoaPods 把 Flutter 引入工程中;在 Windows 和 Linux 中則主要是基於 CMake。
關於 Flutter 的一些特性,比如 PlatformView,它提供了橋接原生控制元件的能力,比如在 Web 上顯示一個 Element 或者在 Android、iOS 上顯示自定義的 View。不過目前桌面端暫不支援 PlatformView,這並不是說技術上無法實現,而是目前還未開發。ExternalTexture 是外接紋理,使用者可以對自己的圖形資料進行渲染。dart::ffi 使 Flutter 擁有直接呼叫 C 和 C++ 的能力,這兩點除了 Web 都是支援的。
Flutter2 視訊渲染外掛的實現
1、渲染視訊外掛實現流程
接下來將分享下聲網在視訊渲染外掛方面的實踐,這裡主要針對 Web 和桌面端。
就像在前面平臺差異中所描述的那樣,Web 不支援 ExternalTexture,Desktop 不支援 PlatformView。所以在 Web 上我們通過 PlatformView 的方式去實現視訊渲染,基本的流程是使用 ui.platformViewRegistry 註冊 PlatformView 並返回 DivElement,在 DivElement 建立完成之後,需要使用 package:js 實現 Dart 和 JavaScript 的互相呼叫。
聲網有專門的 Web 音視訊 SDK,所以我們並沒有在 Dart 層做過多的操作,而是做了 JS 層的包裝,由這個包裝庫來排程 SDK 操作 WebRTC 以建立 VideoElement,最後 append 到先前建立的 DivElement 中實現視訊渲染。
接下來看一下桌面端的方案,因為它不支援 PlatformView,所以想實現自定義的視訊渲染,我們只能使用 ExternalTexture 方案,通過 MethodChannel 呼叫 Native 層中自定義的 createTextureRender 函式,由它排程 FlutterTextureRegistry 建立 FlutterTexture,同時 將textureId 拋回 Dart 層與 Texture Widget 繫結。Native SDK 的視訊資料會在 AgoraRtcWrapper 層進行影像格式轉化,然後我們可以通過 FlutterTextureRegistry 的 MarkTextureFrameAvailable 函式通知 FlutterTexture 從回撥中獲取影像資料。
2、Flutter2 開發中遇到的一些坑
在外掛開發過程中我們也會遇到一些問題,這裡給大家簡單分享一下:
就桌面端而言,macOS 是 OC 標頭檔案,Windows 是 C++ 的標頭檔案。Linux 則是 C 的標頭檔案,這部分並沒有完全統一,甚至有些 API 都不一樣,所以在桌面開發過程中會遇到很多麻煩,畢竟它目前也沒有完全穩定。
具體舉一些案例,如上圖所示,前面 3 個都是在 Web上遇到的問題。
1.ui.platformViewRegistry在Web上會報錯,是因為它並沒有在Framework層的ui.dart中定義,而是定義在web_ui/ui.dart中,不過它並不影響執行,所以可以選擇使用ignore註釋忽略它。
2.我們使用 dart::js,比如構建一個 JavaScript 物件,這時候會使用 @JS 的註解進行宣告,如果沒有加上external建構函式,雖然在 Debug 模式下能夠正常執行,但在 Profile 和 Release 模式下會報錯。
3.dart::io 主要用來做一些具體平臺的呼叫,比如平臺判斷在 Web 上是無法使用的。我們可以使用 if(dart.library.html) 在 import 的時候指向自定義的 Dart 檔案,並對相關 API 定義空實現,也可以使用 kIsWeb 在 Web 上不去執行相關 API。
4.在 Windows 上,是使用 EncodableValue 來進行 Dart和C++ 的通訊(基於 C++17 的 std::variant,可以理解成 TypeScript 中的 type1|type2|type3)。在處理 int32 和 int64 的時候,Framework 層直接判斷是不是超過 int32最大值,如果超過則直接標註成 int64,有用過聲網 SDK 的開發者可能會知道,我們的 使用者ID的型別是 uint32,uint32 取值範圍有部分割槽間大於 int32 並小於 int64,因此如果單純使用 std::get 來獲取,則不論指定 int32_t 還是 int64_t 都有可能報錯,好在它提供了 LongValue 函式,在內部做好了判斷並統一使用 int64 返回。
接下來是本次主題的重點 Flutter2 渲染原理,Flutter 引擎這部分有很多原理是通用的,只不過在 Web 上用 Dart 實現,在 Native 上則主要使用 C 和 C++ 實現。
Flutter2 的渲染原理
1、Flutter Framework
在正式開始前,我們先簡單回顧一下,之前提到 Flutter 框架分為 Framework 部分和 Engine 部分,而渲染流程也是這兩個部分互相配合完成的,但是區別於其他框架由上層處理完後直接交給下層的特點,Flutter Engine 會提供一些 Builder 供 Framework 使用,所以很多流程都由這兩個部分來回撥度完成的。
先看一下Flutter的整個渲染流程,UserInput 是處理使用者輸入,Animation 是動畫,不過這兩個部分不是今天要探討的重點,Build 主要用於使 Widget 生成 Flutter 框架能識別的 RenderObject,Layout 主要用於確定元件位置和尺寸等,Paint 主要用於轉化渲染物件為 Layer,再由 Composition 進行合併,最後 Rasterize 光柵化進行 GPU 渲染。
Flutter 在處理 UI 時都是基於樹形結構,從下圖中我們可以看到 3 個樹形結構,分別是 Widget Tree、Element Tree 和 Render Tree。
我們從 Widget 開始,建立一個 Container,其中包含 Row ( Flex 佈局容器),而 Row 又包含 Image 和 Text。Container 內部包含 ColoredBox,它可以作為背景或者邊框。Image 內部包含 RawImage,Text內部則包含了 RichText,只有 ColoredBox、Row、RawImage 和 RichTexth 才會被轉為 RenderObjectElement,它們最終會分別生成對應的 RerderObject。
那麼我們看一下 RenderObject 是什麼,它是真正需要被渲染的物件,其中的 attach 函式會把渲染的流程交給 PipelineOwner 管理,下圖中 3 個函式主要用於判斷是否需要 Layout、是否需要被合成,以及是否需要繪製。
現在看一下PipelineOwner的主要功能,它用於管理渲染流程,首先 Flutter 初始化時會註冊一個幀回撥,Flutter 的幀是由其自身管理的,隨即會在回撥中觸發 flushLayout、flushCompositingBits 和 flushPaint這 3 個函式,它們和之前提到的 RenderObject 的 3 個 mark 函式相對應。
PipelineOwner 中有 3 個陣列,之前被 mark 的 RenderObject 會分別存放在這個 3 個陣列中,最後 flush 的時候可以快速遍歷這些 RenderObject。經過 PipelineOwner 處理之後,它會呼叫 RenderView 的 compositeFrame 函式,這部分我們會在後文做講解。
我們先來重點看下 flushPaint 函式,flushPaint 會呼叫 RenderObject 的 paint 函式,這是一個抽象函式,它本身是沒有實現的,而是由繼承它的子類去實現。
可以看到 paint 函式的第一個引數是 PaintingContext,我們來看一下它的部分 API,它們的返回值都是 Layer,包括後面的 pushClipRect 等函式會分別返回 Layer 的不同子類。所以 paint 函式的一個職責就是將 RenderObject 轉成 Layer,並將其新增到其成員的 ContainerLayer 中,順帶一提,這裡的 LayerHandle 是一個引用計數,用來處理自動釋放。
而 paint 函式的另一個職責就是對於需要繪製的 RenderObject,通過 PictureRecorder 將 Canvas 的繪製指令儲存起來。
Canvas 主要用於繪製需要繪製的物件,比如之前提到的 RichText、RawImage 等,除此之外,還可以進行 transform、clipPath 等操作。
這裡的 Canvas 工廠構造中,會判斷 useCanvasKit 並構造不同的 Canvas,為什麼會有這個邏輯,這裡先按下不表,後面會介紹。我們先按照 Render Pipeline 往下看。
之前提到的 PipeLineOwner 流程結束後,會呼叫 RenderView 的 compositeFrame 函式進行 Layer 合成。而在 compositeFrame 函式中,我們可以看到幾個非常重要的 Class,那就是 Scene 和 SceneBuilder,Scene 是 Layer 合成完畢後的產物,由 SceneBuiler 構建得到。
如圖所示,最後它會呼叫 _window.render 函式,這裡的 _window 是 SingletonFlutterWindow,它是一個單例的 RenderView,後面會詳細介紹,我們先看一下 Build Scene 的流程。
這裡我們可以看到 Layer 的部分原始碼,之前提到 RenderObject 中有一個 ContainerLayer,buildScene 就是呼叫 ContainerLayer 的 buildScene 函式(如上圖的右半部分),隨後會呼叫 Layer 的 addToScene 函式,它和 RenderObject 的 paint 函式類似,也是一個抽象函式,需要 Layer 的子類自己去實現,比如 ContainerLayer 的 addToScene 函式就是遍歷 Child Tree 來分別呼叫子 Layer 的 addToScene。
那 addToScene 做了什麼呢,它實際上是呼叫 SceneBuilder 提供的 pushXXX 函式,這些函式的返回值也是 Layer,只不過是 EngineLayer,Layer 是 Framework 中圖層的抽象,而EngineLayer 是 Engine 中圖層的抽象,隨後在 Engine 層將這些 EngineLayer 組合到 Scene 中。
2、Flutter Engine
Framework 層已經介紹得差不多了,接下來我們來看一下 Engine 層。
簡單回顧一下,我們的 Widget 會經由這樣的轉換流程:Widget->RenderObject->Layer->EngineLayer->Scene,那麼這個 Scene 如何渲染出來呢?
這裡我們看到了之前提到的 SingletonFlutterWindow,它的 render 函式會呼叫 EnginePlatformDispatcher 的 render 函式,這裡我們又看到了熟悉的 useCanvasKit,根據判斷將 Scene 強轉成了不同的 Scene,那麼這個 useCanvasKit 到底表示什麼呢,我們接著往下看。
這個時候我們必須得引入一個概念,就是 Web Renderer,在 Flutter Web 中有兩種渲染模式:一種是基於 HTML 標籤的渲染模式,它會將 Flutter 的 Widget 都對映成不同的標籤,無法單純用標籤表示的就會使用 Canvas 進行繪製,有點類似於 ReactNative 的表現形式。
另一種則是基於 CanvasKit 的渲染模式,它會下載 2MB 的 wasm 檔案以呼叫 Skia 渲染引擎,Widget 渲染都是通過該引擎來繪製的。
我們可以通過命令列引數在 flutter build 或者 run 的時候指定渲染模式,值得一提的是,預設的渲染模式是 auto,在桌面端瀏覽器上預設是 CanvasKit,而在移動端 WebView 上預設是 HTML。
首先,我們來看一下 HTML 渲染模式,以 我們 Flutter SDK 的 API Example 為例,通過 Elements Tree 可以看到,它的標籤層級還是比較多的,圖片中的 <canvas> 標籤指向了 "Basic" 的文字,這說明該模式下文字的渲染使用的是 Canvas,那為什麼要使用 Canvas 繪製文字而不使用瀏覽器預設的文字渲染能力呢?那是因為要抹除平臺渲染表現的差異,尤其是文字的換行處理等,Flutter 內建了文字排版的引擎,會基於該引擎進行渲染。此處延伸一下,比如輸入框元件,在沒有獲取焦點的狀態下,它其實和 Text 是類似的,如果獲取了焦點 Flutter 則會新增一個 <input> 標籤,然後接收輸入的文字資訊,當焦點失去的時候再隱藏,這是一個非常巧妙的方案。
接下我們看一下在 HTML 渲染模式下的一些細節。之前按下不表的 Canvas 在這裡就要顯示它的真身了,在HTML渲染模式下會構建 SurfaceCanvas,可以從右圖中看到List,這就是存放繪圖指令的集合。
而對於 SceneBuilder,這裡的是其子類 SurfaceSceneBuilder,我們可以先看一下下圖中右側的PersistedSurface。
它是 EngineLayer 的子類,並且擁有一個 rootElement 屬性,還有一個 visitChildren 函式,這也是一個抽象函式。PersistedLeafSurface 是一個沒有 child 的EngineLayer,所以它的 visitChildren 是空實現,由它派生出 PersistedPicture 和 PersistedPlatformView,分別對應圖片文字(我們之前提到文字是使用 Canvas 繪製的)和平臺 View。PersistedContainerSurface 就是一個容器的 EngineLayer,它也有非常多的子類,比如 PersistedClipPath、PersistedTransform等,這些 EngineLayer 對應到之前 API Example 複雜的 Elements Tree 中的各個自定義標籤。
在 SurfaceSceneBuilder 的 build 函式執行後,生成的 SurfaceScene 中的 webOnlyRootElement 就已經包含了我們的整個 Html Element 了。
最後我們可以看到 SurfaceScene 會呼叫 DomRenderer 的 renderScene 函式,將這些 Element 新增到 _sceneHostElement 中。
到這裡 HTML 渲染模式就完結了。
下面我們看一下 CanvasKit 的渲染模式,從 Elements Tree 中我們可以看到該模式下的層級非常簡單,所有的渲染都是在一個 canvas 中進行的,這裡用到的 #shadow-root 是 HTML 的一個特性,可以做到樣式隔離。
同樣,我們先從 Canvas 入手,這裡的是 CanvasKitCanvas,而繪圖指令則儲存在 CkPictureSnapshot 的 _commands 屬性中。
對於 SceneBuilder,CanvasKit 渲染模式下的子類是 LayerSceneBuilder,這裡的 Layer 類似於 HTML 渲染模式下的 PersistedSurface,都是派生自 EngineLayer,並且有用一個 ContainerLayer 包含所有的 child,也有對應的 PictureLayer 和 PlatformViewLayer。不過不同的是,它有一個 paint 函式,這裡的 paint 函式才是真正的操作 GPU 進行繪製的函式。
而 LayerSceneBuilder 的 build 函式生成的 LayerScene 中包含一個叫作 LayerTree 的根節點,和 HTML 渲染模式下的 webOnlyRootElement 相對應。
既然這裡提到 paint 函式才是真正的繪製,那麼我們來看一下它是什麼時候被呼叫的。
之前提到 How To Render Scene 的時候,LayerScene 通過呼叫 rasterizer 的 draw 函式進行繪製。Rasterizer 是負責光柵化進行 GPU 渲染的類,這裡會先呼叫 acquireFrame 從 LayerTree 中獲取 frameSize 以構建 SurfaceFrame,同時也會在其內部構建 SkSurface,繫結 WebGLContext 等一系列對 Skia 的排程操作。
context.acquireFrame 生成的 Frame 只是一個簡單的聚合類,不用太在意,隨後呼叫 Frame 的 raster 函式進行光柵化處理。最後的 addToScene 則是將 baseSurface 中的 canvas 的 HTML 標籤新增到 skiaSceneHost 中。
光柵化階段由 preroll 和 paint 組成,分別計算繪製邊界,以及遍歷 LayerTree 並呼叫所有 Layer 的 paint 函式,這裡的 PaintContext 區別於 Framework 的 PaintingContext,它持有所有的 canvas,以便於不同的 Layer 對其進行 paint 操作。
至此,CanvasKit 渲染模式下的流程也差不多走完了,我們最後看一下最終是如何顯示在HTML 中的。其實,CanvasKit 渲染模式下最終也使用了 DomRenderer,在 Flutter 的初始化流程中,我們可以看到,initializeCanvasKit 函式的前半部分是我們之前提到的引入 Skia 的 wasm 資源和對應的 JavaScript 檔案;後半部分則是建立了一個 skiaSceneHost 根節點,這個 Element 就是之前 baseSurface.addToScene 中引用的。
整個渲染原理到這裡就介紹完了,當然,整個渲染中還有很多的細節,比如 SurfaceFactory 中除了 baseSurface 還有 backupSurface 可以對繪製進行快取等,這些點每個展開都能作為一個單獨的議題進行討論。最後貼上一個總結的流程圖,大家可以結合前文回顧一下整個流程。
在分享的最後,給大家附上 Flutter RTC SDK 的 GitHub 連結,目前我們已經在 dev/flutter 分支上做了 Flutter2 的適配。在 Web 和桌面端上也支援了螢幕共享。大家可以自行體驗,如果有任何問題或者建議,歡迎大家反饋,如果使用體驗還不錯,也歡迎大家給我們的倉庫點上 Star。