Flutter動態化框架Thresh

xiangzhihong發表於2022-01-17

原文連結:滿幫動態化Flutter框架“Thresh”,現在開源了

一、前言

移動端技術棧自誕生以來,其雙端開發成本和釋出效率一直廣受詬病。為了解決這些問題,前端跨端技術一直在不斷嘗試,希望能一次開發、多端執行並且能做到快速釋出。期間經歷了多個技術發展階段。

第一階段:以H5為代表,基於webview渲染

只需一次開發即可執行在雙端,解決了開發效率低下的問題。但是webview存在嚴重的效能問題,使用者的互動體驗相比Native渲染有明顯差距。

第二階段:以RN和Weex為代表,前端技術棧開發,Native渲染

這些方案使用前端技術開發,最終對映到Native元件渲染,使用者體驗相比H5方案有了巨大的提升。但是這一階段的方案同樣存在不足。由於框架的渲染最終還是依賴雙端Native元件,存在雙端體驗不一致性和平臺相容問題,極端情況下開發成本甚至超過雙端Native開發。

第三階段:Flutter,自繪引擎渲染

Google基於Skia渲染引擎,推出了Flutter跨平臺框架,支援了Android/iOS/Web三個平臺(尤其2.0的釋出支援了全平臺)。

基於自繪引擎,Flutter抹平了各個平臺的差異,真正做到了一處開發,多端執行。業內對於Flutter徹底解決跨端開發的問題也寄予厚望。但是Flutter也並非完美,其動態能力不足,無法像H5、RN等技術一樣快速釋出。

為了解決動態能力不足的問題,滿幫大前端團隊從2019年開始對Flutter動態化能力進行探索,自研了動態化Flutter框架,在內部不斷優化迭代,已上線20+頁面,包括核心頁面訂單詳情、貨主貨源詳情、導航地圖等等,並且於2020年底進行了開源。

二、Flutter動態化的思考

Thresh專案推出的初心是為了能提供一種基於Flutter的完全跨端動態化方案,效能能達到甚至優於React Native,再加上其多端渲染一致性以及即將推出的Google Fuchsia系統預設開發語言為Flutter,都表明Thresh未來將會充滿想象力。

2.1、動態化常見方案

實現Flutter的動態化,通常需要考慮以下幾點:

  • Flutter編譯產物替換

Google原本打算在2019年推出Code Push方案,後來放棄了,主要兩個原因:違反應用商店的規定和安全方面考慮;但目前android是可以通過產物替換來做到動態化,iOS端則無法做到。

  • 元件化搭建

通過Dart來定義部分核心通用元件,在平臺下發已有的元件列表拼裝的頁面JSON,端上再通過解析渲染成頁面。這種方案能滿足輕互動場景,但只能支援有限動態性。

  • 自定義Dart轉換+動態邏輯對映

通過自定義一套Dart規範以及通過轉換器生成JSON來做到動態更新,效能損失小,但是邏輯動態性需要提前預埋,且前端開發同學需要一定的學習成本。

  • 自定義DSL+依賴JS引擎的動態執行

類似於RN/Weex,通過自定義動態化UI描述 + JS引擎的解釋執行轉換思路,最終構建成頁面和執行動態邏輯。這個方案對於前端開發非常友好,零學習成本,但是由於在JS引擎執行,會有一些效能損耗。

2.2、Thresh的選擇

滿幫的實際使用場景,業務快速迭代,需要Android和iOS都要支援動態性,所以產物替換的思路不能完全解決問題。隨後又考慮使用元件化思路,拼接多個業務元件雖然能搭建出頁面,但是弊端也很明顯,複雜互動邏輯時無法實現。另外自定義Dart描述UI方案雖然滿足了動態更新的要求,但是邏輯動態性依舊不強,而且Dart開發對於前端開發同學有一定的學習成本。

最終,綜合考慮了開發效率、學習成本、多端效能和一致性等因素,我們選擇了自定義JS描述UI + JS引擎的解釋執行轉換思路,類React語法結構,開發語言使用JS/TS。

