Flutter 實現原理及在馬蜂窩的跨平臺開發實踐

一直以來,跨平臺開發都是困擾移動客戶端開發的難題。

在馬蜂窩旅遊 App 很多業務場景裏,我們嘗試過一些主流的跨平臺開發解決方案,比如 WebView 和 React Native,來提升開發效率和用戶體驗。但這兩種方式也帶來了新的問題。

比如使用 WebView 跨平臺方式,優點確實非常明顯。基於 WebView 的框架集成了當下 Web 開發的諸多優勢:豐富的控件庫、動態化、良好的技術社區、測試自動化等等。但是缺點也同樣明顯:渲染效率和 JavaScript 的執行能力都比較差,使頁面的加載速度和用戶體驗都不盡如人意。

而使用以 React Native(簡稱 RN)爲代表的框架時,維護又成了大難題。RN 使用類 HTML+JS 的 UI 創建邏輯,生成對應的原生頁面,將頁面的渲染工作交給了系統,所以渲染效率有很大的優勢。但由於 RN 代碼是通過 JS 橋接的方式轉換爲原生的控件,所以受各個系統間的差異影響非常大,雖然可以開發一套代碼,但對各個平臺的適配卻非常的繁瑣和麻煩。

爲什麼是 Flutter

2018 年 12 月初,Google 正式發佈了開源跨平臺 UI 框架 Flutter 1.0 Release 版本,馬蜂窩電商客戶端團隊進行了調研與實踐,發現 Flutter 能很好的幫助我們解決開發中遇到的問題。

  1. 跨平臺開發針對 Android 與 iOS 的風格設計了兩套設計語言的控件實現(Material & Cupertino)。這樣不但能夠節約人力成本,而且在用戶體驗上更好的適配 App 運行的平臺。
  2. 重寫了一套跨平臺的 UI 框架,渲染引擎是依靠 Skia 圖形庫實現。Flutter 中的控件樹直接由渲染引擎和高性能本地 ARM 代碼直接繪製,不需要通過中間對象(Web 應用中的虛擬 DOM 和真實 DOM,原生 App 中的虛擬控件和平臺控件)來繪製,使它有接近原生頁面的性能,幫助我們提供更好的用戶體驗。
  3. 同時支持 JIT 和 AOT 編譯。JIT 編譯方式使其在開發階段有個備受歡迎的功能——熱重載(HotReload),這樣在開發時可以省去構建的過程,提高開發效率。而在 Release 運行階段採用 AOT 的編譯方式,使執行效率非常高,讓 Release 版本發揮更好的性能。

於是,電商客戶端團隊決定探索 Flutter 在跨平臺開發中的新可能,並率先應用於商家端 App 中。在本文中,我們將結合 Flutter 在馬蜂窩商家端 App 中的應用實踐,探討 Flutter 架構的實現原理,有何優勢,以及如何幫助我們解決問題。

Flutter 架構和實現原理

Flutter 使用 Dart 語言開發,主要有以下幾點原因:

  • Dart 一般情況下是運行 DartVM 上,但是也可以編譯爲 ARM 代碼直接運行在硬件上。
  • Dart 同時支持 AOT 和 JIT 兩種編譯方式,可以更好的提高開發以及 App 的執行效率。
  • Dart 可以利用獨特的隔離區(Isolate)實現多線程。而且不共享內存,可以實現無鎖快速分配。
  • 分代垃圾回收,非常適合 UI 框架中常見的大量 Widgets 對象創建和銷燬的優化。
  • 在爲創建的對象分配內存時,Dart 是在現有的堆上移動指針,保證內存的增長是程線性的,於是就省了查找可用內存的過程。

Dart 主要由 Google 負責開發和維護。目前 Dart 最新版本已經是 2.2,針對 App 和 Web 開發做了很多優化。並且對於大多數的開發者而言,Dart 的學習成本非常低。

Flutter 架構也是採用的分層設計。從下到上依次爲:Embedder(嵌入器)、Engine、Framework。

<center>圖 1: Flutter 分層架構圖</center>

Embedder是嵌入層,做好這一層的適配 Flutter 基本可以嵌入到任何平臺上去; Engine層主要包含 Skia、Dart 和 Text。Skia 是開源的二位圖形庫;Dart 部分主要包括 runtime、Garbage Collection、編譯模式支持等;Text 是文本渲染。Framework在最上層。我們的應用圍繞 Framework 層來構建,因此也是本文要介紹的重點。

Framework

1.【Foundation】在最底層,主要定義底層工具類和方法,以提供給其他層使用。

