美團外賣Flutter動態化實踐

美團技術團隊發表於2020-06-26

一、前言

Flutter 跨端技術一經推出便在業內贏得了不錯的口碑,它在“多端一致”和“渲染效能”上的優勢讓其他跨端方案很難比擬。雖然 Flutter 的成長曲線和未來前景看起來都很好,但不可否認的是,目前 Flutter 仍處在發展階段,很多大型網際網路企業都無法毫無顧慮地讓全線 App 接入,而其中最主要的顧慮是包大小與動態化。

動態化代表著更短的需求上線路徑,代表著大大壓縮了原始包的大小,從而獲得更高的使用者下載意向,也代表著更健全的線上質量維護體系。當明白這些意義後,我們也就不難理解,在 Flutter 的應用與適配趨近完善時,動態化自然就成為了一個無法避開的話題。RN 和 Weex 等成熟技術甚至讓大家認為動態化是跨端技術的標配。

美團外賣 MTFlutter 團隊從 2019 年 9 月開始對動態化進行研究,目前已在多個業務模組上線,內部專案代號 “Flap” 。。

二、Flap 的特點與優勢

Flap 研發的初心是為了提供一個完整解決方案,而不是一個過渡方案。專案組思考了當下最痛的點並逐一列出,然後再根據目標來做具體選型。在前期,只有需求考慮得越周全,後續的架構和研發才會越明確。在研發過程中,團隊應該堅守底線,堅守初心,不斷攻克困難,完成昔日定下的目標。

2.1 核心目標

  • 通用性,保持 Flutter 多平臺支援的能力且方案無平臺差異。
  • 低成本,動態化對齊 Flutter 生態和常規開發習慣,且可低成本轉化現有的 Flutter 頁面。
  • 適用性,避免包過大、不穩定等不利於應用的缺陷。
  • 高效能,保留 Flutter 渲染效能極佳的特點。

2.2 動態化選型

a. 產物替換

選型中首先考慮到的是下發產物替換,官方在也曾經推出了 Code Push 方案,甚至可以支援 Diff 差量下載,但是在 2019 年 4 月被叫停,這裡引用一下官方的發言 Flutter/issues/14330

To comply with our understanding of store policies on Android and iOS, any solution would be limited to JIT code on Android and interpreted code on iOS. We are not confident that the performance characteristics of such a solution on iOS would reach the quality that we demand of our product. (In other words, "it would be too slow".)

There are some serious security concerns. Since these patches would essentially allow arbitrary code execution, they would be extremely attractive malware vectors. We could mitigate this by requiring that patches be signed using the same key as the original package, but this is error prone and any mistake would have serious consequences. This is, fundamentally, the same problem that has plagued platforms that allow execution of code from third-party sources. This problem could be mitigated by integrating with a platform update mechanism, but this defeats the purpose of an out-of-band patching mechanism.

簡而言之,就是官方對動態化後的效能沒有自信,並且對安全性有所顧慮。之前,官方提供方案的侷限性也十分明顯。比如對 Native-Flutter 混合 App 支援不友好,並且無法進行灰度等業務定製操作,所以不能滿足通用性和高效能的核心目標。

b. AOT 搭載 JIT

Flutter 在 Release 模式下構建的是 AOT 編譯產物,iOS 是 AOT Assembly,Android 預設 AOTBlob。 同時 Flutter 也支援 JIT Release 模式,可以動態載入 Kernel snapshot 或 App-JIT snapshot。如果在 AOT 上支援 JIT,就可以實現動態化能力。但問題在於,AOT 依賴的 Dart VM 和 JIT 並不一樣,AOT 需要一個編譯後的 “Dart VM”(更準確地說是 Precompiled Runtime),JIT 依賴的是 Dart VM(一個虛擬機器,提供語言執行環境);並且 JIT Release 並不支援 iOS 裝置,構建的應用也不能在 AppStore 上釋出。

實現此方案需要抽離一份 DartVM 獨立編譯,再以動態庫的形式引入專案。通過初步測試,發現會增大包體積 20MB+,這超過了 MTFlutter 之前做 Flutter 包體積優化的總和。進一步讓 Flutter 包體積成為推廣與接入業務方的巨大阻礙,不滿足我們對適用性的要求。

c. 動態生產 DSL

Native 側本身具備 JS 動態執行環境,利用這個執行環境動態生成包含頁面和邏輯事件繫結 DSL,進而解析為 Flutter 頁面或元件,也可以實現動態化訴求。技術思路接近 RN,但與其不同的是利用 Flutter 渲染引擎和框架。這種先將程式碼執行起來再獲取 DSL 的手段,我們簡稱為動態生產 DSL。

