Flutter實現動態化更新-技術預研

語言: CN / TW / HK

highlight: a11y-dark

前言:有做過完整專案的小夥伴應該都知道,隨著業務的發展,app的運營需求會越來越多(比如:根據運營活動動態更換頁面的UI)。這就要求我們的app要儘可能的滿足市場的運營的動態化需求,通過這篇文章你將瞭解到:
1. Flutter動態化的方案使用和效果對比;
2. 針對中小型團隊,該如何最小成本、最高效的實現app的動態化需求。

動態化的常用方式和實現原理

首先什麼是動態化?即不依賴程式安裝包,就能進行動態實時更新頁面的技術。
接下來列舉常用的方式和原理: - 一般大家都會想到webview,這確實是最常用的方式,但也是動態化中最不穩定的方式;webview的體驗比較差,同時需要做大量裝置的相容。 - 基於 GPL 的 Native 增強。 GPL即通用程式語言,比如我們常見的Dart、JavaScript等,通過這些通用語言來為Native功能增強動態化能力。通俗的舉個例子解釋:運營者動態更改線上的js檔案,Flutter應用通過網路拉取更新後動態渲染,這就是基於GPL的Native增強。 - 基於 DSL 的 Native 增強。 DSL即專用領域語言,為了解決某些特定場景下的任務而專門設計的語言,比如xml、json、css、html。通過生成簡單的DSL語言檔案,通過解析協議對頁面進行動態配置。
我們整體來看Flutter的動態化生態,目前市面上並沒有一個成熟的開源框架,只有國內各大網際網路公司陸續開源,但也都處在急需維護的狀態。當前主流框架有:

  1. 騰訊開源的 MXFlutter
  2. 58同城開源的 Flutter Fair
  3. 阿里巴巴開源的 北海Karken 同時我還會介紹另外兩種比較通用的方式:

  4. webview增強 【植入騰訊 X5核心,模型升級改造】

  5. UI庫模板化

各大廠動態化架構對比

image.png

Flutter Fair

Fair是“58同城”為Flutter設計的動態化框架,通過Fair Compiler工具實現JSON配置和原生Dart原始檔的自動轉化,從而動態更新Widget Tree和State。

使用介紹

官方並沒有維護pub上的Fair外掛,我們需要去GitHub fork原始碼下來編寫demo。58Fair
準備一份配置好的JSON檔案,然後直接呼叫FairWidget傳入檔案路徑即可顯示,非常簡單。動態化需求無非就是把JSON配置檔案放到線上,然後FairWidget每次都會重新拉取下來展示,從而實現動態化。 dart /// 基本使用 return Container( alignment: Alignment.centerLeft, color: Colors.white, constraints: BoxConstraints(minHeight: 80), child: FairWidget( name: item.id, path: 'assets/bundle/sample.json', data: {"fairProps": json.encode({'detail': details})}, ), ); 繼續跟進原始碼,可以看到當我們傳入的檔案路徑是以http開頭的時候,會通過網路進行拉取 ``` dart void _reload() { var name = state2key; var path = widget.path ?? _fairApp.pathOfBundle(widget.name); bundleType = widget.path != null && widget.path.startsWith('http') ? 'Http' : 'Asset'; parse(context, page: name, url: path, data: widget.data).then((value) { if (mounted && value != null) { setState(() => _child = value); } }); }

/// 再通過parse()方法逐層進入decoder → bundle_provider,檢視onLoad方法 @override Future onLoad(String path, FairDecoder decoder, {bool cache = true, Map h}) { bool isFlexBuffer; if (path.endsWith(FLEX)) { isFlexBuffer = true; } else if (path.endsWith(JSON)) { isFlexBuffer = false; } else { throw ArgumentError( 'unknown format, please use either $JSON or $FLEX;\n $path'); } if (path.startsWith('http')) { return _http(path, isFlexBuffer, headers: h, decode: decoder); } return _asset(path, isFlexBuffer, cache: cache, decode: decoder); }

