Android Flutter 多例項實踐

網易雲信發表於2021-07-23

引言

Flutter CLI 工具支援將 Flutter Module 打包成 Android AAR 包以供外部依賴使用,即 Flutter AAR。在一個沒有使用 Flutter 技術棧的 Android 工程中整合 Flutter AAR 是沒有任何問題的,但如果目標工程本身已經使用了 Flutter 框架,在此基礎上再接入 Flutter AAR 就會失敗,我們稱之為 Flutter 多例項問題。本文主要介紹在 Android 平臺下 Flutter 多例項問題的一種解決方案。

背景

企業的業務往往是複雜多樣的,如果是 ToC 的業務,我們大多時候需要開發一個體驗良好的應用 APP;而如果是 ToB 的業務,我們往往需要提供一個易於接入和使用的 SDK。在 ToC 業務上,Flutter 框架提供的跨平臺、高效開發與高效能特性,使得移動端應用開發變得更加簡單且高效;那在 ToB 業務上,SDK 的開發是否能夠享受 Flutter 框架提供的這些紅利呢?這一點對於像我們網易雲信這樣的服務、能力提供商而言尤為重要。網易雲信是集網易 21 年 IM 以及音視訊技術打造的融合通訊雲服務專家,穩定易用的通訊與視訊 PaaS 平臺,其服務大多以能力 SDK 的形式對外提供,如果能夠提高 SDK 的生產效率和研發效能,好處不言而喻。所以,上面的問題答案當然是肯定的!就像使用 Flutter 開發 APP 一樣,我們同樣可以使用 Flutter 進行 SDK 開發,從而在 Android / iOS 甚至更多平臺中共享一致的業務邏輯實現,減小人力、提高生產效率和研發效能。

在使用 Flutter 進行 SDK 開發時,產物的打包方式主要有以下兩種形式:

  • Flutter Package / Flutter Plugin:該打包方式需要以 Dart 原始碼形式釋出到 Pub.dev 或 GitHub,第三方開發者在接入時本質上是以原始碼的形式依賴,同時接入方本地需要搭建並引入 Flutter 開發環境。此種方式有明顯的缺陷:首先,原始碼釋出會將 SDK 內部實現細節完全暴露在外( Flutter 框架並未提供類似 Proguard 的混淆工具),這對企業的非開源專案而言是不可接受的;其次,它變相要求接入方使用 Flutter 技術棧,這對於當前沒有在目標專案中使用 Flutter 開發的接入方而言,門檻較高不說,接入體驗也不太友好。
  • Android AAR:AAR 是 Android 應用官方的依賴形式,並不存在明顯的短板。通過 Flutter 框架提供的 CLI 工具,可以很方便地將 Flutter Module 打包成 AAR 釋出出去,不用擔心洩漏業務原始碼,也不損失接入體驗。因為打包工具會將 Flutter 層的業務程式碼編譯成 AOT 共享庫,而平臺層的 Java 業務程式碼則可以開啟混淆避免反編譯(為了簡便,後面統一使用 Flutter AAR 命名由 Flutter Module 打包而成的 Android AAR 包)。

綜上所言,對於企業的一個商業 SDK 專案來說,如果選擇使用 Flutter 技術棧進行開發,那麼使用 Flutter AAR 形式來發布才是明智之舉。但其實這又會引入新的問題。在前文 Flutter 混合開發基礎 中我們介紹了,一個 Flutter APP 的包結構,它包含有引擎庫 libflutter.so、業務庫 libapp.so、 以及flutter_assets 等部分。同理,一個 Flutter Module 打包出來的 AAR 也會包含類似的結構以及產物檔案。那在一個 Flutter APP 中,應該以何種姿勢接入 Flutter AAR 呢?可以預見的是,它們之間必然存在衝突,檔案衝突已經顯而易見,類、資源、甚至 Flutter Engine 也可能會衝突,這種常規的 Flutter AAR 包顯然是無法整合到 Flutter APP 工程中使用的。有問題就有答案,接下來,我們就一起來分析、探索該問題的解決方案。