此方案可以很好地支援邏輯動態化,但弊端也比較明顯。首先要對齊 Flutter 框架,JS 側的開發量很大且開發體驗受損。另外,對 JS 的依賴偏重,構建的 JS 框架本身解釋執行有一定開銷,對於頁面邏輯與事件在執行中需要頻繁地進行 Flutter 與 JS 的跨平臺通訊,同樣也會產生一定開銷。這不能滿足 MTFlutter 團隊對高效能的訴求。更嚴重的是,此方案對開發同學的開發習慣並不友好,將 Dart 改為 JS,現有的 Flutter 開發工具無法直接使用,這與低成本訴求背道而馳。

d. 靜態生產 DSL

前面說 “將程式碼執行起來再獲取 DSL 的手段,我們簡稱為動態生產 DSL”,那麼程式碼不執行直接轉換 DSL,就稱為靜態生產 DSL 方案。

靜態生產的特點是抹平了平臺差異,因為 input 是 Dart source 與平臺無關,直接將 Dart source 內的完整資訊通過一層轉換器轉換到 DSL,然後通過 Native 和 Dart 的靜態對映和基礎的邏輯支援環境,使得其可以在純 Dart 的環境下渲染與互動。

在具體實現上,可以利用 Dart-lang 官方提供的 Analyzer 分析庫(該工具在 Dartfmt、Dart Doc、Dart Analyzer Server 中都有使用)構建 DSL。該庫提供了一組 API 能對 Dart source 進行分析,按照檔案粒度生成 AST 物件。AST 物件用整齊的資料結構包含了 Dart 檔案的所有資訊,利用這些資訊可以便捷地生成所需的 DSL。所有的這個分析 + 轉換的過程全部線上下進行。接下來, DSL-JSON 以 Zip 的形式下發,Flutter 的 AOT 側以此為資料來源,完成整個 Flutter 專案的渲染與互動。

這種方案,一來可以保持 Flutter/Dart 的開發體驗,也沒有平臺差異,邏輯動態化依賴靜態對映和基礎邏輯支援,而非 JScore,有效地避免了效能上的開銷。綜上考慮,靜態生產 DSL 最終成為 MTFlutter 團隊選型的方案。

2.3 專案架構

圖1 Flap整體架構

如圖 1 所示,三處淺綠色部分為一個階段的階段產物,起到承上啟下的作用。以綠色部分為界,整體架構自然而然的就被劃分成了三個區域:

  • 下層第一部分是對開發階段的賦能,產物是正確且規範(也滿足 Flap 規範)的 Dart 原始碼。
  • 第二部分是 DSL 的轉換器,產物是 JSON 格式的 DSL,用於標準化的描述頁面層級與邏輯。
  • 上層的第三部分是執行時環境,準備了所有需要的符號構建 Dart 物件與邏輯,產物是動態化 App 或動態化的模組。

三、Flap 的原理與挑戰

圖 1 中的核心模組是轉換器部分和執行時部分,接下來會介紹下這兩個部分的原理與部分實現。

3.1 轉換器原理

AST & DSL

AST 意為抽象語法樹(Abstract Syntax Tree)。Dart 的 AST 和其他語言的 AST 基本概念類似。'package:front_end/src/scanner/token.dart' 中定義了所有的 Token,AST 也是通過詞法分析、語法分析、解層級巢狀得到。ASTNode 物件作為儲存編譯單元中重要資訊的基本資料結構,派生類基本分為 Declaration、Expression、Literal、Statement。

DSL 意為領域特定語言(Domain-specific Language)。表示專門針對特定問題領域的程式語言或者規範語言。相對自然語言,程式語言是不靈活的,它的語法和語義設計常取決於它的執行環境和特定目的。過去人們總是發明新的程式語言,近年來新出現的語言越來越相近,因此 DSL 也變得流行起來。

那 Flap 的 DSL 具體是什麼?對於開發者而言,那這個 DSL 就是 Dart Code。而對於機器或 App 而言,那這個 DSL 就是 JSON。

前面的技術選型中提到:

利用 Dart-lang 官方提供了 Analyzer 分析庫,官方的 Analyzer 的能力可以拿來直接用,該庫提供了一組 API 能對 Dart source 進行分析,按照檔案粒度生成 AST 物件,該資料結構包含了 input 的 Dart 檔案的所有資訊。

我們的 DSL 的基本原理就是對 AST 內資料的一個描述, 並附帶一些其他操作。

圖2 DSL-JSON 的轉換步驟

