Flutter 動態化方案探索

1、背景

隨着移動平臺的發展,移動端用戶規模愈來愈大,相應地產品需求也是日益見長。爲了解決諸多快速迭代的業務產品線及需求,提升咱們的開發效率,業內的同行們嘗試探索了許多跨平臺方案,現在比較主流的方案大體有如下幾種。如:java

  1. React Native;
  2. Weex;
  3. Hybrid App;
  4. Flutter;
  5. 小程序;

上述的幾種方案或多或少都存在一些瓶頸或使用場景的缺陷,這裏就很少展開討論了。下面列出主要的對照信息,給你們一個參考:node

方案名稱 React Native Weex Hybrid App Flutter 小程序
平臺實現 JS JS 無橋接 無橋接 無橋接
引擎 JSCore JS V8 原生渲染 Flutter engine -
核心語言 React Vue Java/Obeject-C Dart WXML
Apk大小(Release) 4-6M左右 10M左右 - 8-10M左右 -
bundle文件大小 默認單一,較大 較小,多頁面可多文件 不須要 不須要 不須要
上手難度(原生角度) 容易 通常 通常 容易 容易
框架程度 較重 較輕 較重 較輕
特色 適合開發總體App 適合單頁面 適合開發總體App 適合開發總體App 適合開發總體App
社區 豐富(FaceBook) 通常(阿里) 通常 豐富(Google) 通常(微信)
跨平臺支持 Android、iOS Android、iOS Android、iOS Android、iOS、Web、Fuchsia 等 Android、iOS

Flutter做爲最近兩年發展勢頭迅猛的一種跨平臺解決方案, 進入了咱們調研的視線範圍,咱們主要會從如下幾個方面去衡量:linux

  1. 接入難度。
  2. 學習成本。
  3. 性能。
  4. 包體積。
  5. 動態化能力。

2、探索動態化方案

前面幾個方面,相信你們在接觸Flutter的時候都已經有了一些瞭解,這裏就很少做深刻探討了。而做爲跨平臺解決方案,動態化算是一個比較重要的功能之一,經過查資料&翻文檔&技術羣交流討論,發現目前在Flutter中主要有如下三種實現方案:android

  1. 相似React Native 框架。
  2. 替換Flutter編譯產物。
  3. 頁面動態組件框架。

三種實現方案

接下來咱們簡要介紹一下這幾個方案的具體實現原理。ios

1. 動態組件方案

目前,市面上大多技術團隊都是經過這種頁面動態組件的思想去實現動態化,好比閒魚、惟品會、頭條等。該方案的核心原理是在打包應用前,如在編譯期時插樁/預埋好DynamicWidget到代碼中,而後動態下發Json 數據,經過協定好的語義匹配到JSON內的數據,動態替換Widget內容來實現動態化 (除UI外,若須要實現邏輯代碼的動態化,則能夠經過相似Lua 這種比較動態的腳本語言寫業務邏輯)。c++

總結特色以下:git

  1. 在市面上已經有不少與之相似的成熟框架,如天貓的Tangram,淘寶的DinamicX等。它在性能以及動態性,開發成本上取得相對較好的平衡。它能知足常見狀況的動態性需求,在必定程度上能解決實際問題。
  2. 能支持Android/iOS 兩端的動態化。
  3. UI動態化相對較容易,業務邏輯動態化較麻煩。
  4. 語義解析器開發成本相對較大,且不易維護。

1.1 關於語法樹

Tangram、DinamicX等框架它們有個共同點,都是經過Xml或者Html 作爲DSL。可是Flutter 是React Style語法,Flutter本身的語法已經能很好的來表達頁面。所以,這個方案無需自定義語法。用Flutter 源碼作爲DSL便可。這樣 能大大減輕開發以及測試過程,不須要額外的工具支持。github

Flutter analyze 解析源碼獲得ASTNode過程:算法

從上圖能夠看出,插件或者命令對analysis server發起請求,請求中帶須要分析的文件path,和分析的類型,analysis_server通過使用 package:analyzer 獲取 commilationUnit (ASTNode),再對ASTNode 通過computer分析,返回一個分析結果list。shell