Flutter APP 整合 Flutter AAR 問題分析

上面說到 Flutter APP 無法整合常規打包出來的 Flutter AAR,因為存在一系列的衝突,但具體會出現什麼樣的錯誤,還是需要我們真正動手去整合才能知道。這個環節感興趣的小夥伴可以親自動手嘗試,不再贅述,下面直接給出結論說明兩者共存存在哪些問題:

  • 構建失敗,其實就是因為檔案、類衝突導致編譯失敗。主要衝突有:
    • Flutter 版本依賴衝突:Flutter APP 宿主工程與 Flutter AAR 使用的 Flutter 版本不一致導致,包括 Flutter Embedding Jar 與 Flutter SO Jar,前者包含平臺層 Java 程式碼,後者包含 libflutter.so 引擎庫檔案。通過 Gradle 我們可以解決這個依賴的版本衝突,例如強制使用其中某個版本,但這樣做極有可能會出現執行時錯誤。
    • Flutter Plugin 平臺程式碼 / 資源衝突:Flutter APP 和 Flutter AAR 引用了相同的 Plugin 但版本不一致導致。外掛中會包含平臺層的程式碼,版本不一致同樣可能會導致編譯失敗或者執行時錯誤。
    • GeneratedPluginRegistrant.java 檔案衝突:該檔案為 Flutter 工具生成的外掛自動註冊類,用於 Flutter Engine 啟動時自動載入所需外掛。Flutter APP 與 Flutter AAR 均有對應的類檔案,負責載入各自依賴的外掛,兩者缺一不可。
    • libapp.so 衝突:這是 Dart 程式碼經過 AOT 生成的動態庫,Flutter APP 和 Flutter AAR 都會生成與其對應的 so 庫,我們不能單純的只使用它們其中之一,因為它們本身包含的 AOT 程式碼是從不同的原始碼編譯過來的。
  • 執行時錯誤
    • 同一個 Flutter Engine 不支援載入多個 AOT 庫:Flutter Engine 在初始化時會動態連結 libapp.so 這個 AOT 庫,解析其中的資料段,並執行程式碼段中的機器指令。但在我們的場景中,執行時其實是包含有兩個 AOT 庫的,它們都需要載入到 Flutter Engine 中來,使用同一個Engine 是無法滿足需求的,因為在 Flutter 的實現中,一個 Engine 只能對應一個 AOT 庫。
    • 圖片資源、字型庫無法正常顯示:此類資源會被打包至 flutter_assets 中,並且會生成對應的 Manifest 資源描述清單檔案。但 Flutter APP 生成的資源清單檔案會覆蓋 Flutter AAR 中的資源清單檔案,這樣導致 Flutter Engine 在載入資源時,無法從清單檔案中查詢到對應的資源,因此載入失敗。

以上就是我們在 Flutter APP 中接入 Flutter AAR 遇到的問題。針對這些問題,我們首先想到的是,Flutter Team 或者開源社群是不是已經有此類問題的解決方案了?但在經過調研後發現目前並沒有。Flutter 框架是支援多個 Engine 的,包括 Flutter 2.0 新支援的 Engine Group 僅支援載入和執行同一個 AOT 庫下的程式碼,明顯不能滿足我們的需求。我們還給官方提了對應 Issue(github.com/flutter/flu…) 進行討論,但是暫時還沒有得到滿意的解決方案,為此我們不得已走上了自己探索解決方案的自強之路。

解決方案探索

通過上面的分析,我們已經瞭解了接入過程中出現的具體錯誤以及出錯原因。在真正著手探索解決方案前,還應設立目標解決方案應該滿足的一些原則:

  • 首先方案應該朝著最小引擎改動、甚至無改動的方向努力。因為 Flutter 框架一直在不斷迭代演進,如果我們修改了引擎這塊的邏輯,除非這些改動能通過 PR 進入主幹分支,否則引擎一旦更新,我們的方案就得重新適配,後期維護工作大。
  • 其次方案應該儘量不依賴宿主工程做額外的改造或支援。首先 Flutter APP 接入 Flutter AAR 就跟普通 Android APP 接入 Android AAR 一樣簡單,不應引入額外的外掛或是 Gradle 指令碼;其次 Flutter AAR 和 Flutter APP 的 Flutter 執行時環境應該儘量隔離。