因為用 Analyzer 的 API 跑出的 AST 也叫 CompilationUnit,實際上是一個編譯單元,裡面還存有很多編譯相關的屬性例如 lineInfo、beginToken 等。但使用 DSL 的方式不依賴編譯,所以很多不需要的屬性會被裁剪或忽略。

在轉換器入口會對大類(identifier、statementImpl、literal、methodInvocation 等等)進行分發,每一個大類的資料結構使用一種中間結構 Dart model 來傳輸,然後對於大類中細分的型別(IfStatement、AssignmentStatement、DoStatement、SwitchStatement 等等),配有足夠細粒度的轉換介面,以 AST 結構作為輸入,以 Map 節點作為輸出。最終定義並提煉了 10 種標準的 Map 結構(class、method、variable、stmt 等等)來承載所有型別。

舉個例子

一個簡單的 Widget 節點經過轉換後得到這樣的 DSL-JSON,可以看到 DSL 的可讀性還是 OK 的(預設下發時產物是一個壓縮成單行並加密的二進位制檔案,這裡是解密後 Format 換行後展示的)。我們在轉換中會區分普通的字串、變數名引用、系統列舉等型別,加以不同的符號表示。

圖3 常規 Widget 元件的原始碼與 DSL 示例

關於邏輯

舉一個簡單的四則運算的例子,可以看出在對於“乘法應當先計算”這個規則上,我們的 DSL 能夠自動遵循, 其中的奧祕是 Analyzer 幫我們做了這種運算優先順序的判斷,歸根結底還是一種描述 AST 的工作,我們自己不會去根據靜態程式碼做分析過程。

圖4 簡單邏輯的程式碼與DSL示例

關於語法糖

語法糖往往畫風清奇,結構與眾不同,但是在 AST 中還是很誠實的,該什麼結構就是什麼結構。所以語法糖應該在轉換器側進行展開為常規結構再轉 DSL,而不是對特殊格式設定特殊的 DSL 傳到執行時再去解析。

圖5 部分語法糖的展開情況

這裡只舉了一些簡單的例子,只是 DSL 體系中的一個片段,實際在專案落地時有很多較為複雜的邏輯,類似於迴圈套迴圈內進行集合操作或是非同步回撥內加多重三目邏輯等等。這裡因為篇幅原因和涉及到業務程式碼相關就不展開詳細的介紹了,其中的原理是一樣的,都是描述 AST 的過程中增加一些特殊處理,最終會將轉換產物的 Map 節點根據原有 AST 的層級結構組裝起來,再通過 JSONEncode 轉為 JSON。

圖6 DSL內部結構層級

轉換器側能夠完整的描述一個 Dart 檔案的所有資訊,如圖 6 所示。值得一提的是,不同的節點還可能出現任意結構,method 裡的 Argument 裡可能是一個全域性變數,條件表示式的右邊又可能是一個方法。對於這種相同的結構即使出現在不同的位置也應當使用一套處理邏輯來轉換,因此轉換器是以迭代為主加小範圍遞迴的設計思路。

將細粒度轉換介面按照具體類別分在不同檔案中(statement_factory、class_factory、function_factory) 等待解析生產匯流排的呼叫。實際操作中各個類之間是近似於網狀的呼叫,因此所有呼叫應當都是 Static 的,並且內部隔離,不引用不修改外部變數,做到無副作用。

DSL 轉換器是一個命令列程式,因此可以無縫的部署到自動化的機器上。新程式碼合入主幹後, 接下來的 Bundle 生成與分發邏輯都可以使用各種圖形化介面的釋出系統來操作。

3.2 執行時原理

Prepare & Running

執行時相關的操作是在 App 內發生的,包括初始化,拉取 DSL,解析與使用。 簡言之可以分為 Prepare 和 Running 兩個階段。Prepare 是準備各種執行時所需的符號,包括系統類符號與自定義符號,屬性符號與方法符號(這裡所說的符號實際就是 Dart 內的物件)。Prepare 階段完成才能進行後續的 Running 相關操作,具體是頁面的構建,事件的繫結,互動與邏輯的正常運轉。

圖7 執行時原理的兩大階段

萬能方法 Function.apply()

Flutter 期望線上產品是編譯後的“完全體現”,同時為了避免生成過大的包,並不支援 Dart:Mirror。“Flutter apps are pre-compiled for production, and binary size is always a concern with mobile apps, we disabled dart:mirrors.”那麼,在這種前提下,如何將外部符號轉內部符號?Function() 物件提供了這樣一個萬能方法。

// function.dart
external static apply(Function function, List positionalArguments,
      [Map<Symbol, dynamic> namedArguments]);

複製程式碼

第一個引數是 Function 型別,後兩個引數是該函式所需的引數(位置引數與命名引數,這兩者在 DSL 中都可以取到),因此只要能獲取到某個 Function,那就能在任何時候呼叫它。