根據Flutter的原理,一樣咱們也可使用 package:analyzer 把源文件轉換爲commilationUnit (ASTNode),ASTNode是一個抽象語法樹(abstract syntax tree或者縮寫爲AST)是源代碼的抽象語法結構的樹狀表現形式,利用抽象語法樹能很好的解析Dart 源碼。

方案缺陷:

須要對源碼的格式制定規則,好比不支持 直接寫if else ,須要使用邏輯wiget組件來代替if else 語句。若是不制定規則,那AST Node 到widget node 的解析過程會很複雜。所以能夠引入lua 來實現邏輯代碼的動態化。

1.2 json +lua 方案的總體架構規劃:

Flutter 動態組件框架設計圖.jpg

開源方案:

github.com/dart-lang/s…

github.com/dengyin2000…

luakit_plugin:github.com/williamwen1…

參考資料:

dart.dev/tools/darta…

yq.aliyun.com/articles/67…

2. 相似RN的方案(JS bundle)

參考 React Native 的設計思路,總結起來就是利用 JavasSriptCore 替換DartVM,用 JavaScript(簡稱JS) 把 XML DSL 轉爲 Flutter 的原子widget組件,而後再讓 Flutter 來渲染。從技術上來講是可行的,但成本也很大,這會是一個龐大的工程。

具體來講就是把 Flutter 的渲染邏輯中的三棵樹(即:WidgetTree、Element、RenderObject )中的第一棵(即:WidgetTree),放到 JS 中生成。用 JS 完整實現了 Flutter 控件層封裝,可使用 JS 以相似 Dart 的開發方式,開發Flutter應用。利用JavaScript版的輕量級Flutter Runtime,生成UI描述,傳遞給Dart層的UI引擎,而後UI引擎把UI描述生產真正的 Flutter 控件。

手機QQ看點團隊開源方案:《基於JS的高性能Flutter動態化框架》

方案缺陷:無論JSWidget建立有多快,老是有跨語言執行,對於性能老是會有影響的。另外因爲iOS系統內置支持JS,因此它在iOS上是徹底動態化的,可是Android 端須要額外引入JS庫。目前MXFlutter 這套方案也僅僅實現了iOS版的動態化,而且實現起來較複雜。

3. 替換編譯產物方案

若要實現編譯產物的動態化,那麼在Android平臺上,則會被限制在JIT代碼上;而在iOS平臺上,則會被限制在解釋執行的代碼。谷歌Flutter團隊的以前嘗試過提供官方的解決方案,但後來放棄了並回滾了代碼。他們是說法是對於這樣在有平臺限制下的解決方案,在iOS平臺上的性能表現可否達到預期並沒太多信心(簡單地說就是,在iOS系統上跑起來會卡得沒法讓人忍受,由於iOS不像android 那樣,能夠直接加載動態庫so,它須要加載的是靜態庫)所以,若採用這種編譯產物替換的方案,那麼目前只能使用在Android 端。

首先,咱們得知道Flutter的編譯產物是什麼,就正如咱們所熟知的Android那套編譯產物是dex文件,經過對dex文件的加載流程進行偷樑換柱,能夠達到動態化的目的。那麼,咱們先來了解一下Flutter的編譯產物,這裏須要注意的是Flutter目前的更新速度太快了,不一樣版本下的編譯產物也不太一致。

3.1 Flutter的編譯指令

(1)編譯apk & aar 默認引擎

// 編譯純Flutter apk,默認是release版本
flutter build apk

// 編譯純debug版apk
flutter build apk --debug

// 編譯 aar, 默認是release 版本
flutter build aar

// 編譯aar, 默認是debug版本
flutter build aar --debug
複製代碼

關於編譯的指令,能夠經過flutter build -h進行查看,以下截圖:

(2)編譯apk & aar 指定本地引擎

  • 關於如何編譯引擎,能夠查看這篇文章[Ubuntu 16.04 編譯Flutter Engine](/home/lichaojian/文檔/Ubuntu 16.04 編譯Flutter Engine.md)

  • 如何引用本地引擎

// 指定引用本地的引擎去編譯apk,適用於純Flutter應用
flutter build apk --target-platform android-arm64 --local-engine=android_release_arm64 --local-engine-src-path=/home/lichaojian/engine/src