三、實現原理

3.1、構建Dart頁面原理

在 Flutter 中描述檢視組成的基本單位是 Widget,每一個 Widget 只包含當前部件的配置資訊,它是一個輕量的、可被高效建立並銷燬的資料結構。而許許多多的 Widgets 組合在一起,構建出了一個包含檢視所有資訊的 WidgetTree。之後 Flutter 會從 WidgetTree 中生成 ElementTree,再由 ElementTree 生成 RenderObjectTree。ElementTree 中的 Element 會同時持有其對應的 Widget 與 renderObject。

在這裡插入圖片描述
三棵樹中,WidgetTree 會被頻繁建立於銷燬,但是 ElementTree 和 RenderObjectTree 只會在發生狀態改變的時候才會改變,ElementTree 負責元素的更新與 diff,RenderObjectTree 則負責實際的佈局與繪製。

核心思路是把 Flutter 的頁面渲染邏輯中的三棵樹中的第一棵樹Widget,通過JS 來構造。這其中要完成JS與 Flutter 層完成基礎元件對映,再通過JS引擎來生成UI描述,並傳遞給Dart層的 UIEngine,UIEngine 把UI描述轉換為 Flutter 控制元件,最終渲染成頁面。

Thresh框架完成了常用基礎元件的定義與開發,能支撐95%以上業務場景的接入,語法定義規則支援React,對前端開發人員零成本接入。現支援的元件列表以及其部分屬性如下在這裡插入圖片描述
在這裡插入圖片描述

3.1.1、Flutter初始化

Flutter 是由 main() 函式開始程式執行的,主要完成以下幾個工作:

  • 建立與 Native 之間的通訊渠道 MethodChannel 以保證所有的通訊都能夠被接收和傳送;
  • 建立接收到訊息時的所有處理方法的分發渠道,以保證所有合法的通訊都能夠在 Flutter 中被正確處理,同時通過 MethodChannel 向 JS 傳送當前裝置的媒介資料;
  • 註冊攔截函式,以便在接收到渲染 JSON 資料後將 JSON 轉換為 Widget;
  • 最後建立 Flutter App 的初始承載頁面,該頁面在接收到 JS 傳送顯示頁面的訊息之前將會一直處於等待狀態;同時向 JS 傳送 ready 訊息,表示 Flutter 環境已準備完成,可以顯示頁面。

3.1.2、生成WidgetTree

依據 Flutter 中對 Widget 註冊的所有攔截函式,JS 中會提供一套與之相對應的原子元件,以便在兩種不同的 DSL 之間進行元件的互相轉換。在 JS 中 UI 的構建通過 JSX 實現,借鑑了 React 的寫法。

通過在 JS 中構建 UI 的描述層,再將 UI 描述轉換為 JSON 格式字串,經由 Native 傳送到 Flutter ,由 Flutter 對 JSON 字串進行解析後建立對應的 WidgetTree 並執行後續渲染操作。

在這裡插入圖片描述

3.1.3、JS與Flutter通訊

在 JS 程式碼執行之前,Native 會向 JS 程式碼的執行環境中註冊兩個通訊方法,一個為 JS 向 Flutter 傳遞訊息的通道,另一個則是 Flutter 向 JS 傳遞訊息的通道。通過這兩個通道,就可以實現所有資料在 JS 與 Flutter 之間的流轉(後面3.2章節會詳細介紹)。

3.1.4、構建Flutter頁面

對於當完成所有鏈路的資料轉換後就會拿到ModelTree & WidgetTree,ModelTree會持有並快取WidgetTree,最終構建一個Widget頁面並渲染顯示。頁面構建渲染流程主要是:
在這裡插入圖片描述
Flutter 接收到渲染 JSON 資料後,會通過遞迴遍歷的方式從最底層開始,將每一個獨立的渲染資料節點解析為 Model 物件。Model 將會持有所有的渲染資料,同時會關聯自己的父節點;同時 Model 會攜帶所有的渲染資料,通過 Widget 攔截函式生成其對應的 Widget 例項,並持有該 Widget 例項。