此 Function 若為 Constructor Function 那返回值則為構造出的物件型別。

Proxy-Mirror

DSL 後只能得到字串的標識,因此需要建立一個 String 與 Function 的對映關係,考慮到類名方法名,資料結構應該是 {String:{String:Function}},通過 className 和 functionName 兩個 String Key 即可取得一一對應的 Function(),下面給出一個系統類的類方法(構造方法)的程式碼片段:

{
  'EdgeInsets': 
  {
    'fromLTRB': (left, top, right, bottom) => EdgeInsets.fromLTRB(left, top, right, bottom),
    // ...other function
  },
  // ...other class
};
複製程式碼

然後對於系統類的例項方法、getter、setter 則需要在外部多傳一個 instance 引數,instance 是外部通過該類的構造方法的 func 建立後傳入。

// instance method
"inflateSize": (instance, size) => instance.inflateSize(size),
// getter
"horizontal": (instance) => instance.horizontal,
// setter
"last": (List instance, dynamic value) => instance.last = value,
複製程式碼

Custom Class's meta

對於自定義類,我們需要構建一個模擬的元類系統,存放所有符號資訊,在解析時將所有的 JSON 節點轉成可處理的物件。所有的屬性宣告都會構建成 FlapVariable 型別,所有的方法宣告都會構建成 FlapFunction 型別。

如圖 8 所示,父類和元類也是有相應的指標,父類的成員變數也會填充到子類,並且通過 mixin 的方式將類相關屬性注入到派生類型別,例如 FlapState,FlapState 繼承自 state,這樣既可以讓系統類的生命週期方法留個呼叫鏈的開口,也可以使用注入的執行時類屬性。

圖8 執行時模擬的元類系統

Evaluate

如下面程式碼的例子,一個 if 語句的 JSON 節點下發後,經過 parser 之後會得到一個 IfStatement 物件,這類物件都有一個特點就是包含幾個屬性,和一個執行時入口方法 evaluate(Scope scope)。這個方法在抽象類 Evaluative 類中,所有語句和表示式的類都會繼承於此,自動獲得 evaluate 方法,其中屬性部分是在解析過程中解析成 Dart 物件後通過構造方法的引數傳入的。

class IfStatement extends Statement {
  dynamic condition = undefined;
  Body thenBody;
  Body elseBody;
  IfStatement(this.condition, this.thenBody, [this.elseBody]);
  // 簡化版程式碼
  ProcessResult evaluate(Scope scope) {
    bool conditionValue = condition.evaluate(scope)
    if (conditionValue){
      return thenBody(Scope);
    }else{
      return elseBody(Scope);
    }
  }
}
複製程式碼

屬性中的條件物件與語句物件在解析的過程中並不會被觸發, 真正的觸發是方法被呼叫時從執行時的入口方法 evaluate 進入,此時才會通過作用域 Scope 判定條件是 true or false,然後呼叫到其他需要 evaluate 的 Dart 物件,如下圖 9 所示:

圖9 執行時 evaluate 觸發鏈路

經過表示式的堆疊,實現了語句,經過語句的堆疊實現了 body,再補充上形參和返回值,則就構成了我們執行時中的自定義方法 FlapFunction。這裡要用到一下模擬函式的概念,FlapFunction 要實現 call 方法,這樣在外部呼叫時就真的和 Function 畫風一致了。

動態化頁面執行時,Flap 會維持一套作用域體系。Scope 的結構相當於雙向連結串列,每一個 Scope 有 outer 和 inner 兩個指標。全域性作用域的 outer 為 null,inner 為類作用域;類作用域的 inner 為區域性作用域;區域性作用域的 inner 可能為 null 也可能又是一個區域性作用域;隨便哪一個作用域順著 outer 一直往上找,肯定能找到全域性作用域。

Scope

Scope 在邏輯的執行中實際就是充當了 Context 上下文的作用,因為每個方法或表示式被 evalute 時需要一個 Scope 入參,這個 Scope 是從外部傳入的,並且這一行語句物件執行後 Scope 還會作為入參傳給下一行語句。比如第一行語句宣告瞭一個 “code” 的變數,第二行語句對這個 “code” 進行修改,則需要先通過引用從 Scope 中取出這個 “code” 的值,不但可以從 Scope 中取出宣告的屬性,也可以取出宣告過的方法,方法內也是可以呼叫方法的。這也就解釋了為什麼我們可以處理自定義方法中的邏輯。

圖10 Scope的尋找與構建