// 指定引用本地的引擎去編譯aar,適用於Flutter & Native 的混編項目
flutter build aar --target-platform android-arm64 --local-engine=android_release_arm64 --local-engine-src-path=/home/lichaojian/engine/src
複製代碼

(3)查看Flutter 編譯指令的源碼

​ 其實不管咱們是編譯apk或者是aar,都是經過flutter這個指令,因此查看一下這個flutter指令的源碼其實是什麼。接下來咱們能夠查看一下/your_flutter_sdk_path/bin/flutter,打開flutter這個文件,裏面最核心的一句話以下:

FLUTTER_TOOLS_DIR="$FLUTTER_ROOT/packages/flutter_tools"
SNAPSHOT_PATH="$FLUTTER_ROOT/bin/cache/flutter_tools.snapshot"
STAMP_PATH="$FLUTTER_ROOT/bin/cache/flutter_tools.stamp"
SCRIPT_PATH="$FLUTTER_TOOLS_DIR/bin/flutter_tools.dart"
DART_SDK_PATH="$FLUTTER_ROOT/bin/cache/dart-sdk"

DART="$DART_SDK_PATH/bin/dart"
PUB="$DART_SDK_PATH/bin/pub"
 # FLUTTER_TOOL_ARGS isn't quoted below, because it is meant to be considered as
# separate space-separated args.
"$DART" --packages="$FLUTTER_TOOLS_DIR/.packages" $FLUTTER_TOOL_ARGS "$SNAPSHOT_PATH" "$@"
複製代碼
  • $DART: 啓動一個dart虛擬機
  • $SNAPSHOT_PATH: 指定一個可執行的snapshot文件,路徑是/your_flutter_sdk_path/bin/cache/flutter_tools.snapshot
  • $@: 就是你傳過來的參數(例如:build apk)
// 從上面能夠看出,平時咱們運行的
flutter build apk

// 其實是
/your_flutter_sdk_path/bin/cache/dart-sdk/bin/dart /your_flutter_sdk_path/bin/cache/flutter_tools.snapshot build apk
複製代碼

運行上述指令,以下截圖,編譯apk成功,aar也是一樣的原理:

  • 接下來查看一下flutter_tools.snapshot的源碼文件,位於/your_flutter_sdk_path/flutter/package/flutter_tools/bin/flutter_tools.dart

從上述截圖能夠看出,實際是調用了executable.main的方法,接下來咱們看一下executable.dart

能夠看出,runner這裏運行了一系列的Command類,而後咱們熟悉的固然是flutter build這個命令,因此咱們能夠看一下flutter build的命令對應的就是BuildCommand

從上圖能夠看出,實際上,BuildCommand其實是由不少的子Command組成來的,例如aar、apk、aot等都是屬於BuildCommand的子命令。

若是想更詳細的瞭解Flutter的打包編譯流程,推薦查看 [研讀Flutter——打包編譯流程詳解]

3.2 Flutter不一樣版本下的編譯產物差別

(v1.5.4-hotfixes & v1.9.1)

  • V1.5.4-hofixed

(1)debug 模式

image-20191026064033993

(2)release模式

image-20191026064004887

  • v1.9.1

(1)debug模式

(2)release模式

從上面的截圖能夠看出來,debug模式下,v1.5.4-hofixes以及v1.9.1的產物沒多大變化,這裏咱們也不針對debug版本進行討論,能夠忽略,可是咱們能夠發現二者的區別以下:

v1.5.4-hotfixes release模式下產物

  • isolate_snapshot_instr
  • isolate_snapshot_data
  • vm_snapshot_data
  • assets/vm_snapshot_instr

v1.9.1 release模式下產物

  • libapp.so

着重分析v1.9.1 release模式下的產物主要分爲這幾個:

  • /lib/libapp.so 主要是編譯Dart的生成的可執行文件
  • /lib/libflutter.so 主要存放Flutter Engine 的可執行文件
  • /assets/flutter_assets 主要存放flutter的一些資源文件,例如字體,圖片等。

​ 能夠看出,在v1.9.1版本之後,Flutter的代碼編譯產物就變得更單一了,這是有助於咱們進行動態化的研究的,咱們知道,libapp.so是天生支持動態連接的。意思是咱們就能夠替換掉libapp.so文件,從而達到動態化的目的。這個最開始也是立森經過直接root手機替換掉產物,發現是支持的,而後纔有了咱們的後續。