比如, JS 中的 <Container /> 元件在 Flutter 中經過攔截函式將會被建立為一個名叫 DFContainer 的 widget 例項。DFContainer 等 widgets 是使用 Flutter 提供的原子元件封裝的一套自定義元件。

當通過 model 建立 Widget 時,如果發現其 isStateful = true,則會在該 Widget 例項外層包裹一個 StatefulWidget,同時讓 model 持有該 StatefulWidget 及其 state,以便之後進行更新操作。也就是說,如果一個 model 具有 isStateful = true,則其會同時擁有 Widget & statefulWidget & state的特性。

在遍歷過程中,原先的 JSON 資料會被轉換為兩個樹 —— ModelTree & WidgetTree。其中 WidgetTree 中的每個節點都會被 ModelTree 中對應的節點所持有。

對於首次顯示的頁面來說,會使用被建立的 WidgetTree 直接替換初始化時建立的承載頁面的內容;而非首頁則會直接通過 Navigator.push(),使用 WidgetTree 建立並顯示一個新頁面。整個流程如下圖:
在這裡插入圖片描述

3.2、通訊機制

JS 與 Flutter 是依賴於 Native 又完全獨立的兩端:JS 中的資料運算與流轉不會直接影響到 Flutter 頁面的渲染;Flutter 的渲染過程也不會阻塞 JS 的程式碼執行。

為了讓完全獨立的兩者產生聯絡,我們找到了一個既能與 JS 產生聯絡,又能與 Flutter 傳遞訊息的媒介 —— Native. 通過將一個訊息從一端傳遞給 Native,再由 Native 完整傳遞給另一端,就實現了 JS 與 Flutter 之間的通訊。

動態化Flutter 框架主要由這三部分構成,每一部分都處理不同的邏輯和繫結事件通訊來更新渲染頁面、事件響應,其核心渲染通訊流程:Flutter ⇋ Native ⇋ JS 。

3.2.1、搭建三端通訊鏈路

Flutter 初始化時,Flutter會與Native通過 methodChannel 建立通訊關係,methodChannel 是一條雙向通訊的鏈路,既可以在 Flutter 中接收到 Native 的訊息,也可以主動向 Native 發出訊息。

同時,Native 在執行 JS 程式碼之前會向 JS 的 context 中注入一個方法,我們將這個方法命名為 methodChannel_js_call_flutter,用來使 JS 能夠向 Flutter 傳遞訊息。因此,在 Flutter 動態化中的通訊鏈路如下圖。
在這裡插入圖片描述
從上面兩個鏈路中會發現,JS到Native的訊息是可以順利到達 Flutter;但是Flutter到JS沒有直接的的通訊鏈路,在 Native 中斷掉了。為了解決這個問題,JS 會在 context 中暴露一個名為 methodChannel_flutter_call_js 的方法,該方法的引數即為訊息內容,這樣 Native 就能夠直接呼叫該方法將訊息傳遞到 JS。

3.2.2、“半雙工”通訊過程

在Thresh中,幾乎所有的三端通訊需求都是“半雙工”的。此處的“半雙工”指的是,當一方作為訊息傳遞方時,無法通過當前傳遞訊息的通道獲得訊息接受方的反饋。這就表示當傳遞方傳送出一條訊息後就會結束自己的通訊行為,它們不需要去關心自己是否會得到反饋,而實際上也不會有任何反饋。

基於以上情況,Thresh中的所有通訊鏈路都會使用這種模式進行通訊:訊息傳遞方只需要傳遞資料而不需要關心回撥,訊息接收方只需要處理資料而不需要返回處理結果。這種模式對於跨越三端的通訊來說更便於管理和約束,也使得 Native 成為了一個完全的資料中轉站,否則 Native 除了需要傳送資料外,還需要處理結果的反饋工作。即【資料傳遞方】 -> 【資料中轉方】 -> 【資料接收方】是單向的。