圖 10 描述了 Scope 在實際運用中的兩種場景。左半部分是點選按鈕觸發 onTap 回撥,需要找到 confirm 方法,此時會先從區域性作用域的方法列表裡找,沒找到,則會 outer 一層去類作用域裡尋找,此時找到了該方法的實現。

右半部分展示了執行該方法的 body 時是需要傳入的 Scope 是如何構建的。先從符號大本營中獲取全域性變數、全域性屬性構成全域性作用域,再從此類的元類中取出屬性和方法構成類作用域,再構建區域性作用域,當然引數也是會放到區域性作用域裡的,以此構建了完整的 Scope 傳入 body 的 evaluate 方法支撐後面的邏輯執行。

3.3 遇到的挑戰

工作量大,需要長期有耐心

首先解釋下,這裡的工作量大並不是指系統方法對映等這種體力活的工作量大,這些我們都是有自動生成且按需生成的(生態部分會提到)。我們所說的工作量大,主要是指涵蓋轉換器、執行時的研發以及生態相關建設等,我們要儘可能的滿足所有的 Dart 語法才能讓業務程式碼能夠低成本的轉換,並且有眾多的指令碼與工具支撐。

專案複雜,需要設計合理的架構以支撐擴充套件

在專案的分模組開發中,各個模組(parser、intermediate、runtime 等等)嚴格遵守單一職責原則與最小知道原則,最大化的杜絕了模組間耦合,模組與模組的通訊由一些標準的資料結構進行(map 或繼承自 ASTNode 的結構)。 這就使得任何一個模組出現重大重構時不會影響到其他模組,其中底層核心的幾個類的單側覆蓋率接近100%,有專人負責優化。並且在專案中隨處可以抽象類、介面類、mixin 類等,這也就使得隨著支援的能力越來越複雜時,專案的可讀性不會成反比,程式碼不會變“噁心”,而是以整齊的方式擴張,檔案多而不亂。

疑難雜症較多,對問題保持足夠的信心

有時候會遇到一些諸如靜態方法呼叫構造方法時作用域被覆蓋、迴圈語句巢狀時內側 continue 之後外側語句也會跟著停、某方法引數的 Function 取完引用之後 Function 也跟著執行了等等的 Bug,解 Bug 是開發中必不可少的一部分,有時候加個 if else 用 easy way 可以很快解決,但我們不會那麼做,探索優雅 Right Way 的樂趣是研發過程中的一個重要組成部分。

相比於草草了事之後,每晚睡前都會面臨這段程式碼“靈魂”拷問,我們更願意多花時間思考把程式碼寫的像 Mac pro 主機的包裝那樣“絲滑”。這樣的工作氛圍培養了每位同學的信心,只要是必現問題,基本都能優雅地解決。

四、生態支撐

雖然 Flap 的設計理念使得其在開發效率與執行效率上有一定的亮點,但這還不足以讓其在業務中快速推廣。因此我們建設了一套完整的 Flap 生態體系,涵蓋了開發、釋出、測試、運維各階段。

圖11 Flap在美團內網生態

如圖 11 所示,Flap 生態的特點可以用 穩、快、準、狠 四個字來表達。

4.1 穩

穩,意為可靠的質量管理體系。在①IDE 開發中②提測階段③線上監控④降級容災,我們都有對應的策略。其中②和③的基本是和 Native 類似的 PR 檢查、QA、日誌、上報之類的這裡就不做贅述了,下面主要提一下①和④。

IDE 語法檢測外掛

這個功能的意義是儘早地將不支援的語法以編譯錯誤的方式暴露出來,以便同學在開發期就能發現及時修改。 設想一下當你程式碼寫完了,Code Review 也逃過了同學的眼睛,PR 的 Dart 檢測也過了,開開心心下班了,突然一個電話打來說發 Bundle 的時候錯了,有的語法 Flap 不支援,需要返工去改,此時你的內心一定會“萬馬奔騰”。

所以,我們將這種暫不支援的語法提前暴露,並推薦使用什麼方式代替,可以有效的減少返工, 得到一份滿足 Dart 規範和 Flap 規範的程式碼。同樣的 Lint 檢測規則後續也配置到了 PR 階段,如果真出現外掛規則更新不及時場景,也會被攔在 PR 階段。

圖12 IDE語法檢測外掛

不過,目前 Flap 不支援的語法已經很少了,目前基本就是 await、as 和超過 2 個 with 等場景, 其中 await 和多個 with 的理論上也能支援,但會讓專案有較大的重構和多處的分別對待,不利於後期的維護,考慮到 await 完全可以使用 future.then 代替,所以這個語法就禁了。對於 mixin 的特性,在 Dart 側本身就是排列組合的關係。超過 2 個 with 會產生多個派生類,動態化的實現類似,所以為了不讓簡單問題複雜化,我們也禁用了 2 個以上 with 的寫法,還有一些寫法上的限制,例如 import 不使用全路徑也會報錯。

