掘金x得物公開課 - Flutter 3.0下的混合開發演進

語言: CN / TW / HK

theme: smartblue

hello 大家好,我是《Flutter 開發實戰詳解》的作者,Github GSY 專案的負責人郭樹煜,同時也是今年新晉的 Flutter GDE,藉著本次 Google I/O 之後釋出的 Flutter 3.0,來和大家聊一聊 Flutter 裡混合開發的技術演進。

為什麼混合開發在 Flutter 裡是特殊的存在?因為它渲染的控制元件是通過 Skia 直接和 GPU 互動,也就是說 Flutter 控制元件和平臺無關,甚至連 UI 繪製執行緒都和原生平臺 UI 執行緒是相互獨立,所以甚至於 Flutter 在誕生之初都不支援和原生平臺的控制元件進行混合開發,也就是不支援 WebView ,這就成了當時最大的缺陷之一

其實從渲染的角度看 Flutter 更像是一個 2D 遊戲引擎,事實上 Flutter 在這次 Google I/O 也分享了基於 Flutter 的遊戲開發 ToolKit 和第三方工具包 Flame ,如圖所示就是本次 Google I/O 釋出的 Pinball 小遊戲,所以從這些角度上看都可以看出 Flutter 在混合開發的特殊性。

如果說的更形象簡單一點,那就是如何把原生控制元件渲染到 WebView

TT

最初的社群支援

不支援 WebView 在最初可以說是 Flutter 最大的痛點之一,所以在這樣窘迫的情況下,社群裡湧現出一些臨時的解決方法,比如 flutter_webview_plugin

類似 flutter_webview_plugin 的出現,解決了當時大部分時候 App 裡開啟一個網頁的簡單需求,如下圖所示,它的思路就是:

在 Flutter 層面放一個佔位控制元件提供大小,然後原生層在同樣的位置把 WebView 新增進去,從而達到看起來把 WebView 整合進去的效果,這個思路在後續也一直被沿用

image-20220625170833702

這樣的實現方式無疑成本最低速度最快,但是也帶來了很多的侷限性

相信大家也能想到,因為 Flutter 的所有控制元件都是渲染一個 FlutterView 上,也就是從原生的角度其實是一個單頁面的效果,所以這種脫離 Flutter 渲染樹的新增控制元件的方法,無疑是沒辦法和 Flutter 融合到一起,舉個例子:

  • 如圖一所示,從 Flutter 頁面跳到 Native 頁面的時候,開啟動畫無法同步,因為 AppBar 是 Flutter 的,而 Native 是原生層,它們不在同一個渲染樹內,所以無法實現同步的動畫效果
  • 如圖二所示,比如在開啟 Native 頁面之後,通過 Appbar 再開啟一個黃色的 Bottm Sheet ,可以看到此時黃色的 Bottm Sheet 打開了,但是卻被 Native 遮擋住(Demo 裡給 Native 設定了透明色),因為 Flutter 的 Bottm Sheet 是被渲染在 FlutterView 裡面,而 Native UI 把 FlutterView 擋住了,所以新的 Flutter UI 自然也被遮擋
  • 如圖三所示,當我們通過 reload 重刷 Flutter UI 之後,可以看到 Flutter 得 UI 都被重置了,但是此時 Native UI 還在,因為此時已經沒有返回按鍵之類的無法關閉,這也是這種整合方式一不小心就影響開發的問題
  • 如圖四通過 iOS 上的 debug 圖層,我們可以更形象地看到這種方式的實現邏輯和堆疊效果

| 動畫不同步 | 頁面被擋 | reload 之後 | iOS | | ----------------------------------------------------- | ------------------------------------------------------- | ------------------------------------------------- | ------------------------------------------------------------ | | 11111111 | 222222222 | 333333 | image-20220616142126589 |

PlatformView

隨著 Flutter 的發展,官方支援混合開發勢在必行,所以第一代 PlatformView 的支援還是誕生了,但是由於 Android 和 iOS 平臺特性的不同,最初Android 的 AndroidView 和 iOS 的 UIKitView 實現邏輯相差甚遠,以至於後面 Flutter 的 PlatformView 的每次大調整都是圍繞於 Android 在做優化

Android

最初 Flutter 在 Android 上對 PlatformView 的支援是通過 VirtualDisplay 實現,VirtualDisplay 類似於一個虛擬顯示區域,需要結合 DisplayManager 一起呼叫,VirtualDisplay 一般在副屏顯示或者錄屏場景下會用到,而在 Flutter 裡 VirtualDisplay 會將虛擬顯示區域的內容渲染在一個記憶體 Surface上。