2.【Animation】是動畫相關的類,可以基於此創建補間動畫(Tween Animation)和物理原理動畫(Physics-based Animation),類似 Android 的 ValueAnimator 和 iOS 的 Core Animation。

3.【Painting】封裝了 Flutter Engine 提供的繪製接口,例如繪製縮放圖像、插值生成陰影、繪製盒模型邊框等。

4.【Gesture】提供處理手勢識別和交互的功能。

5.【Rendering】是框架中的渲染庫。控件的渲染主要包括三個階段:佈局(Layout)、繪製(Paint)、合成(Composite)。

從下圖可以看到,Flutter 流水線包括 7 個步驟。

<center>圖 2: Flutter 流水線</center>

首先是獲取到用戶的操作,然後你的應用會因此顯示一些動畫,接着 Flutter 開始構建 Widget 對象。

Widget 對象構建完成後進入渲染階段,這個階段主要包括三步:

  • 佈局元素:決定頁面元素在屏幕上的位置和大小;
  • 繪製階段:將頁面元素繪製成它們應有的樣式;
  • 合成階段:按照繪製規則將之前兩個步驟的產物組合在一起。

最後的光柵化由 Engine 層來完成。

在渲染階段,控件樹(widget)會轉換成對應的渲染對象(RenderObject)樹,在 Rendering 層進行佈局和繪製。

在佈局時 Flutter 深度優先遍歷渲染對象樹。數據流的傳遞方式是從上到下傳遞約束,從下到上傳遞大小。也就是說,父節點會將自己的約束傳遞給子節點,子節點根據接收到的約束來計算自己的大小,然後將自己的尺寸返回給父節點。整個過程中,位置信息由父節點來控制,子節點並不關心自己所在的位置,而父節點也不關心子節點具體長什麼樣子。

<center>圖 3: 數據流傳遞方式</center>

爲了防止因子節點發生變化而導致的整個控件樹重繪,Flutter 加入了一個機制——Relayout Boundary,在一些特定的情形下 Relayout Boundary 會被自動創建,不需要開發者手動添加。

例如,控件被設置了固定大小(tight constraint)、控件忽略所有子視圖尺寸對自己的影響、控件自動佔滿父控件所提供的空間等等。很好理解,就是控件大小不會影響其他控件時,就沒必要重新佈局整個控件樹。有了這個機制後,無論子樹發生什麼樣的變化,處理範圍都只在子樹上。

<center>圖 4: Relayout Boundary 機制</center>

在確定每個空間的位置和大小之後,就進入繪製階段。繪製節點的時候也是深度遍歷繪製節點樹,然後把不同的 RenderObject 繪製到不同的圖層上。

這時有可能出現一種特殊情況,如下圖所示節點 2 在繪製子節點 4 時,由於其節點 4 需要單獨繪製到一個圖層上(如 video),因此綠色圖層上面多了個黃色的圖層。之後再需要繪製其他內容(標記 5)就需要再增加一個圖層(紅色)。再接下來要繪製節點 1 的右子樹(標記 6),也會被繪製到紅色圖層上。所以如果 2 號節點發生改變就會改變紅色圖層上的內容,因此也影響到了毫不相干的 6 號節點。

<center>圖 5: 繪製節點與圖層的關係</center>

爲了避免這種情況,Flutter 的設計者這裏基於 Relayout Boundary 的思想增加了Repaint Boundary。在繪製頁面時候如果遇見 Repaint Boundary 就會強制切換圖層。

如下圖所示,在從上到下遍歷控件樹遇到 Repaint Boundary 會重新繪製到新的圖層(深藍色),在從下到上返回的時候又遇到 Repaint Boundary,於是又增加一個新的圖層(淺藍色)。

<center>圖 6: Repaint Boundary 機制</center>

這樣,即使發生重繪也不會對其他子樹產生影響。比如在 Scrollview 上,當滾動的時候發生內容重繪,如果在 Scrollview 以外的地方不需要重繪就可以使用 Repaint Boundary。Repaint Boundary 並不會像 Relayout Boundary 一樣自動生成,而是需要我們自己來加入到控件樹中。

6.【Widget】控件層。所有控件的基類都是 Widget,Widget 的數據都是隻讀的, 不能改變。所以每次需要更新頁面時都需要重新創建一個新的控件樹。每一個 Widget 會通過一個 RenderObjectElement 對應到一個渲染節點(RenderObject),可以簡單理解爲 Widget 中只存儲了頁面元素的信息,而真正負責佈局、渲染的是 RenderObject。