目前開發中 Flap 動態化已經與 AOT 共用一份業務程式碼了,為了不讓 Flap 的規則影響到專案中還未覆蓋到動態化的頁面,讓其滿屏報錯,我們使用 @Flap 註解作為是否開啟當前頁面的 Flap 規範檢測的開關。這也很好理解,當這個頁面內沒有 @Flap 時,肯定是個 AOT 模組則還是預設的 Dart 檢測規則, 一旦加上了 @Flap('pageID'),說明此頁面會被動態發版,所以會自動開啟 Flap 檢測規則。

降級容災

Flap 接入了美團內部統一的動態化釋出平臺 DD,並利用 DD 平臺的能力實現了 App 版本、平臺型別、UUID、Flutter SDK 版本等細粒度的下發規則管控。業務方可以根據實際情況選擇不同的策略灰度釋出方案,如果發生了嚴重異常,Flap 也支援撤包操作。

圖13 Bundle釋出系統的各項邊界控制

某一個頁面加了標記支援了動態化之後,也會繼續進行 AOT 編譯過渡2個版本, 前置頁面點選跳轉是跳 AOT 頁還是跳 Flap 頁完全由 URL 裡的引數控制,這個 URL 不是完全由雲端下發的,是程式碼中先寫上預設的 URL,若需要在配置平臺修改後,下發的配置資訊會讓這個 URL 在路由側完成替換。即使配置平臺掛了,頂多喪失 URL 的替換能力而不是無法前往落地頁。

圖14 URL 動態替換與條件配置

對於 Flap 還有個更犀利的功能,在過渡期間(Flap 已經上線且 AOT 程式碼還沒刪時),一旦 Flap 出現 Dart 異常, 當使用者退出頁面再進入時會自行進入該 pageID 下的 Flutter AOT 頁面,最大化降低對使用者的干擾。

4.2 快

快,意為快速發版,快速更新。Flap 動態化改造使應用具備了分鐘級動態發版的能力,為了更全面地釋放這個能力,客戶端業務迭代的流程也做了相應的調整。

當業務包發版上線,到了應用執行階段,Flap 主要面對的問題變成敏捷與質量的平衡,即:如何保證動態程式碼能夠儘快生效,同時又要保證載入效能和穩定性。

對於此問題,Flap 的解法是二級快取與實時更新相結合,線上環境使用記憶體 + 磁碟二級快取,進入頁面之後再預拉取更新包,平衡載入效能與更新實時性。而線下環境則強制載入遠端包,實現測試程式碼的快速交付。

圖15 Flap二級快取策略

得益於這種機制,Flap 線上上可以實現接近 Web 的觸達效率:應用會在啟動時和具體業務入口處發起更新請求,每當業務有動態釋出,新版本頁面即可在使用者下一次開啟時觸達至使用者。在載入效能方面,二級快取加持下的頁面載入時間僅為數十毫秒,而遠端載入的時間也只有 1 秒左右。

4.3 準

細粒度動態化

準,指哪打哪,可以頁面級動態化,也可以區域性 Widget 級別的細粒度動態化。事實上在 Flutter 的世界中,“頁面”本身也是一個 Widget,業務方在實際開發中,只需要增加一行註解,即可實現對應 Widget 或頁面的動態化。

@Flap('close_protect')
class CloseProtectWidget extends StatelessWidget {
  // ...Widget 的 UI 和邏輯實現
}
複製程式碼

Flap 打包發版時,解析引擎會從註解標記的 Widget 入手,遞迴解析所有依賴的檔案,轉化成對應的 DSL 並打包。App 線上執行時,每個動態化的頁面或元件都會按照註解的 FlapId,通過 FlapWidgetContainer 還原成對應的 UI。

圖16 註解的掃描與widget構建

實際呼叫時,只需傳入註解中標記的 FlapId,即可實現動態化區域或頁面的載入和渲染。

// 區域性 Widget 級別的動態化,通過 FlapWidgetContainer 載入
Column(
  children: <Widget>[
    MyAOTWidget(),  // 原生 Flutter AOT Widget
    FlapWidgetContainer(widgetId: 'kangaroo_card'), // Flap widget
  ],
);

// 頁面級別的動態化,通過 MTFlutterRoute 路由跳轉:
RouteUtils.open('scheme://host/mtf?mtf_page=flap&flap_id=close_protect');
複製程式碼

精準的 Debug 能力