在 Flutter 中通過將 AndroidView 需要渲染的內容繪製到 VirtualDisplays 中 ,然後通過 textureId 在 VirtualDisplay 對應的記憶體中提取繪製的紋理, 簡單看實現邏輯如下圖所示:

image-20220626151538054

這裡其實也是類似於最初社群支援的模式:通過在 Dart 層提供一個 AndroidView ,從而獲取到控制元件所需的大小,位置等引數,當然這裡多了一個 textureId ,這個 id 主要是提交給 Flutter Engine ,通過 id Flutter 就可以在渲染時將畫面從記憶體裡提出出來。

iOS

在 iOS 平臺上就不使用類似 VirtualDisplay 的方法,而是通過將 Flutter UI 分為兩個透明紋理來完成組合,這種方式無疑更符合 Flutter 社群的理念,這樣的好處是:

需要在 PlatformView 下方呈現的 Flutter UI 可以被繪製到其下方的紋理;而需要在 PlatformView 上方呈現的 Flutter UI 可以被繪製到其上方的紋理, 它們只需要在最後組合起來就可以了。

是不是有點抽象?

簡單看下面這張圖,其實就是通過在 NativeView 的不同層級設定不同的透明圖層,然後把不同位置的控制元件渲染到不同圖層,最終達到組合起來的效果。

image-20220626151526444

那明明這種方法更好,為什麼 Android 不一開始也這樣實現呢?

因為當時在實現思路上, VirtualDisplay 的實現模式並不支援這種模式,因為在 iOS 上框架渲染後系統會有回撥通知,例如:當 iOS 檢視向下移動 2px 時,我們也可以將其列表中的所有其他 Flutter 控制元件也向下渲染 2px

但是在 Android 上就沒有任何有關的系統 API,因此無法實現同步輸出的渲染。如果強行以這種方式在 Android 上使用,最終將產生很多如 AndroidView 與 Flutter UI 不同步的問題

問題

事實上 VirtualDisplay 的實現方式也帶來和很多問題,簡單說兩個大家最直觀的體會:

觸控事件

因為控制元件是被渲染在記憶體裡,雖然你在 UI 上看到它就在那裡,但是事實上它並不在那裡,你點選到的是 FlutterView,所以使用者產生的觸控事件是直接傳送到 FlutterView

所以觸控事件需要在 FlutterView 到 Dart ,再從 Dart 轉發到原生,然後如果原生不處理又要轉發回 Flutter ,如果中間還存在其他派生檢視,事件就很容易出現丟失和無法響應,而這個過程對於 FlutterView 來說,在原生層它只有一個 View 。

所以 Android 的 MotionEvent 在轉化到 Flutter 過程中可能會因為機制的不同,存在某些資訊沒辦法完整轉化的丟失。

文字輸入

一般情況下 AndroidView 是無法獲取到文字輸入,因為 VirtualDisplay 所在的記憶體位置會始終被認為是 unfocused 的狀態

InputConnectionsunfocused 的 View 中通常是會被丟棄。

所以 Flutter 重寫了 checkInputConnectionProxy 方法,這樣 Android 會認為 FlutterView 是作為 AndroidView 和輸入法編輯器(IME)的代理,這樣 Android 就可以從 FlutterView 中獲取到 InputConnections 然後作用於 AndroidView 上面。

在 Android Q 開始又因為非全域性的 InputMethodManager 需要新的相容

當然還有諸如效能等其他問題,但是至少先有了支援,有了開始才會有後續的進階,在 Flutter 3.0 之前, VirtualDisplay 一直默默在 PlatformView 的背後耕耘。

HybridComposition

時間來到 Flutter 1.2,Hybrid Composition 是在 Flutter 1.2 時釋出的 Android 混合開發實現,它使用了類似 iOS 的實現思路,提供了 Flutter 在 Android 上的另外一種 PlatformView 的實現。

如下圖是在 Dart 層使用 VirtualDisplay 切換到 HybridComposition 模式的區別,最直觀的感受應該是需要寫的 Dart 程式碼變多了。

111111

但是其實 HybridComposition 的實現邏輯是變簡單了: PlatformView 是通過 FlutterMutatorView 把原生控制元件 addViewFlutterView 上,然後再通過 FlutterImageView 的能力去實現圖層的混合

又懵了?不怕,馬上你就懂了

簡單來說就是 HybridComposition 模式會直接把原生控制元件通過 addView 新增到 FlutterView 上 。這時候大家可能會說,咦~這不是和最初的實現一樣嗎?怎麼邏輯又回去了