/// 重點檢視以http開頭的解析方法,用的是http庫拉取 Future _http(String url, bool isFlexBuffer, {Map headers, FairDecoder decode}) async { var start = DateTime.now().millisecondsSinceEpoch; var response = await client.get(url, headers: headers); var end = DateTime.now().millisecondsSinceEpoch; if (response.statusCode != 200) { throw FlutterError('code=${response.statusCode}, unable to load : $url'); } var data = response.bodyBytes; if (data == null) { throw FlutterError('bodyBytes=null, unable to load : $url'); } Map map; map = await decode.decode(data, isFlexBuffer); var end2 = DateTime.now().millisecondsSinceEpoch; log('[Fair] load $url, time: ${end - start} ms, json parsing time: ${end2 - end} ms'); return map; } 看下依賴,其實都是非常舊的了,很明顯**維護力度不夠**;同時**對Flutter版本也有限制**,Flutter每出一個版本,58Fair官方就很可能需要做一次適配。 yaml fair_annotation: path: ../annotation fair_version: path: ../flutter_version/flutter_2_5_0

flat_buffers: ^1.12.0 url_launcher: ^5.7.2 http: ^0.12.2 最後怎麼寫JSON配置檔案,肯定自帶一套協議,跟著官方文件Api寫就可以了。熟悉Flutter的同學看下面的示例程式碼應該能秒懂。 JSON { "className": "Center", "na": { "child": { "className": "Container", "na": { "child": { "className": "Text", "pa": [ "巢狀動態元件" ], "na": { "style": { "className": "TextStyle", "na": { "fontSize": 30, "color": "#(Colors.yellow)" } } } }, "alignment": "#(Alignment.center)", "margin": { "className": "EdgeInsets.only", "na": { "top": 30, "bottom": 30 } }, "color": "#(Colors.redAccent)", "width": 300, "height": 300 } } } } ```

利弊分析

  1. Fair的好處:用起來很簡單,效能穩定;
  2. 缺點很明顯:
  3. 用JSON來配置UI,就註定了它是不支援邏輯的
  4. Flutter的widget太多,Fair目前也只能匹配有限的靜態UI
  5. 脫離Dart生態,UI都用JSON寫了......;
  6. 團隊維護力度非常有限,很多外掛都沒有更新,pub也沒有更新。【但其實這是所有Flutter動態化開源框架的通病 😭】

MxFlutter

MxFlutter 同樣也是維護力度有限,目前pub並不是最新版本。GitHub地址也換了,最新的請看https://github.com/Tencent/mxflutter。
MxFlutter通過JavaScript編寫Dart,同樣是通過載入線上js檔案,通過引擎在執行時轉化並顯示,從而達到動態化效果。 官方在0.7.0版本開始接入TypeScript,引入npm生態,優化了js開發的成本,向前端生態進一步靠攏。
很遺憾,在對比各大廠的方案時,發現MxFlutter的價效比極低,學習成本也高,而且拋棄了js生態,又拋棄Dart生態。 image.png 所以針對MxFlutter我只對實現原理做簡單剖析,不進行深入研究。

使用介紹

  • 初始化引擎 Dart String jsBundlePath = await _copyBizBundelZipToMXPath(); if (jsBundlePath != null) { // 啟動 MXFlutter,載入JS庫。 MXJSFlutter.runJSApp(jsAppPath: jsBundlePath); }
  • 通過MXJSPageWidget傳入js指令碼,就能解析出來顯示了。一般使用MxFlutter都是展示一整個使用MXFlutter框架編寫的頁面 Dart Navigator.push( context, MaterialPageRoute( builder: (context) => MXJSPageWidget( jsWidgetName: "mxflutter-ts-demo", flutterPushParams: { "widgetName": "WidgetExamplesPage" }), ), );
  • 再來看看MxJsFlutter的介面定義,可以看到定義了很多協議,這就勢必增加js的學習成本,同時對mxFlutter的引擎依賴度極高。而自己團隊是否有能力hold住這裡面的坑,是要慎重考慮的。 ``` Dart abstract class MXJSFlutter { static MXJSFlutter _instance; static String _localJSAppPath; static String get localJSAppPath => _localJSAppPath;

/// 獲取對外介面類MXJSFlutter。 /// MXFlutter的大部分介面通過MXJSFlutter來呼叫。 static MXJSFlutter getInstance() { if (_instance == null) { _instance = _MXJSFlutter(); } return _instance; }

/// 由Flutter 程式碼啟動JSApp。 可以用在先顯示Flutter頁面,然後Push路由跳轉到JS頁面。 /// 啟動JSApp之後,執行JS程式碼,JS程式碼可以主動呼叫Flutter顯示自己的頁面,也能接受Flutter的指令,顯示對應頁面。 /// /// @param jsAppPath jsApp root path ,JS業務程式碼放置在一個資料夾中。jsAppPath和jsAppAssetsKey根據場景二選一。 /// @param jsAppAssetsKey 使用pubspec.yaml裡的AssetsKey配置來設定jsAppPath,預設為flutter工程下,與lib,ios同級目錄的mxflutter_js_bundle/資料夾下。 /// @param jsExceptionHandler js異常回調。方法引數見 MXJSExceptionHandler 說明。 /// @param debugBizJSPath 目前iOS模擬器下才能使用!!!本地js目錄放置路徑,直接放置xxx/bundle-xxx.js檔案,無需打包成bizBundle.zip。使用該引數後,jsAppPath不生效。 /// @returns Future /// @throws Error if Path error /// static Future runJSApp( {String jsAppPath = '', String jsAppAssetsKey = defaultJSBundleAssetsKey, MXJSExceptionHandler jsExceptionHandler, String debugJSBundlePath = ''}) async { WidgetsFlutterBinding.ensureInitialized(); MXJSFlutter.getInstance();

if(jsAppPath == null || jsAppPath.isEmpty){
  jsAppPath  = await defaultJSAppUpdatePath();
}

// 檢查是否需要拷貝main.zip包。
MXBundleZipManager bundleManager = MXBundleZipManager(jsAppPath: jsAppPath);
MXBundleZipCheckResult result = await bundleManager.checkNeedCopyMainZip();

if (!result.success) {
  MXJSLog.log(
      'MXJSFlutter.runJSApp: checkAppBundleZip failed: ${result.errorMessage}');

  // 引擎初始化的success回撥
  MXJSFlutter.getInstance().jsEngineInitCompletionHandler(
      {'success': result.success, 'errorMessage': result.errorMessage});

  return;
}

// 除錯狀態下,debugJSBundlePath不為空,則執行此目錄下的js檔案。
String realJSAppPath = jsAppPath;
if (debugJSBundlePath != null &&
    debugJSBundlePath.isNotEmpty &&
    await canDefineDebugJSBundlePath()) {
  realJSAppPath = debugJSBundlePath;
}

_localJSAppPath = realJSAppPath;

// 載入main.js。
_callNativeRunJSApp(
    jsAppPath: realJSAppPath, jsExceptionHandler: jsExceptionHandler);

}