明確目標之後,我們再來看看入手點在哪裡。由於需要儘量避免引擎改動,那應該是自上而下,首先從應用層切入,看能否找到對策。這就需要我們深入原始碼,從上到下了解 Flutter 框架的初始化、執行機制。這裡不做單獨講解,在具體問題分析解決上再說明。現在我們再回過頭來看最初遇到的一系列問題,並嘗試運用所掌握的 Android 、Flutter 框架知識來解決。

Class 衝突解決

Class 衝突是因為 Flutter AAR 與 Flutter APP 都有自己的 Plugins 依賴、以及可能會依賴不同版本的 Flutter Embedding Jar,這些依賴庫裡都包含有平臺程式碼,這會導致編譯期類重複而失敗。那如何解決這個問題呢? 最簡單也是最暴力的方法就是對 Flutter AAR 依賴的所有 Plugin 以及 Embedding Jar 原始碼進行重新命名(修改類名或者包名),雖然能解決問題,但工作量巨大、修改面廣、不靈活,一旦 Plugin 或 Flutter 版本更新都需要重新修改。

那有沒有更好的辦法呢?答案是自定義ClassLoader。具體的,在構建 Flutter AAR 時,在原始碼編譯成 .class 階段完成之後,將所有的外掛、Flutter Embedding Jar 對應的 .class 檔案蒐集起來,打包成一個 DEX 檔案放入 Flutter AAR 的 assets 中。在執行時,需要將 assets 下的 DEX 檔案拷貝到應用的 data 私有目錄下,再通過 DexClassLoader 去動態載入這個 DEX。這裡需要注意的是 DEX 檔案是版本號的概念的,它跟 Flutter AAR 的版本號是繫結的,意味著每次載入這個 DEX 時,我們首先需要檢查當前私有目錄下的檔案版本是否與 Flutter AAR 版本一致,一致則直接載入即可,不一致需要刪除原 DEX 檔案並重新拷貝後再載入。關鍵程式碼如下:

image.png

image.png

針對 DEX 檔案的載入一般而言我們只需要使用 DexClassLoader 這個系統類就行了,但這裡我們需要繼承 BaseDexClassLoader,並重寫 findClass 方法。

預設類的載入基於雙親委派模型,一般都是先請求父載入器載入,如果父載入器載入失敗子載入器才有機會載入。但在這裡,我們 findClass 的邏輯需要反其道而行之。Flutter AAR 需要載入的類應該優先使用子載入器從 DEX 檔案中載入,載入失敗後才能通過父載入器載入。程式碼如下:

image.png

庫檔案衝突解決

libflutter.so 是Flutter Engine 動態庫檔案,在執行時會被 Flutter Embedder Jar 載入進來。這個庫檔案衝突,我們不能單純使用宿主中同名的庫檔案,因為兩者的 Engine 版本可能不一致以及不違背執行時 Flutter 版本隔離的目標。

這裡解決衝突最簡單的方法就是重新命名。通過閱讀程式碼,我們發現 Android 以 so 庫的路徑為 key 儲存所有已經載入的動態庫,即便是完全相同的 so 庫,只要檔案路徑不一致,就可以同時 load 進來。因此,這裡通過重新命名能解決檔案衝突的問題,也不會影響到 so 的載入。

libapp.so 衝突也是類似的,我們同樣需要對 Flutter AAR 中的 libapp.so 重新命名。此外,我們還需要特殊處理這兩個 so 的載入流程。因為 Flutter 執行時硬編碼了動態庫的名稱,如果不修改載入流程,在查考庫時就會找到 Flutter APP 生成的庫檔案,而不是我們 Flutter AAR 的庫檔案。