在 Debug 階段加上一個註解 @Flap(‘pageId’),就會自動嘗試轉 DSL。如果該頁面非常獨立,且語法沒有太花哨,則直接就能看到轉換完成的字樣。這個就說明該頁面用到的語法既支援 Dart 又支援 Flap,不需要做任何修改。如果出現錯誤,則會在終端下精準列印出錯誤的位置。在此功能支援之前,基本都是“一崩就崩”到系統類的某某方法,開發同學只能通過自己的經驗去堆疊中往上找。目前的精準 Debug 能力實現了轉換器、執行時 parser、執行時 evaluate 三個階段的全面覆蓋。

圖17 三個階段的 Debug 定位

在轉換器階段的報錯位置資訊可直接在 Exception 中獲得 AST 物件的 lineinfo 進而獲取到列號行號資訊。在 parser 與 evaluate 階段的錯誤定位是根據對核心方法的 trycatch 與設定通用 Exception 型別逐層上拋實現的。因為 DSL-JSON 會被壓縮且可以 format,行號列號並無意義,所以在執行時階段的報錯全是精確到某 class 中的某 method。

4.4 狠

狠,各種自動生成,實際轉換步驟操作方式簡單粗暴。Flap 在整個迭代流程環節都提供了便捷的自動化工具支撐。

imports 自動載入

基於 Flap 轉換一箇舊的 Flutter AOT 頁面到 Flap 頁面的操作是簡單粗暴的,加上註解,一行終端指令就可以一把“梭”。但一個業務頁面為了設計上的合理往往會分成多個檔案,如果有 10 個檔案是不是要重複 10 遍這樣的工作?答案是否定的。Flap 無論是在 DSL 轉換器側,還是在執行時載入 DSL,都會做到 imports 的遞迴載入。

IDE 語言檢測外掛有一條限制是:import 必須使用 package 全路徑,不能只 import 一個類名。因為多檔案需要匯入的位置都是根據全路徑擷取出的相對路徑來計算的。

Proxy-mirror 按需生成

前面介紹過 Proxy-Mirror 是外部符號轉內部符號的橋樑, 那麼具體 Dart 檔案中哪些用到的類或方法需要內建 Proxy,而哪些類不需要呢?這個劃分的邊界就是,在轉換的程式碼內能否看到此類或方法的宣告。系統方法的宣告肯定不在業務檔案裡,所以需要 Proxy。業務 Model 的宣告在“我的業務”檔案中有,所以不需要 Proxy。程式碼中使用到了官方 Pub 或是其他業務線的 Pub,例如美團金融的 Pub 裡的方法,宣告不在“我的業務”檔案裡,所以需要 Proxy。

在 Flutter AOT 遷移動態化初期,經常需要手動干預的問題是:專案中遇到 Proxy-Mirror 缺失會打斷轉換器, 需要手動補充後繼續進行轉換。

對於這種問題後期研發 Proxy 自動生成按需生成的工具, 主要原理是在預轉換階段,先掃描程式碼的 AST Tree,壓平層級獲取所有的專案結構中 identifer 節點包裹的 Value,進行一系列判定規則,然後基於reflectable 功能實現 Proxy 的自動生成。

釋出鏈路“一條龍”服務

經過不斷的提煉與簡化,目前開發者大可以將注意力集中在開發階段,一旦程式碼合入主幹,接下來就會有完整的 Flap 工程化釋出和託管系統協助開發者完成後續的打包、釋出、運維流程。前面介紹過的所有細節工作,都會由這些工具自動化完成,實現便捷釋出。Flap 也在路由層面對接了集團內通用的運維工具,開發者無須任何額外操作即可實現載入時間、FPS、異常率等基礎指標的監控。對於指標波動、異常升高等情況,也會自動註冊報警項並關聯至當前的打包人。

五、業務實踐經驗

業務落地只是我們的目標之一,更重要的是在業務實踐過程中,發現框架問題,完善各類語法特性支援,提高在複雜的混合場景下的相容性,反哺促進框架的完善。不斷打磨的同時完善工作流,思考與沉澱最佳實踐,逐漸總結出合理的除錯方案、操作步驟與協作方式,不斷提升開發效率與體驗。完善動態化基建及工具鏈建設,完成動態化流程的自動化與工程化,進一步降低轉換與開發成本。

5.1 應用場景

對於 Flap 在業務中的實踐,主要有兩種應用場景。

場景1. 原有 Flutter 頁面,需要轉換成動態化頁面

設想一下,理想狀態下一個好的動態化框架應該是怎樣的?動態化框架將原有 Flutter 改寫成支援動態化的頁面?那加一個 @Flap 註解就好了。然後就可以提交程式碼,自動走工具鏈那一套。