其實確實是社群的進階版實現,Flutter 直接通過原生的 addView 方法將 PlatformView 新增到 FlutterView 裡,而當你還需要在 PlatformView 上渲染 Flutter 自己的 Widget 時,Flutter 就會通過再疊加一個 FlutterImageView 來承載這個 Widget 的紋理。

舉一個簡單的例子,如下圖所示,一個原生的 TextView 被通過 HybridComposition 模式接入到 Flutter 裡(NativeView),而在 Android 的顯示佈局邊界和 Layout Inspector 上可以清晰看到: 灰色 TextView 通過 FlutterMutatorView 被新增到 FlutterView 上被直接顯示出來

image-20220618152055492

所以在 HybridCompositionTextView 是直接在原生程式碼上被 add 到 FlutterView 上,而不是提取紋理

那如果我們看一個複雜一點的案例,如下圖所示,其中藍色的文字是原生的 TextView ,紅色的文字是 Flutter 的 Text 控制元件,在中間 Layout Inspector 的 3D 圖層下可以清晰看到:

  • 兩個藍色的 TextView 是通過 FlutterMutatorView 被新增在 FlutterView 之上,並且把沒有背景色的紅色 RE 遮擋住了
  • 最頂部有背景色的紅色 RE 也是 Flutter 控制元件,但是因為它需要渲染到 TextView 之上,所以這時候多一個 FlutterImageView ,它用於承載需要顯示在 Native 控制元件之上的紋理,從而達 Flutter 控制元件“真正”和原生控制元件混合堆疊的效果。

image-20220616165047353

可以看到 Hybrid Composition 上這種實現,能更原汁原味地保流下原生控制元件的事件和特性,因為從原生角度看它就是原生層面的物理堆疊,需要都一個層級就多加一個 FlutterImageView ,同一個層級的 Flutter 控制元件共享一個 FlutterImageView

當然,在 HybridCompositionFlutterImageView 也是一個很有故事的物件,由於篇幅原因這裡就不詳細展開,這裡大家可以簡單看這張圖感受下,也就是在有 PlatformView 和沒有 PlatformView 是,Flutter 的渲染會有一個轉化的過程,而在這個變化過程,在 Flutter 3.0 之前可以通過 PlatformViewsService.synchronizeToNativeViewHierarchy(false); 取消

image-20220618153757996

最後,Hybrid Composition 也不少問題,比如上面的轉化就是為了解決動畫同步問題,當然這個行為也會產生一些效能開銷,例如:

在 Android 10 之前, Hybrid Composition 需要將記憶體中的每個 Flutter 繪製的幀資料複製到主記憶體,之後再從 GPU 渲染複製回來 ,所以也會導致 Hybrid Composition 在 Android 10 之前的效能表現更差,例如在滾動列表裡每個 Item 巢狀一個 Hybrid CompositionPlatformView ,就可能會變卡頓甚至閃爍。

其他還有執行緒同步,閃爍等問題,由於篇幅就不詳細展開,如果感興趣的可以詳細看我之前釋出過的 《Flutter 深入探索混合開發的技術演進》

TextureLayer

隨著 Flutter 3.0 的釋出,第一代 PlatformView 的實現 VirtualDisplay 被新的 TextureLayer 所替代,如下圖所示,簡單對比 VirtualDisplayTextureLayer 的實現差異,可以看到主要還是在於原生控制元件紋理的提取方式上

image-20220618154327890

從上圖我們可以得知:

  • VirtualDisplayTextureLayerPlugin 的實現是可以無縫切換,因為主要修改的地方在於底層對於紋理的提取和渲染邏輯
  • 以前 Flutter 中會將 AndroidView 需要渲染的內容繪製到 VirtualDisplays ,然後在 VirtualDisplay 對應的記憶體中,繪製的畫面就可以通過其 Surface 獲取得到;現在 AndroidView 需要的內容,會通過 View 的 draw 方法被繪製到 SurfaceTexture 裡,然後同樣通過 TextureId 獲取繪製在記憶體的紋理

是不是又有點蒙?簡單說就是不需要繪製到副屏裡,現在直接通過 override Viewdraw 方法就可以了。

TextureLayer 的實現裡,同樣是需要把控制元件新增到一個 PlatformViewWrapper 的原生布局控制元件裡,但是這個控制元件通過 override 了 Viewdraw 方法,把原本的 Canvas 替換成 SurfaceTexture 在記憶體的 Canvas ,所以 PlatformViewWrapper 的 child 會把控制元件繪製到記憶體的 SurfaceTexture 上。

舉個例子,還是之前的程式碼,如下圖所示,這時候通過 TextureLayer 模式執行之後,通過 Layout Inspector 的 3D 圖層可以看到,兩個原生的 TextView 通過 PlatformViewWrapper 被新增到 FlutterView 上。