在頁面更新重新生成控件樹時,RenderObjectElement 樹會盡量保持重用。由於 RenderObjectElement 持有對應的 RenderObject,所有 RenderObject 樹也會儘可能的被重用。如圖所示就是三棵樹之間的關係。在這張圖裏我們把形狀當做渲染節點的類型,顏色是它的屬性,即形狀不同就是不同的渲染節點,而顏色不同只是同一對象的屬性的不同。

<center>圖 7:Widget、Element 和 Render 之間的關係</center>

如果想把方形的顏色換成黃色,將圓形的顏色變成紅色,由於控件是不能被修改的,需要重新生成兩個新的控件 Rectangle yellow 和 Circle red。由於只是修改了顏色屬性,所以 Element 和 RenderObject 都被重用,而之前的控件樹會被釋放回收。

<center>圖 8: 示例</center>

那麼如果把紅色圓形變成三角形又會怎樣呢?由於這裏發生變化的是類型,所以對應的 Element 節點和 RenderObject 節點都需要重新創建。但是由於黃色方形沒有發生改變,所以其對應的 Element 節點和 RenderObject 節點沒有發生變化。

<center>圖 9: 示例</center>

7. 最後是【Material】 & 【Cupertino】,這是在 Widget 層之上框架爲開發者提供的基於兩套設計語言實現的 UI 控件,可以幫助我們的 App 在不同平臺上提供接近原生的用戶體驗。

Flutter 在馬蜂窩商家端App 中的應用實踐

<center>圖 10: 馬蜂窩商家端使用 Flutter 開發的頁面</center>

開發方式:Flutter + Native

由於商家端已經是一款成熟的 App,不可能創建一個新的 Flutter 工程全部重新開發,因此我們選擇 Native 與 Flutter 混編的方案來實現。
在瞭解 Native 與 Flutter 混編方案前,首先我們需要了解在 Flutter 工程中,通常有以下 4 種工程類型:

1. Flutter Application

標準的 Flutter App 工程,包含標準的 Dart 層與 Native 平臺層。

2. Flutter Module

Flutter 組件工程,僅包含 Dart 層實現,Native 平臺層子工程爲通過 Flutter 自動生成的隱藏工程(.ios /.android)。

3. Flutter Plugin

Flutter 平臺插件工程,包含 Dart 層與 Native 平臺層的實現。

4. Flutter Package

Flutter 純 Dart 插件工程,僅包含 Dart 層的實現,往往定義一些公共 Widget。