​ 既然是支持替換libapp.so來實現動態更新的,那麼咱們怎麼經過代碼去實現呢?

3.3 Flutter 如何動態替換編譯產物?

​ 從上面咱們能夠知道,Flutter的編譯產物到底有哪些東西了,因此咱們經過對代碼進行動態指定加載編譯產物的路徑便可達到動態化的效果,那麼應該怎樣對代碼進行修改呢?有兩種方式:

(1)經過修改Flutter Engine的方式。

優勢:

  • 便於熟悉Engine 代碼。
  • 可定製擴展Engine。

缺點:

  • 對Engine的代碼的入侵性較強。
  • 須要維護一個本身的Engine,對外提供。
  • 須要按期更新同步官方Engine代碼。

(2)經過Hook 的方式。

優勢:對Engine代碼入侵性較小。

缺點:須要維護SDK,Engine版本更新時,需跟進hook點是否須要替換。

3.3.1 so文件的替換流程

  • (1)Android 是如何加載so文件的。

    ​ 經過上面介紹的編譯產物,能夠看出,release版本下,Android下,目前flutter會編譯成一個libapp.so文件,那麼Android自己加載so文件的方式有哪幾種呢?主要分爲如下兩種:

// 默認加載路徑加載,對應~/app/libs
System.loadLibrary("libname")
    
// 經過絕對路徑進行加載
System.load("/your_so_path/libupdate.so")
複製代碼

這兩種方式的主要區別,就是loadLibrary經過加載app下的libs目錄的so文件,load的話是經過加載其絕對路徑加載。

關於Android當中,加載.so文件的原理,能夠看一下gityuan的 loadLibrary動態庫加載過程分析,也能夠看一下

深刻理解System.loadLibrary 這篇文章。

簡單的說,都是經過調用dlfcn.h 這個頭文件下的函數,以下:

void *dlopen(const char *filename, int flag);  //打開動態連接庫
char *dlerror(void);   //獲取錯誤信息
void *dlsym(void *handle, const char *symbol);  //獲取方法指針
int dlclose(void *handle); //關閉動態連接庫 
複製代碼

​ 瞭解完Android是如何加載so文件的,接下來看一下Flutter是如何加載so文件的。

  • (2)Flutter 是如何加載so文件的。
  1. 初始化Flutter,經過查看源碼,咱們知道必須調用的方法有兩個。

    FlutterMain.startInitialization(@NonNull Context applicationContext)
    FlutterMain.ensureInitializationComplete(@NonNull Context applicationContext, @Nullable String[] args)
    複製代碼

此處省略不少.......直接進入重點....

native_library_posix.cc

NativeLibrary::NativeLibrary(const char* path) {
  ::dlerror();

  FML_LOG(ERROR)<< "lichaojian-path = " << path;
  
  handle_ = ::dlopen(path, RTLD_NOW);
  if (handle_ == nullptr) {
    FML_DLOG(ERROR) << "Could not open library '" << path << "' due to error '"
                    << ::dlerror() << "'.";
  }
}

fml::RefPtr<NativeLibrary> NativeLibrary::Create(const char* path) {
  auto library = fml::AdoptRef(new NativeLibrary(path));
  FML_LOG(ERROR)<< "lichaojian-Create = " << path;
  return library->GetHandle() != nullptr ? library : nullptr;
}
複製代碼

從上述指令能夠看出,實際上也是調用dlopen來加載so庫的。因此知道這個原理以後,咱們就知道怎麼處理了,我在這兩個函數加的日誌打印以下:

知道了原理以後,實現Flutter動態加載so文件的方式主要分爲兩部分:

​ (1) native層

經過更改native層代碼,讓native層判斷某個預約好的路徑是否存在更新的文件,存在的話,則進行加載更新的文件,不存在的話,則加載原來的libapp文件。
複製代碼

(2) java 層

​ 剛纔在FlutterMain#ensureInitializationComplete方法當中,咱們能夠看到libapp相關的參數當中,有兩行代碼相當重要,咱們來回顧一下:

private static final String DEFAULT_AOT_SHARED_LIBRARY_NAME = "libapp.so";
private static String sAotSharedLibraryName = DEFAULT_AOT_SHARED_LIBRARY_NAME;

shellArgs.add("--" + AOT_SHARED_LIBRARY_NAME + "=" + sAotSharedLibraryName);

                // Most devices can load the AOT shared library based on the library name
                // with no directory path. Provide a fully qualified path to the library
                // as a workaround for devices where that fails.
                shellArgs.add("--" + AOT_SHARED_LIBRARY_NAME + "=" + applicationInfo.nativeLibraryDir + File.separator + sAotSharedLibraryName);
複製代碼

經過上述代碼能夠發現,這裏把aot_share_library_name以及其路徑都加載到了shellArgs參數當中,因此咱們能夠經過在java層更改這個路徑以及名稱,從而達到動態加載so的目的。爲何連名稱都要更改,若是名稱不更改的化,找到libapp.so這個名稱的時候,會直接映射到lib目錄下的libapp.so這個文件,因此致使動態加載so失效。

經過替換一個路徑已經更更名字的so文件,達到動態加載。

3.3.2 資源的替換

關於flutter資源

相比起Android系統的資源管理,flutter對資源的管理實在是簡單得太多了,flutter的資源沒有通過編譯的任何處理,徹底是以源文件的形式暴露出來,獲取資源就是以文件的讀取方式來進行,返回給到flutter的是資源在內存中的buffer內容,資源是以目錄名+文件名來標識,以下:

關於flutter AssetManager

flutter engine內部也有一個AssetManager,源碼路徑是flutter/assets/asset_manager.h AssetManager的代碼很少,只是內部維護了AssetResolver的一個隊列,核心的方法有兩個

//往隊列裏面添加一個AssetResolver
void AssetManager::PushBack(std::unique_ptr<AssetResolver> resolver) {
  if (resolver == nullptr || !resolver->IsValid()) {
    return;
  }

  resolvers_.push_back(std::move(resolver));
}

//檢索資源
std::unique_ptr<fml::Mapping> AssetManager::GetAsMapping(
    const std::string& asset_name) const {
  if (asset_name.size() == 0) {
    return nullptr;
  }
  TRACE_EVENT1("flutter", "AssetManager::GetAsMapping", "name",
               asset_name.c_str());
  for (const auto& resolver : resolvers_) {
    auto mapping = resolver->GetAsMapping(asset_name);
    if (mapping != nullptr) {
      return mapping;
    }
  }
  FML_DLOG(WARNING) << "Could not find asset: " << asset_name;
  return nullptr;
}
複製代碼

從代碼裏面能夠看得出來,其實真正的資源是由AssetResolver提供的。

關於flutter AssetResolver

AssetResolver是個接口類,flutter資源提供者必需要實現這個接口源碼是在flutter/assets/asset_resolver.h 下面,定義大體以下

namespace flutter {

class AssetResolver {
 public:
  // 無關重要的被我省略了。。。
  virtual std::unique_ptr<fml::Mapping> GetAsMapping(
      const std::string& asset_name) const = 0;

 private:
  FML_DISALLOW_COPY_AND_ASSIGN(AssetResolver);
};

}  // namespace flutter
複製代碼

其中最爲核心的就是GetAsMapping方法,此方法返回了一個文件的MappingMapping也是個接口類,其定義也極爲簡單,源碼是在 flutter/fml/mapping.h下面,這裏直接給出

class Mapping {
 public:
  // 無關重要的被我省略了。。。
  virtual size_t GetSize() const = 0;
  virtual const uint8_t* GetMapping() const = 0;
};
複製代碼

其中GetSize 返回了文件的大小,GetMapping返回的是資源在內存中的地址,整個資源的管理結構大體以下圖所示:

關於flutter APKAssetProvider

APKAssetProvider實現了AssetResolver接口,爲flutter提供了Android平臺下的資源獲取能力,其本質就是把Java層的AssetManager經過AAssetManager_fromJava接口轉換到C++層,而後再經過AAssetManager_open AAsset_getBuffer AAsset_close等NDK接口來讀取Asset資源, 源碼路徑在flutter/shell/platform/android/apk_asset_provider.h 下面,代碼也很少,這裏直接給出調用流程