目前,雖然沒有達到理想狀態,但我們也在無限接近中,當然還是要簡單地本地除錯一下。基本都需要改個 URL 路由和 Mock 環境之類的步驟,我們已經提供了模板的除錯工程,支援一鍵對比 AOT 與動態化執行之後的差異,如圖 18 所示。基本就是加上註解,IDE外掛會報錯哪些語法不支援,需要換一種寫法,然後跑一下就可以,然後就提交程式碼。

圖18 研發過程支援不同的執行模式

場景2. 直接使用 Flap 技術棧開發新頁面

重新開發場景很明顯比第一種要簡單,因為沒有歷史包袱。設想一下,好的動態化框架應該怎麼做?就是和 Flutter 的 AOT 開發使用一套相同的 IDE 環境,相同的開發模式,就是 IDE 會多報幾項語法錯誤罷了,開發時就能直接被提示到換一種寫法就行。寫完後加上註解,然後再提交程式碼。

5.2 實踐經驗

目前,我們團隊已經把 Flutter 動態化能力在一些業務場景落地,當然業界也會有相似的或者不同的動態化方案。無論方案本身怎樣, 在落地時的步驟基本都大同小異,我們也總結了一些經驗。

繞過問題並加以記錄

初期任何框架的能力都不是完美的,都會存在問題。業務方同學遇到 Proxy 類缺失之類等比較簡單的問題可以直接解決,執行時環境的深層問題、某些語法在複雜疊加場景下出現異常等等,一般會先嚐試用其他的語法繞過,記錄文件,然後同步到 Flap 團隊同學進行解決。

定時補充 IDE Plugin Rules

對明確不支援的語法、關鍵字等新增到 IDE Plugin Rules 中,並提供了相關語法的替代方案,Rules 也會定時補充和刪減。

提前周知各方資源

包括確認好 Android 的上線節奏,QA 的測試節奏,以及周知PM動態化的覆蓋佔比。

關鍵許可權收緊管理

相比於整理許可權、灰度、降級、容災等線上的 SOP 和 FAQ,讓大家都學著操作, 直接指定 2~3 位超級管理員看上去更靠譜,線上環境由“老司機”把控更好。

5.3 落地結果

業務應用涵蓋 App 一級頁在內的多個頁面,場景既有頁面動態化,也有區域性動態化,經受住了一級、二級頁面的流量驗證。

圖19 部分動態化落地頁面

圖20 部分動態化頁面FPS資料

圖21 部分動態化頁面渲染時長

圖 21 涉及到 PV 的地方打了馬賽克,Flap 團隊對包括 FPS、載入時間、Bundle 下載時長、渲染時長等 11 項指標進行了統計,可以看到 FPS 平均是在 58 以上,渲染時長根據頁面複雜度的不同在 7~96ms 之間。

總的來說,各項指標表現均接近於 Flutter 原生效能。並且圖中的資料都還有可提升的空間,目前的平均值也受到了區域性較差數值的影響,後續會根據不同的 TP 分位使用分層的優化方案。

六、總結與展望

我們通過靜態生產 DSL+Runtime 解釋執行的思路,實現了動態下發與解釋的邏輯頁面一體化的 Flutter 動態化方案,建設了一套 Flap 生態體系,涵蓋了開發、釋出、測試、運維各階段。目前 Flap 已在美團多個業務場景落地,大大縮短了需求的發版路徑,增強了線上問題修復能力。Flap 的出現讓 Flutter 動態化和包大小這兩個短板得到了一定程度的彌補,促進了 Flutter 生態的發展。此外,多個技術團隊對 Flap 表示出了極大的興趣,Flap 在更多場景的接入和共建也正在進行中。

未來我們還會進一步完善複雜語法支援能力和生態建設,降低開發和轉換 Flap 的成本,提升開發體驗,爭取覆蓋更多業務場景,積極探索與業務方共建。然後基於大前端融合,探索打通其他技術棧,基於Flap DSL 抹平終端差異的可能。

參考文獻

作者簡介

  • 尚先,2015 年加入美團,到家研發平臺前端技術專家。
  • 楊超,2016 年加入美團,到家研發平臺前端資深工程師。
  • 松濤,2018 年加入美團,到家研發平臺前端資深工程師。

招聘資訊

美團外賣長期招聘 Android、iOS、FE 高階/資深工程師和技術專家,歡迎加入外賣 App 大家庭。歡迎感興趣的同學傳送簡歷至:tech@meituan.com(郵件標題註明:美團外賣技術團隊)

閱讀更多技術文章,請掃碼關注微信公眾號-美團技術團隊!

美團外賣Flutter動態化實踐

相關文章