瞭解了 Flutter 工程類型後,我們來看下官方提供的一種混編方案(https://github.com/flutter/flutter/wiki/Add-Flutter-to-existing-apps),即在現有工程下創建Flutter Module 工程,以本地依賴的方式集成到現有的 Native 工程中。

官方集成方案(以 iOS 爲例)

a. 在工程目錄創建 FlutterModule,創建後,工程目錄大致如下:

b. 在 Podfile 文件中添加以下代碼:

flutter_application_path = '../flutter_Moudule/'

該腳本主要負責:

  • pod 引入 Flutter.Framework 以及 FlutterPluginRegistrant 註冊入口
  • pod 引入 Flutter 第三方 plugin
  • 在每一個 pod 庫的配置文件中寫入對 Generated.xcconfig 文件的導入
  • 修改 pod 庫的 ENABLE_BITCODE = NO(因爲 Flutter 現在不支持 bitcode)

c. 在 iOS 構建階段 Build Phases 中注入構建時需要執行的 xcode_backend.sh (位於 FlutterSDK/packages/flutter_tools/bin) 腳本:

"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh" build

該腳本主要負責:

  • 構建 App.framework 以及 Flutter.framework 產物
  • 根據編譯模式(debug/profile/release)導入對應的產物
  • 編譯 flutter_asset 資源
  • 把以上產物 copy 到對應的構建產物中

d. 與 Native 通信

  • 方案一:改造 AppDelegate 繼承自 FlutterAppDelegate
  • 方案二:AppDelegate 實現 FlutterAppLifeCycleProvider 協議,生命週期由 FlutterPluginAppLifeCycleDelegate 傳遞給 Flutter

以上就是官方提供的集成方案。我們最終沒有選擇此方案的原因,是它直接依賴於 FlutterModule 工程以及 Flutter 環境,使 Native 開發同學無法脫離 Flutter 環境開發,影響正常的開發流程,團隊合作成本較大;而且會影響正常的打包流程。(目前 Flutter 團隊正在重構嵌入 Native 工程的方式)

最終我們選擇另一種方案來解決以上的問題:遠端依賴產物。

<center>圖 11 :遠端依賴產物</center>

iOS 集成方案

通過對官方混編方案的研究,我們瞭解到 iOS 工程最終依賴的其實是 FlutterModule 工程構建出的產物(Framework,Asset,Plugin),只需將產物導出並 push 到遠端倉庫,iOS 工程通過遠端依賴產物即可。

依賴產物目錄結構如下:

  • App.framework: Flutter 工程產物(包含 Flutter 工程的代碼,Debug 模式下它是個空殼,代碼在 flutter_assets 中)。
  • Flutter.framework:Flutter 引擎庫。與編譯模式(debug/profile/release)以及 CPU 架構(arm*, i386, x86_64)相匹配。
  • lib.a & .h 頭文件: FlutterPlugin 靜態庫(包含在 iOS 端的實現)。
  • flutter_assets: 包含 Flutter 工程字體,圖片等資源。在 Flutter1.2 版本中,被打包到 App.framework 中。

Android 集成方案

Android Nativite 集成是通過 Gradle 遠程依賴 Flutter 工程產物的方式完成的,以下是具體的集成流程。

a.創建 Flutter 標準工程

$ flutter create flutter_demo

默認使用 Java 代碼,如果增加 Kotlin 支持,使用如下命令:

$ flutter create -a kotlin flutter_demo

b.修改工程的默認配置

  1. 修改 app module 工程的 build.gradle 配置 apply plugin: 'com.android.application' => apply plugin: 'com.android.library',並移除 applicationId 配置
  2. 修改 root 工程的 build.gradle 配置

    在集成過程中 Flutter 依賴了三方 Plugins 後,遇到 Plugins 的代碼沒有被打進 Library 中的問題。通過以下配置解決(這種方式略顯粗暴,後續的優化方案正在調研)。

subprojects {
   project.buildDir = "${rootProject.buildDir}/app"
}
  1. app module 增加 maven 打包配置
  2. c. 生成 Android Flutter 產物
$ cd android
$ ./gradlew uploadArchives

官方默認的構建腳本在 Flutter 1.0.0 版本存在 Bug——最終的產物中會缺少 flutter_shared/icudtl.dat 文件,導致 App Crash。目前的解決方式是將這個文件複製到工程的 assets 下(在 Flutter 最新 1.2.1 版本中這個 Bug 已被修復,但是 1.2.1 版本又出現了一個 UI 渲染的問題,所以只能繼續使用 1.0.0 版本)。

d.Android Native 平臺工程集成,增加下面依賴配置即可,不會影響 Native 平臺開發的同學

implementation 'com.mfw.app:MerchantFlutter:0.0.5-beta'

Flutter 和 iOS、Android 的交互

使用平臺通道(Platform Channels)在 Flutter 工程和宿主(Native 工程)之間傳遞消息,主要是通過 MethodChannel 進行方法的調用,如下圖所示:

<center>圖 12 :Flutter 與 iOS、Android 交互</center>

爲了確保用戶界面不會掛起,消息和響應是異步傳遞的,需要用 async 修飾方法,await 修飾調用語句。Flutter 工程和宿主工程通過在 Channel 構造函數中傳遞 Channel 名稱進行關聯。單個應用中使用的所有 Channel 名稱必須是唯一的; 可以在 Channel 名稱前加一個唯一的「域名前綴」。

Flutter 與 Native 性能對比

我們分別使用 Native 和 Flutter 開發了兩個列表頁,以下是頁面效果和性能對比:

iOS 對比(機型 6P 系統 10.3.3):

Flutter 頁面:

iOS Native 頁面:

可以看到,從使用和直觀感受都沒有太大的差別。於是我們採集了一些其他方面的數據。

Flutter 頁面:

iOS Native 頁面:

另外我們還對比了商家端接入 Flutter 前後包體積的大小:39Mb →  44MB

在 iOS 機型上,流暢度上沒有什麼差異。從數值上來看,Flutter 在 內存跟 GPU/CPU 使用率上比原生略高。Demo 中並沒有對 Flutter 做更多的優化,可以看出 Flutter 整體來說還是可以做出接近於原生的頁面。

下面是 Flutter 與 Android 的性能對比。

Flutter 頁面:

Android Native 頁面:

從以上兩張對比圖可以看出,不考慮其他因素,單純從性能角度來說,原生要優於 Flutter,但是差距並不大,而且 Flutter 具有的跨平臺開發和熱重載等特點極大地節省了開發效率。並且,未來的熱修復特性更是值得期待。

混合棧管理

首先先介紹下 Flutter 路由的管理:

  • Flutter 管理頁面有兩個概念:Route 和 Navigator。
  • Navigator 是一個路由管理的 Widget(Flutter 中萬物皆 Widget),它通過一個棧來管理一個路由 Widget 集合。通常當前屏幕顯示的頁面就是棧頂的路由。
  • 路由 (Route) 在移動開發中通常指頁面(Page),這跟 web 開發中單頁應用的 Route 概念意義是相同的,Route 在 Android 中通常指一個 Activity,在 iOS 中指一個 ViewController。所謂路由管理,就是管理頁面之間如何跳轉,通常也可被稱爲導航管理。這和原生開發類似,無論是 Android 還是 iOS,導航管理都會維護一個路由棧,路由入棧 (push) 操作對應打開一個新頁面,路由出棧 (pop) 操作對應頁面關閉操作,而路由管理主要是指如何來管理路由棧。

<center>圖 14 :Flutter 路由管理</center>

如果是純 Flutter 工程,頁面棧無需我們進行管理,但是引入到 Native 工程內,就需要考慮如何管理混合棧。並且需要解決以下幾個問題:

1. 保證 Flutter 頁面與 Native 頁面之間的跳轉從用戶體驗上沒有任何差異

2. 頁面資源化(馬蜂窩特有的業務邏輯)

3. 保證生命週期完整性,處理相關打點事件上報

4. 資源性能問題

參考了業界內的解決方法,以及項目自身的實際場景,我們選擇類似於 H5 在 Navite 中嵌入的方式,統一通過 openURL 跳轉到一個 Native 頁面(FlutterContainerVC),Native 頁面通過 addChildViewController 方式添加 FlutterViewController(負責 Flutter 頁面渲染),同時通過 channel 同步 Native 頁面與 Flutter 頁面。

  • 每一次的 push/pop 由 Native 發起,同時通過 channel 保持 Native 與 Flutter 頁面同步——在 Native 中跳轉 Flutter 頁面與跳轉原生無差異
  • 一個 Flutter 頁面對應一個 Native 頁面(FlutterContainerVC)——解決頁面資源化
  • FlutterContainerVC 通過 addChildViewController 對單例 FlutterViewController 進行復用——保證生命週期完整性,處理相關打點事件上報
  • 由於每一個 FlutterViewController(提供 Flutter 視圖的實現)會啓動三個線程,分別是 UI 線程、GPU 線程和 IO 線程,使用單例 FlutterViewController 可以減少對資源的佔用——解決資源性能問題

Flutter 應用總結

Flutter 一經發布就很受關注,除了 iOS 和 Android 的開發者,很多前端工程師也都非常看好 Flutter 未來的發展前景。相信也有很多公司的團隊已經投入到研究和實踐中了。不過 Flutter 也有很多不足的地方,值得我們注意:

  1. 雖然 1.2 版本已經發布,但是目前沒有達到完全穩定狀態,1.2 發佈完了就出現了控件渲染的問題。加上 Dart 語言生態小,學習資料可能不夠豐富。
  2. 關於動態化的支持,目前 Flutter 還不支持線上動態性。如果要在 Android 上實現動態性相對容易些,iOS 由於審覈原因要實現動態性可能成本很高。
  3. Flutter 中目前拿來就用的能力只有 UI 控件和 Dart 本身提供能力,對於平臺級別的能力還需要通過 channel 的方式來擴展。
  4. 已有工程遷移比較複雜,以前沉澱的 UI 控件,需要重新再實現一套。
  5. 最後一點比較有爭議,Flutter 不會從程序中拆分出額外的模板或佈局語言,如 JSX 或 XM L,也不需要單獨的可視佈局工具。有的人認爲配合 HotReload 功能使用非常方便,但我們發現這樣代碼會有非常多的嵌套,閱讀起來有些吃力。

目前阿里的閒魚開發團隊已經將 Flutter 用於大型實踐,並應用在了比較重要的場景(如產品詳情頁),爲後來者提供了良好的借鑑。馬蜂窩的移動客戶端團隊關於 Flutter 的探索纔剛剛起步,前面還有很多的問題需要我們一點一點去解決。不過無論從 Google 對其的重視程度,還是我們從實踐中看到的這些優點,都讓我們對 Flutter 充滿信心,也希望在未來我們可以利用它創造更多的價值和奇蹟。

路途雖遠,猶可期許。

本文作者:馬蜂窩電商研發客戶端團隊。

(馬蜂窩技術原創內容,轉載務必註明出處保存文末二維碼圖片,禁止商業用途,謝謝配合。)

參考文獻:

關注馬蜂窩技術,找到更多你想要的內容

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章