/// 預設的 JSBundle 的熱更新目錄,用於放置下載的JS Bundle檔案 static Future defaultJSAppUpdatePath() async { // 如果業務沒有指定目錄,則使用預設目錄 return await Utils.findLocalPath() + Platform.pathSeparator + mxJSAPPDefaultAssetsKey; }

/// 是否允許定義debugJSBundlePath static Future canDefineDebugJSBundlePath() async { // 目前只支援場景:1)除錯環境的iOS模擬器 if (kDebugMode && Platform.isIOS) { DeviceInfoPlugin deviceInfoPlugin = DeviceInfoPlugin(); IosDeviceInfo deviceData = await deviceInfoPlugin.iosInfo; return !deviceData.isPhysicalDevice; } else { return false; } }

static _callNativeRunJSApp( {String jsAppPath = "", MXJSExceptionHandler jsExceptionHandler}) { Map args = {"jsAppPath": jsAppPath};

// 設定JS Exception Handler。
MXPlatformChannel.getInstance().setJSExceptionHandler((arguments) {
  // 如果是main.js的錯誤,arguments['jsFileType'] 為 0 則執行js引擎的success回撥。
  if (arguments is Map && arguments['jsFileType'] == 0) {
    MXJSFlutter.getInstance().jsEngineInitCompletionHandler(
        {'success': false, 'errorMessage': arguments['errorMsg']});
  }

  // 回撥給業務側。
  if (jsExceptionHandler != null) {
    jsExceptionHandler(arguments);
  }
});

// 初始化MXFFICallbackManager。
MXFFICallbackManager.getInstance();

args["flutterAppEnvironmentInfo"] = flutterAppEnvironmentInfo;
MXPlatformChannel.getInstance().invokeMethod("callNativeRunJSApp", args);

}

