Airing

Airing

哲学系学生 / 小学教师 / 程序员,个人网站: ursb.me
github
email
zhihu
medium
tg_channel
twitter_id

Flutter 產品分析與減包方案

在混合開發場景下,Flutter 的包增量略大一直是被大家詬病的一點,但 Google 官方明確表示了 Flutter 不會支持動態化,而且目前 Flutter SDK 官方還沒有提供一套定制方案。因此想要瘦身,那麼只能自己動手豐衣足食了。

所謂減包,前提條件是必須知道產物內容有什麼?產物裡有哪些部分可以減?被減掉的部分我們要怎麼加回來?因此本文將圍繞 “產物分析” 與 “減包方案” 兩個主題來分別論述 iOS 與 Android 兩端的 Flutter 減包原理與方案。

那麼,先從 iOS 端開始吧。

注:本文數據與代碼片段均來源於一個基於 Flutter 1.17.1 的 Flutter Module 在 Release(AOT Assembly)Mode 下構建後的產物,未經過任何壓縮。

1. iOS 篇#

1.1 產物構成#

我們知道使用 flutter build ios-framework 即可將一個 Flutter Module 構建成一個 Framework 供 iOS 宿主集成,這種集成方式我們稱之為產物集成,這麼這個 “產物” 就是 Flutter 產物,它包含以下幾個部分組成:

  1. App.framework
    • App: 這個是 Dart 業務代碼 AOT 的產物
    • flutter_assets: Flutter 靜態資源文件
  2. Flutter.framework
    • Flutter: Flutter Engine 的編譯產物
    • icudtl.dat: 國際化支持數據文件

打出產物之後,我們在終端可以顯示各個部分的體積,最後整理一下 iOS 端 Flutter 產物結構如下圖所示:

image

需要注意的是 Mac Finder 中顯示的體積會偏大,其換算倍率是 1000 而非 1024,需要我們用命令行拿到顯示的體積之後再手動計算得到真實體積。

此外,Engine 產物的體積我們選用的是 profile 模式(arm64+arm32)下的體積,因 Flutter 1.17.1 release 存在 bug,bitcode 無法被壓縮,導致體積有 351.47 MB,影響分析。具體原因可見:Flutter app size is too big · Issue #45519

1.2 減包方案#

減包的基本方法有二:

  1. 刪產物:把產物中沒用的部分直接刪掉
  2. 挪產物:把可以暫時移除的部分挪走改為遠端下發,同時需要修改產物加載邏輯,使 Flutter 支持動態加載遠端下發的部分產物

我們針對前文中總結的產物結構一一來實現產物減包,首先是 App.framework 中的 App 部分。

1.2.1 App.framework/App#

在說方案之前,我們先看看 App.framework 下的 App 是如何構建而來的,如下圖所示:

image

首先,frontend_server 會將 Dart 源碼編譯成一個中間產物 dill,我們通過運行以下命令也可以實現通過的編譯效果:

image

app.dill 是二進制字節碼,我們通過 string app.dill 可以發現它其實就是 Dart 代碼合併之後的產物:

image

而 Dart 在開發模式下提供的 Hot Reload 其實也正是通過將變動的代碼通過 frontend_server 編譯得到新增的 kernel(app.dill.incremental.dill),通過 WS 提交給 Dart VM Update 之後來進行整棵樹的 Rebuild,從而實現 Hot Reload。

image

之後會通過兩個平台側的 gen_snapshot 進行編譯得到 IL 指令集和優化代碼,最後輸出匯編產物。匯編產物通過 xcrun 工具得到單架構的 App 產物,最後經過 lipo 得到最後雙 ARM 架構的 App 產物。所以,我們這裡展示的 App.framework 下的 App 的體積是雙架構的。

ARMv7: iPhone 5s 之前的 iOS 設備。
ARM64: iPhone 5s 及其之後的 iOS 設備。

接著,我們從刪產物和挪產物兩個層面來講解如何減少該產物的體積。

刪產物#

image

這部分體積是 Dart 代碼 AOT 之後的產物,體積較大,是我們減包過程中重點關照對象。

按照之前說的減包基本方法,我們首先試試 “刪產物”,看看有什麼可以直接刪掉的,使用 Flutter 提供的體積分析工具可以直接得到體積圖:

image

我們發現確實有兩個庫業務中沒有用到,直接刪掉依賴即可。

除此之外還有一些優化,可以幫助我們減少代碼體積:

  • 配置 linter 來禁止不合理的語法:如顯示類型轉換等,編譯前會追加大量的 try catch 導致代碼體積變大。
  • 混淆 Dart 代碼: 0.75MB (2.5%) ↓

此外,我們還可以刪除一些符號來達到減包效果

  • 不使用堆棧跟蹤符號:1.8MB (6.2%) ↓
  • 刪除 dSYM 符號表信息文件:5.8MB (20%) ↓

注:dSYM 是保存 16 進制函數地址映射信息的中轉文件,包含我們調試的 symbols,用來分析 crash report 文件,解析出正確的錯誤函數信息。

挪產物#