在這裡插入圖片描述
但是並不是所有的通訊都不需要反饋,例如與 Native 通訊的雙端通訊鏈路 bridge,在向 Native 發出通訊訊息後需要獲得 Native 的處理結果。對於這種情況,簡單粗暴的單向通訊將無法直接滿足需求。但如果換成攜帶回撥的“全雙工”通訊,從而能夠在同一個通訊通道上實現結果的接收,將會破壞原有的通訊模式,也為通訊的管理增加了難度。

為了解決在“半雙工”通訊模式上的通訊反饋問題,我們通過在傳遞方為每一個需要反饋的通訊加上識別符號,再將反饋處理方法通過識別符號快取;當接收方處理完成後,攜帶識別符號通過另一個通訊通道將處理結果作為一個新的訊息傳遞給原本的傳遞方後(在這個新的通道中,原本的資料傳遞和接收方將會互換身份),傳遞方會根據識別符號在快取中查詢到處理方法並執行處理邏輯。

在這裡插入圖片描述

3.2.3、建立可靠的訊息通道

JS 與 Flutter 的通訊是 Flutter 動態化的基石,而首次通訊的成功與否又是通訊能否成功建立的首要條件。

由於所有的跨三端通訊都是“半雙工”的,而 JS 與 Flutter 的環境準備又各自完全獨立,這也就導致如果任一方環境準備完成前,另一方就傳送了訊息,這就會出現環境未完成的一方無法接收到訊息的情況,從而影響後面所有的通訊,導致通訊中斷或錯亂。

為了解決這種情況,JS 與 Flutter 中採取了以下策略來保證首次通訊的順利執行(以下以 A / B 代指 JS 與 Flutter 中的任一方):

  • A 環境準備完成後會立即向 B 傳送通知;
  • 如果 B 已準備好則會立即回覆一條通知,A 收到回覆通知後標記雙方環境已建立,可進行後續的通訊;
  • 如果 B 未準備好,則 A 將不會收到任何回覆,直到 B 準備好,此時A / B 身份互換,會重新回到步驟 1。

在這裡插入圖片描述

3.3、元件更新與事件傳遞

3.3.1、JS事件觸發與傳遞

在將 JS 中的事件函式轉換為 id 後,這個 id 也會與節點所屬頁面名稱、節點 id 一起被攜帶到 Flutter 中,最終這三個資訊會被包裝為一個 Flutter 中的事件函式。

當在 Flutter 中觸發事件時,首先會觸發這個函式,該函式會向 JS 傳送一條攜帶了頁面名稱、節點 id、事件 id 以及事件引數的訊息。JS 接收到該訊息後,首先會根據頁面名稱與節點 id 查詢到觸發了事件的節點,接著通過事件 id 在節點事件池中查詢到對應的事件,傳入引數並執行該事件。

3.3.2、JS元件更新

觸發事件的目的大部分都是為了更新頁面上的內容,在 JS 中,元件更新的基本單位是自定義元件。

當一個自定義元件觸發 setState() 後,會將該元件推入更新佇列中等待更新。在節點進入佇列之前會進行去重,從佇列中進入第一個元件開始後的 16ms,佇列將執行更新操作。在這 16ms 內進入佇列中的其他待更新元件將會一同觸發更新。

在實際進行更新操作前,會先對佇列中的元素進行父節點的去重,即:依次獲取所有待更新節點,同時向上獲取該節點的父節點,如果其父節點存在於當前佇列中,則從佇列中移除該待更新節點,不存在則保留。這樣做是因為只要佇列中存在了父元件,則子元件就一定會被更新;其目的是為了執行最少次數操作,但實現儘可能多元件的更新。

元件的更新借鑑了 React 的元件更新 diff 演算法,但是由於引入了 Flutter StatefulWidget 和 StatelessWidget 的概念,因此相比 React 的 diff 演算法,thresh.js 的 diff 演算法是粗粒度的。

兩者相同的地方在於:都會對每一個節點進行對比,以保證每一個節點的狀態都正確,最終被正確更新。