Flutter Engine 的初始化是在 FlutterLoader 這個類中,在這裡會載入 libflutter.so 並配置一系列的引數初始化 Native Engine。我們需要做的就是替換 libflutter.so 的載入邏輯,轉而去載入重新命名後的 Engine 庫檔案。對於 libapp.so ,它並不是在 Java 層載入的,而是由 Native Engine 通過 dlopen 連結的。通過查閱 Engine 的程式碼我們發現通過 --aot-shared-library-name 選項可以設定要載入的目標 libapp.so 路徑。關鍵程式碼如下:

image.png

Flutter 資源衝突解決

Flutter 相關資源是打包放到 assets 目錄下的,且通過對應的 Manifest 檔案來宣告,分別是:**FontManifest.json 與 AssetsManifest.json **檔案。這兩個檔案分別列出了 Flutter 依賴的所有字型資源與路徑對映關係、圖片資源與路徑對映關係。

Flutter-Engine 在執行時通過這兩個檔案來解析圖片與字型資源,Flutter AAR 中雖然也包含了這兩個檔案,但會被 Flutter APP 宿主中的同名檔案覆蓋,導致字型或資源無法載入。所以,這裡有兩個簡單方案:

  • 支援編譯期合併對應的資源清單 json 檔案;這需要開發 Plugin 外掛供宿主使用,實現複雜而且接入不友好;
  • Flutter AAR 中抽離出一個獨立的資源包 Package 供 Flutter APP 依賴,資源包中僅包含 Flutter AAR 引用的所有圖片、字型資源(不包含任何業務邏輯,因此可以放心的釋出到pub平臺),宿主在 Flutter 層依賴這個 Package,這樣宿主在構建時 Flutter 工具會合並所有的的資源,並生成完整的資源清單檔案。

至此,我們解決了 Flutter AAR 與 Flutter APP 的共存問題。當然整個方案落地下來,其中還會碰到其他一些問題,比如:生成的 DEX 檔案需要訪問宿主中的其他類的時候,在混淆啟用的情況下,應該如何保證 DEX 訪問主ClassLoader中的類、方法沒有問題;再如:Flutter AAR 的 DEX 中如果包含有 Android 元件怎麼辦?Android 四大元件都是需要由應用的主ClassLoader進行載入的,如果主 DEX 中沒有包含這些類,那麼肯定啟動失敗;等等諸如此類問題,這裡不再一一列舉。

總結

下圖所示為 Flutter 多例項執行時的架構圖。類似於多 Flutter Engine,以上方案實現的多 Flutter 例項,也是通過建立多個 Native 的 AndroidShellHolder 來實現的。不同的是,在多 Engine 下不同的 ShellHolder 繫結相同的 libapp.so,而多例項下繫結的是不同的 libapp.so ,因此該方案能在執行時隔離 Flutter APP 與 Flutter AAR 的 Flutter 執行時環境。

image.png

該方案的主要優勢表現在:

  • 無 Engine 定製,可維護性較高
  • Flutter APP 與 Flutter AAR 的 Flutter 版本、執行時環境相互獨立

有得必有失,相對地,在其他方面,該方案有所不足:

  • 使用了獨立的 Flutter Engine 庫檔案,因此會導致包體積增加
  • 會載入兩個不同的 Flutter Engine ,記憶體會有所增加

綜上,在 SDK 開發中採用 Flutter 技術,同樣能夠發揮 Flutter 在 APP 開發中的優勢,前提是我們能夠解決好 Flutter 多例項的問題。本文主要講解了 Android Flutter 多例項的一種實現思路,希望能夠對大家有所幫助。

作者簡介

李成達,網易雲信資深移動端開發工程師,熱衷於研究跨平臺開發技術以及工程提效,目前主要負責視訊會議元件化 SDK 的相關研發工作。

更多技術乾貨,歡迎關注【網易智企技術+】微信公眾號

相關文章