接著,我們再看看如何實現 “挪產物”,那就需要對 App.framework/App 中的內容做具體分析了。我們之前說它是 Dart 代碼 AOT 之後的產物,沒錯,因為它主要由四個 AOT 快照庫(snapshot)組成:

  • kDartIsolateSnapshotData: Isolate 快照數據,這是 Dart 堆的初始狀態,並包含 isolate 專屬的信息。
  • kDartIsolateSnapshotInstructions: Isolate 快照指令,包含由 Dart isolate 執行的 AOT 指令。
  • kDartVmSnapshotData: Dart VM 快照數據,isolate 之間共享的 Dart 堆的初始狀態。
  • kDartVmSnapshotInstructions: Dart VM 快照指令,包含 VM 中所有 Dart isolate 之間共享的通用例程的 AOT 指令。

詳情可以看官方 Wiki 中的介紹:https://github.com/flutter/flutter/wiki/Flutter-engine-operation-in-AOT-Mode

同一個進程裡可以有很多個 Isolate,但兩個 Isolate 的堆是不能共享的。Dart VM 開發團隊早就考慮到了交互的問題,於是就設計了一个 VM Isolate,它是運行在 UI 線程中 Isolate 之間交互的橋樑。Dart VM 中 isolate 之間的關係如下圖所示:

image

因此 isolate 對應的 AOT Snapshot 就是 kDartIsolateSnapshot,其又分為指令段和數據段;VM Isolate 對應的 AOT Snapshot 就是 kDartVmSnapshot,其也分為指令段和數據段。

根據以上分析,App.framework 的結構我們可以進一步拆分成下圖所示:

image

我們知道 App Store 審核條例不允許動態下發可執行二進制代碼,因此對於以上 4 個快照,我們只能下發數據段的內容(kDartIsolateSnapshotData 與 kDartVmSnapshotData),而指令段的內容(kDartIsolateSnapshotInstructions 與 kDartVmSnapshotInstructions)則依然要留在產物裡。

那麼,我們要在哪裡分離這個快照庫呢?

在 Dart VM 啟動時的數據加載階段,如下圖所示,修改 settings 裡面的快照庫的讀取路徑即可:

image

修改之後的具體實現本文不做講解,在 《Q 音直播 Flutter 包裁剪方案 (iOS)》 一文有詳細的代碼修改介紹。

1.2.2 App.framework/flutter_assets#

image

flutter_assets 是 Flutter Module 中使用到的本地靜態資源,對於這部分我們不可能 “刪” 的只能 “挪”,我們有兩種方案來挪產物—— 常規方案依然是在 Dart VM 啟動時的數據加載階段來修改 settings 裡的 flutter_assets 路徑,來做到遠程加載,常規情況下我們使用這種方式就可以移除 flutter_assets 了。

那麼有沒有什麼方式可以不修改 Flutter Engine 的代碼移除 flutter_assets?有的,可以使用 CDN 圖片 + 磁碟緩存 + 預加載的組合方案實現同樣的效果,步驟如下:

  1. 封裝一個 Image 組件,根據編譯模式選擇使用本地圖還是網絡圖,即開發環境下使用本地圖快速開發,生產環境下使用 CDN 圖。
  2. 改造 CI,持續集成時移除 flutter_assets 並發布包內的圖片到 CDN 上。
  3. 擴展增強 Image 組件的能力,引入 cached_network_image,支持磁碟緩存。
  4. Flutter 模塊加載時,使用 precacheImage 方法對 CDN 圖片進行預加載。

這套方案稍顯麻煩了一些,而且還要區分環境,因此還是建議修改 Flutter Engine 來實現遠端加載 flutter_assets。

1.2.3 Flutter.framework/icudtl.dat#

image

icudtl.dat 是國際化支持數據文件,不建議直接刪掉,而是同上述挪產物的方案一樣,在 Dart VM 啟動時的數據加載階段修改 settings 裡的 icudtl.dat 路徑(icu_data_path)來實現遠端加載:

image

1.2.4 Flutter.framework/Flutter#

image

引擎修改#

這一部分是 Flutter Engine (C++)的編譯後的二進制產物,是產物裡佔據體積最大的部分,目前我們參考字節跳動的分享《如何縮減接近 50% 的 Flutter 包體積》,可優化的部分目前有以下兩點:

  1. 編譯優化
  2. 引擎裁剪

Flutter Engine 使用 LLVM 進行編譯,其中鏈接時優化(LTO)有一個 Clang Optimization Level 編譯參數,如下圖所示(在 buildroot 裡):

image

我們將這裡 iOS 平台的 Engine 編譯參數從 -Os 參數改成使用 -Oz 參數,最終可以減小 700 KB 左右體積。

image

而引擎裁剪也有兩個部分可以裁剪:

  1. Skia: 去掉一些參數,在不影響性能的情況下可以減少 200KB 的體積。
  2. BoringSSL: 若使用客戶端代理請求,則不需要 Dart HttpClient 模塊,這部分可以完全移除,且代理請求會比 HttpClient 的性能更好,這部分可以減少 500KB 的體積