///從Flutter Push一個 JS寫的頁面 ///@param widgetName: "widgetName",在main.js MyApp::createJSWidgetWithName 函式中使用 ///MyApp::createJSWidgetWithName 通過 widgetName 來建立對應的JSWidget /// 通常你應該使用更高層的API MXJSPageWidget 包裝類來顯示JS Widget 請參考 MXJSPageWidget 的用法 dynamic navigatorPushWithName( String widgetName, Key widgetKey, Map flutterPushParams, {String bizPath});

/// 設定處理器,當JS頁面載入時,定製Loading widget。 void setJSWidgetLoadingHandler(MXWidgetBuildHandler handler);

/// 設定處理器,當JS頁面載入錯誤時,定製Error widget。 void setJSWidgetBuildErrorHandler(MXWidgetBuildHandler handler);

/// JS引擎初始化結束回撥。 void jsEngineInitCompletionHandler(dynamic arguments);

/// JS引擎是否已初始化。 bool isJSEngineInit();

/// 設定JS引擎已初始化。 void setJSEngineInit();

/// JS引擎初始化結果。 Map jsEngineInitResult();

/// 重新建立MXJSFlutter,包括通道,屬性。 void resetup();

/// 當前flutterApp。 MXJSFlutterApp get currentApp; } ```

Karken

Karken是阿里開源的一款基於W3C標準的高效能渲染引擎。也是目前幾個大廠框架內維護力度最高的庫,詳見GitHub。Karken的優勢在於其能夠基於W3C進行開發,而且引入npm生態,支援使用Vue、React框架開發,一般前端人員都能進行開發,學習成本很低。

使用介紹