但是不同的是,在 3D 圖層裡看不到 TextView 的內容,因為繪製 TextView 的 Canvas 被替換了,所以 TextView 的內容被繪製到記憶體的 Surface 上,最終會在渲染時同步 Flutter Engine 裡。

看到這裡,你可能也發現了,這時候因為有 PlatformViewWrapper 的存在,點選會被 PlatformViewWrapper 內部攔截,從而也解決了觸控的問題, 而這裡剛好有人提了一個問題,如下圖所示:

"從圖 1 Layout Inspector 看, PlatformWrapperView 是在 FlutterSurfaceView 上方,為什麼如圖 2 所示,點選 Flutter button 卻可以不觸發 native button的點選效果?"。

| 圖1 | 圖2 | | ------------------------------------------------------------ | ------------------------------------------------------------ | | image.png | img |

思考一下,因為最直觀的感受:點選不都是被 PlatformViewWrapper 攔截了嗎?明明 PlatformViewWrapper 是在 FlutterSurfaceView 之上,為什麼 FlutterSurfaceView 裡的 FlutterButton 還能被點選到

這裡簡單解釋一下:

  • 1、首先那個 Button 並不是真的被擺放在那裡,而是通過 PlatformViewWrappersuper.draw繪製到 surface 上的,所以在那裡的是 PlatformViewWrapper ,而不是 Button ,Button 的內容已經變成紋理去到了 FlutterSurfaceView 裡面
  • 2、 PlatformViewWrapper 裡重寫了 onInterceptTouchEvent 做了攔截onInterceptTouchEvent 這個事件是從父控制元件開始往子控制元件傳,因為攔截了所以不會讓 Button 直接響應,然後在 PlatformViewWrapperonTouchEvent 響應裡是做了點選區域的分發,響應會分發到了 AndroidTouchProcessor 之後,會打包發到 _unpackPointerDataPacket 進入 Dart
  • 3、 在 Dart 層的點選區域,如果沒有 Flutter 控制元件響應,會是 _PlatformViewGestureRecognizer-> updateGestureRecognizers -> dispatchPointerEvent -> sendMotionEvent 又傳送回原生層
  • 4、回到原生 PlatformViewsControllercreateForTextureLayer 裡的 onTouch ,執行 view.dispatchTouchEvent(event);

image-20220625171101069

總結起來就是:PlatfromViewWrapper 攔截了 Event ,通過 Dart 做二次分發響應,從而實現不同的事件響應 ,它和 VirtualDisplay 的不同是, VirtualDisplay 的事件響應都是在 FlutterView 上,但是TextureLayout 模式,是有獨立的原生 PlatfromViewWrapper 控制元件來開始,所以區域效果和一致性會更好。

問題

最後這裡還需要提個醒,如果你之前使用的外掛使用的是 HybirdComposition ,但是沒做相容,也就是使用的還是 PlatformViewsService.initSurfaceAndroidView 的話,它也會切換成 TextureLayer 的邏輯,所以你需要切換為 PlatformViewsService.initExpensiveAndroidView ,才能繼續使用原本 HybirdComposition 的效果

⚠️我也比較奇怪為什麼 Flutter 3.0 沒有提及 Android 這個 breaking change ,因為對於開發來說其實是無感的,不小心就掉坑裡。

那你說為什麼還要 HybirdComposition

前面我們說過, TextureLayer 是通過在 super.draw 替換 Canvas 的方法去實現繪製,但是它替換不了 Surface 裡的一些 Canvas ,所以比如一些需要 SurfaceViewTextureView 或者有自己內部特殊 Canvas 的場景,你還是需要 HybirdComposition ,只不過可能會和官方新的 API 名字一樣,它 Expensive 。

Expensive 是因為在 Flutter 3.0 正式版開始,FlutterView 在使用 HybirdComposition 時一定會 converted to FlutterImageView ,這也是 Flutter 3.0 下一個需要注意的點。

image-20220616170253242

更多內容可見 《Flutter 3.0 之 PlatformView :告別 VirtualDisplay ,擁抱 TextureLayer》

image-20220625164049356

最後

最後做個總結,可以看到 Flutter 為了混合開發做了很多的努力,特別是在 Android 上,也是因為歷史埋坑的原因,由於時間關係這裡沒辦法都詳細介紹,但是相信本次之後大家對 Flutter 的 PlatformView 實現都有了全面的瞭解,這對大家在未來使用 Flutter 也會有很好的幫助,如果你還有什麼問題,歡迎交流。

image-20220626151444011