附:在 https://github.com/flutter/flutter/issues/40345 中提及了另一個角度的編譯優化,即函數編譯優化。同一個加法函數,Dart 實現編譯之後有 36 條指令,而 Objective-C 只有 11 條指令,36 條指令中有頭部 8 條和尾部 6 條的對齊指令,是可以移除的,中間有 5 條棧溢出檢查也是可以移除的,** 即 Dart 編譯後的 36 條指令可以優化成 13 條指令。** 這裡需要等 Google 官方來進行優化。

引擎編譯#

修改完之後我們需要編譯引擎,首先先介紹一下 Flutter Engine 編譯時需要用到的工具:

  • gclient:源碼庫管理工具,原本是 chromium 使用的,它可以管理源碼以及對應的依賴,通過 gclient 來獲取編譯需要所有源碼和依賴。
  • gn:負責生成 ninja 編譯需要的 build 文件,特別像 Flutter 這種跨多種操作系統平台跨多種 CPU 架構的,就需要通過 gn 生成很多套不同的 ninja build 文件。
  • ninja:編譯工具,負責最終的編譯工作。

編譯工具介紹具體可見 Flutter 官方 Wiki:Setting up the Engine development environment - Flutter wiki

具體編譯分為三步,首先創建一個 .gclient 文件,來拉取源碼和所有對應的依賴,如下圖所示:

image

第二步,執行 gclient sync 下載依賴。

需要注意的是以上幾點修改都是依賴(如 buildroot,skia 等)而非源碼,因此需要我們 fork 一份 flutter engine,然後先改好依賴之後,獲取對應依賴的 commit 號再填進 engine 的 DEPS 文件裡,之後提交代碼之後獲取 engine 倉庫最新的 commit 號,填進 .gclient 文件中。

第三步,使用 ninja 配合 gn 生成的配置文件來編譯 engine,想編譯什麼平台架構的 engine 就使用 gn 生成一份配置,之後 ninja 執行編譯即可。如下圖所示:

image

最終,我們就能得到若干份(不同平台架構)的定制 Engine,而使用它們也很簡單,直接替換本地 Flutter SDK 中的 Engine 即可。

經過以上幾步針對各個產物內容的減包處理,我們最終的產物架構如下圖所示:

image

1.3 減包成效#

iOS App 的體積查看分為以下幾種方法,得到的大小都是不同的:

第一種方式是查看本地構建 ipa 之後的分析報告,分析報告裡會提供兩個體積,但是需要注意的是它們都是未加密的:

  1. 安裝包體積:即未加密的,下載大小
  2. 解壓後的體積:即未加密的,占用體積

但是上傳 App Store 之後都是會加密的,因此想要知道用戶最後看到的體積,需要上傳 App Store 查看報告,這裡的報告同樣會提供兩個體積,如下圖所示:

image

分別是:

  1. Download Size
  2. Install Size

用戶最後在 App Store 看到的是 Install Size。

注:但有一種情況例外,即使用 Web 瀏覽器登錄 App Store 去查看 App 的體積,那个時候展示的體積的 Download Size,因為 Apple 認為你此刻關注的並不是安裝占用體積。

我們使用空白項目作為宿主工程上傳 App Store 查看 Install Size,發現 App 的體積從 18.7MB 減少到了 11.8MB

2. Android 篇#

Android 端減包方案則較為簡單,因為沒有 App Store 的審核條例限制,可以粗暴地挪走全部產物並動態下發。我們依然從產物構成、減包方案、減包成效來看看 Android 端的 Flutter 減包。

2.1 產物構成#

首先我們來看看 Android 端 Flutter Module 產物(Release)編譯流程,和 iOS 一樣,依然是 Dart 源碼與 Engine 兩部分產物構成:

image

最終產物 flutter.gradle 裡包括了:

  1. libapp.so
  2. flutter.jar

其中,flutter.jar 又含有 libflutter.so,icudtl.dat 與一些 Java 文件,而 libflutter.so 即引擎產物,icudtl.dat 依然是國際化支持文件,最後一些 Java 是暴露給業務側調用 Flutter 的接口。

那麼關鍵產物構成如下表所示:

image

2.2 減包方案#

libflutter.so 是引擎產物,我們依然可以做裁剪定制,但是必要性已經不大了,因為 Flutter 產物在 Android 端可以做到完全動態下發。步驟如下:

  1. 挪走 libapp.so,libflutter.so,flutter_assets 等文件,發布到雲端
  2. 通過定制 flutter.jar 中的 FlutterLoader.java 邏輯,來加載自定義位置的庫路徑,從而實現動態加載

具體代碼不再演示。

2.3 減包成效#

使用空白工程作為宿主,測量減包前後 APK 的體積大小,可以發現 6.2MB 的 Flutter 產物體積可以完全減去。

image

以上便是雙端的 Flutter 減包方案,內容相對簡單,都是參考前人的腳步來一步步實踐得到的效果,因此強烈建議讀者延伸閱讀一下文末的兩篇文章,以作為進一步學習來加深了解。

參考文章:

  1. 《Q 音直播 Flutter 包裁剪方案 (iOS)》
  2. 《如何縮減接近 50% 的 Flutter 包體積》
載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。