pubspec引入,然後直接使用Widget Kraken傳入指令碼的url就可以了。 yaml kraken: ^0.9.0 Dart Widget build(BuildContext context) { // 我們只需要維護js指令碼就可以了 Kraken kraken = Kraken( bundleURL: 'http://kraken.oss-cn-hangzhou.aliyuncs.com/demo/guide-styles.js'); return Scaffold( appBar: PreferredSize( preferredSize: Size.fromHeight(40), child: AppBar( centerTitle: true, title: new Text( '商品詳情', style: Theme.of(context).textTheme.headline6, ), ), ), body: kraken, ); } 可以看到,重點在於我們如何去維護帶有動態運營內容的js檔案,這是Karken相對於其他框架最有競爭力的點,官方api寫的非常詳細,基於W3C標準,能夠使用Rax、Vue、React這些主流框架進行開發。 ``` JavaScript /// Vue程式碼

``` Kraken的缺點是不支援css樣式,使Vue開發的體驗也相對一般。但總體而言已經很不錯了,官方維護力度大,滿足前端生態,使用方便,是動態化技術很不錯的選擇。

Webview增強優化

幾乎所有的移動應用中,都會用到webview來作為h5的容器。通過運營平臺配置生成h5,app直接顯示即可。但很遺憾,webview的體驗性、穩定性/相容性有很多的問題。
體驗上載入過程白屏,載入中、出錯狀態沒法定義等;相容性上iOS還好,瀏覽器核心都是WKWebView,但是Android的裝置多種多樣,瀏覽器核心也參差不齊,所以在相容性上經常存在問題。 為了解決以上問題,我們基於官方外掛webview_flutter,做了以下方案: - 體驗上修改webview外掛為可配置透明背景,去除載入條;Flutter層開發webview的增強容器,實現可定義載入中、載入失敗的檢視,達到基本符合app的載入效果 - 穩定性上,我們採取統一植入X5核心的方法

為何採取X5核心?

目前開源的瀏覽器核心sdk不多,主要有以下幾個:ChromeView、Crosswalk、TbsX5。 1. 基於Chromium核心的開源ChromeView 目前基本沒有維護,另一個問題是編譯出來的動態庫太大,ARM-29M,x86-38M,這無疑對app體積來說是個大難題。因此放棄採用基於Chromium的ChromeView。 2. Crosswalk同樣是基於Chromium核心,同樣存在上述app體積問題,因此也放棄。 3. TbsX5 基於谷歌Blink核心,生態在國內是很成熟的,只要裝有微信的手機,都支援X5。X5 提供兩種整合方案: - 只共享微信手Q空間的x5核心(for share) - 獨立下載x5核心(with download)

優化體驗

  • 修改webview_flutter為可配置透明色背景,具體做法請檢視我上一篇文章 # 我該如何給Flutter webview新增透明背景?
    最終業務層程式碼: Dart WebView( initialUrl: 'https://www.baidu.com', transparentBackground: true )
  • 構建webview容器。webview背景處理為透明後,通過Stack佈局,以及監聽onProgress回撥,賦予webview容器載入中、載入失敗的效果,讓使用者的體驗達到與原生應用類似。 Dart /// 我們用的是flutter_bloc進行狀態管理 Stack( alignment: Alignment.center, children: [ WebView( transparentBackground: widget.transparentBackground, onProgress: (int progress) { if (progress >= 100) { context.read<WebViewContainerCubit>().loadSuccess(progress); } }, onWebResourceError: (error) { context.read<WebViewContainerCubit>().loadError(); }, ), if (state is WebViewLoading) Center( child: widget.loadingView ?? const LoadingView(), ), ], ) 再看看bloc層,非常簡單的狀態切換。 ``` Dart /// Cubit class WebViewContainerCubit extends Cubit { WebViewContainerCubit() : super(WebViewLoading());

loadSuccess(int progress) { if (state != WebViewLoadSuccess()) { emit(WebViewLoadSuccess()); } }

loadError() { emit(WebViewLoadError()); } }

/// State abstract class WebViewContainerState {}

class WebViewLoading extends WebViewContainerState {}

class WebViewLoadSuccess extends WebViewContainerState {}

class WebViewLoadError extends WebViewContainerState {} ```

植入X5核心

pub上也有一些webview for x5的輪子,但都是年久失修,沒有持續維護,連null safely都沒有支援。
所以我們繼續拓展webview_flutter庫,新建webview_flutter_android_x5模組,引入X5 SDK,重點對官方的webview_flutter_android相關功能和Api進行替換開發。同時提供Api給業務層,由呼叫方來決定是否啟用x5核心。
植入X5需要一定的原生基礎,這裡不對原始碼進行過多講解,有機會的話後面我直接開源一個庫,把透明背景和x5核心一起弄上去。 目錄結構.png

UI元件庫模板化

這個方式是通過UI設計預埋一些坑位,運營端通過匹配已有元件儲存在介面,每次拉取後臺服務確定如何展示UI。  這個是非常通用的方式,沒有那麼動態化,需要先把可能出現的UI先設計出來。但是最靠譜,不過在開發時需要做好UI庫的封裝、協議的定製,同時要非常注意降級處理,如果網路差拉不到後臺資料,那頁面如何做顯示,這點要處理好。

總結歸納

回看下面這張圖,框架方面毫無疑問Kraken最能應用於生產,但筆者想說的是這些框架都不成熟,想要應用於生產,團隊必須有能參與開源,填坑的能力。 另外順帶提一下,騰訊QQ音樂正在準備開源的Kant也可以關注下,基於Kraken進行改造,已經應用於內部生產,值得期待。 image.png 而webview增強,UI元件模板化則是相對靠譜的方式,一般團隊都有能力維護。這兩種方式,運營平臺的同事就不需要再去學習新的Api,管理後臺的配置動態內容的開發成本也小很多。