不同點在於:React 除了會對同型別節點進行屬性和狀態的合併外,也會將新建立或被刪除的節點在舊節點陣列中進行插入或刪除操作,操作和更新的基本單位是原子元件;而 thresh.js 只會關注那些更新前後依然保留的同型別節點,在完成屬性與狀態的合併後,會直接拋棄舊節點,保留新節點,最終新節點將替換待更新自定義元件中的舊節點,並使用更新後的自定義元件的資料向 Flutter 發出更新訊息——更新的基本單位是自定義元件。

在這裡插入圖片描述

3.3.3、Flutter元件更新

JS 傳送的更新訊息有兩部分組成:需要被更新的頁面名稱、更新節點 id 以及更新節點的 JSON 資料。當 Flutter 收到 JS 傳送的更新訊息後,首先會重複 json 轉換為 Model 步驟,建立出 ModelTree & WidgetTree. 之後通過更新的頁面名稱和節點 id 在快取中查詢到需要被更新的 Model。

由於更新以 JS 中的自定義元件為最小單位,而每個自定義元件在 Flutter 中都會被建立為 StatefulWidget,因此在獲取到新舊兩個 Model 後會進行如下操作:

  1. 將 newModel 的渲染資料、子節點 models 及其所持有的 newWidget 合併到 oldModel;
  2. 通過 oldModel 所持有的 state 將 statefulWidget 中所包裹的 oldWidget 更新為 newWidget;
  3. 通過 state 完成元件更新操作後,Flutter 會對被更新的元件進行 diff 與重新渲染,以保證頁面能夠顯示新的內容。

# 四、工程化

## 4.1、Thresh架構
Thresh的整體工程化架構如下圖:
在這裡插入圖片描述

如上圖所示,自下而上,CI/CD + 基礎服務 + 監控上報等支撐了Thresh業務,最上面為架構圖。

  • X-RAY為公司自研的生產釋出平臺,支援Bundle包的構建下發以及運維。
  • 頂部是整體Thresh的架構流程圖,包含 頁面開發、DSL 的轉換、通訊等等,用於構建頁面與邏輯。

Thresh動態化跨平臺方案雖然在設計上有高效能渲染、一致性、開發效率、前端同學零成本接入等優勢,但是考慮未來多業務方接入以及提升開發除錯效率,推進了Thresh周邊基礎設施建設,下面簡單介紹開發期、除錯期、釋出期。

  • 開發期
    支援plugin方式接入,業務方接入提供一套模板工程,能快速進入業務開發;另外Thresh相容TS,能較低成本的讓前端開發融入。
  • 除錯期
    通過支援HotReload模式,秒級編譯,極大的提升開發和除錯效率,另外提供除錯皮膚 + 動態除錯能力也能極大地輔助提高除錯效率。
  • 釋出期
    依賴滿幫自研的X-RAY灰度釋出系統,具備分鐘級別動態發版能力,能快速支撐業務和問題修復。

在這裡插入圖片描述

4.3、Thresh開發整合

Thresh的開發整合形成了一整套流程,涵蓋三方整合、多業務模組接入、開發除錯等等,其中涉及細節比較多,這個在開源倉庫裡面有詳細介紹。

至此,Thresh的架構設計和開發整合能力都基本完成,相比於其他動態化跨平臺開發框架,Thresh有如下優勢:

  • 基於JS的自定義DSL,擴充套件性強,學習成本低
  • 多端一致性,擁有統一的自渲染引擎skia,較好的跨端相容性適配
  • 支援Hot Reload,便於開發除錯,秒級編譯
  • 支援元件級別UI重新整理,極佳的體驗性

    • 提供開發期除錯皮膚,方便開發

五、結束語

通過 JS 構建 Flutter 應用程式的基本原理並不複雜,主要是 JS 中的資料處理、Flutter 中的資料轉換,以及實現資料在 JS 和 Flutter 中的流轉通道。這類方案大提都類似,比如MXFlutter、美團外賣 MTFlutter。不過,這種方案目前看來還是比較雞肋,偏離了Flutter跨平臺涉及的初衷。

相關文章