關於flutter資源動態部署的幾種方案

  • Android平臺

經過前面的代碼分析,咱們能夠清楚的看見,在Android平臺下面,flutter的資源其實也是由AssetManager提供的,因此咱們能夠借鑑熱修復的原理(其實比熱修復還簡單得多,由於這裏咱們不須要作全量合成,只要作一次半全量合成就能夠了,也不須要去replace系統的AssetManager只管調addAssetPath就能夠了)。 固然,用這種方案的話必需要解決Android 9對私有API的限制問題。

  • 跨平臺通用方式

上面的方案弊端是很明顯的,第一隻能知足Android平臺,第二須要解決系統對私有API的約束問題等,其實在作第一種方案前,做者我就已經先實現了基於c++層的跨平臺通用方式,其過程及原理也是很是的簡單,經過前面的分析,咱們只要實現一個本身的AssetResolverMapping,而後把AssetResolver塞到flutter的AssetManager隊列裏面就能夠了。

  • 利用flutter提供的原生支持方案

這種方式是昨晚在寫此文章時才發現的,因此暫時尚未通過驗證,不過從理論上來說也是可行的,而且就目前來看應該是最簡單,最有效的一種方案。

RunConfiguration裏面咱們能找到以下代碼(源碼路徑flutter/shell/common/run_configuration.h

RunConfiguration RunConfiguration::InferFromSettings(
    const Settings& settings,
    fml::RefPtr<fml::TaskRunner> io_worker) {
  // 下面無關重要的代碼已經被我刪除了。。。
  if (fml::UniqueFD::traits_type::IsValid(settings.assets_dir)) {
    asset_manager->PushBack(std::make_unique<DirectoryAssetBundle>(
        fml::Duplicate(settings.assets_dir)));
  }
  asset_manager->PushBack(
      std::make_unique<DirectoryAssetBundle>(fml::OpenDirectory(
          settings.assets_path.c_str(), false, fml::FilePermission::kRead)));
}
複製代碼

實際上這裏的InferFromSettings是給fuchsia用的(flutter跨平臺,在engine工程裏面隨處都能看見相似於fuchsia android ios windows linux darwin等等目錄結構),咱們不能直接調這個函數,可是DirectoryAssetBundle倒是能夠公共的(事實上ios平臺也是沒有像Android平臺那樣包裝一個APKAssetProvider出來,ios也是直接使用DirectoryAssetBundle的) DirectoryAssetBundle本質上也是AssetResolver的一個實現,源碼路徑是在flutter/assets/directory_asset_bundle.h下面,這裏就再也不分析了,有興趣的能夠直接去看下。

3.3.3 實現流程

方案大體流程

這裏實現的原理大體與Tinker 等Android 熱更新方案相似,經過對比新舊版本的文件差別,生成一份補丁包。而後將補丁包放到服務器,下發給舊版本APK的用戶。以後下載好再在本地解壓,將補丁包合併以實現全量替換。

差分包的生成與合併

在這塊走了一些彎路,一開始在網上找的時候,都是推薦了bsdiff和bspatch,可是官網只有c的代碼,這時候,我比較懵逼,就直接經過NDK的方式,直接移植代碼到Android平臺上,在移植編譯動態庫的時候,就踩了一些坑把,可是主要仍是一些不熟悉CMake以及c++引發的新手坑。

關於bsdiff的一些參考連接:

bsdiff.pdf

bsdiff算法

Google 的差量更新,實際上也是用了bsdiff

Tinker 基於 bsdiff v4.2封裝的java代碼

關於差分包的生成與合併,都是用現成的框架,因此難度並不會很大。

總結

本文主要探索並講解了Flutter 中目前主流的三種動態化實現方案,動態組件方案以及相似RN這種Js 方案,本質上都是經過AST 解析語義樹來實現的。而編譯產物的動態化,經過分析源碼發現目前可以在Android 平臺實現,iOS平臺則尚未太好的解決方案。Android的產物編譯動態化方案,目前來講實現起來相對容易些,難度不算太大。在探索過程當中或多或少踩過一些坑,文章如有不足之處還望你們多多指正~

感謝閱讀~

做者


xiaosongzeem
相關文章
相關標籤/搜索