Flutter實現動態化更新-技術預研
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的動態化生態,目前市面上並沒有一個成熟的開源框架,只有國內各大網際網路公司陸續開源,但也都處在急需維護的狀態。當前主流框架有:
- 騰訊開源的 MXFlutter
- 58同城開源的 Flutter Fair
-
阿里巴巴開源的 北海Karken 同時我還會介紹另外兩種比較通用的方式:
-
webview增強
【植入騰訊 X5核心,模型升級改造】 - UI庫模板化
各大廠動態化架構對比
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
/// 重點檢視以http開頭的解析方法,用的是http庫拉取 Future
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
}
}
}
}
```
利弊分析
- Fair的好處:用起來很簡單,效能穩定;
- 缺點很明顯:
- 用JSON來配置UI,就註定了它是不支援邏輯的;
- Flutter的widget太多,Fair目前也只能匹配有限的靜態UI;
- 脫離Dart生態,UI都用JSON寫了......;
- 團隊維護力度非常有限,很多外掛都沒有更新,pub也沒有更新。【但其實這是所有Flutter動態化開源框架的通病 😭】
MxFlutter
MxFlutter 同樣也是維護力度有限,目前pub並不是最新版本。GitHub地址也換了,最新的請看https://github.com/Tencent/mxflutter。
MxFlutter通過JavaScript編寫Dart,同樣是通過載入線上js檔案,通過引擎在執行時轉化並顯示,從而達到動態化效果。 官方在0.7.0版本開始接入TypeScript,引入npm生態,優化了js開發的成本,向前端生態進一步靠攏。
很遺憾,在對比各大廠的方案時,發現MxFlutter的價效比極低,學習成本也高,而且拋棄了js生態,又拋棄Dart生態。
所以針對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
/// 是否允許定義debugJSBundlePath
static Future
static _callNativeRunJSApp(
{String jsAppPath = "", MXJSExceptionHandler jsExceptionHandler}) {
Map
// 設定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
/// 重新建立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核心一起弄上去。
UI元件庫模板化
這個方式是通過UI設計預埋一些坑位,運營端通過匹配已有元件儲存在介面,每次拉取後臺服務確定如何展示UI。 這個是非常通用的方式,沒有那麼動態化,需要先把可能出現的UI先設計出來。但是最靠譜,不過在開發時需要做好UI庫的封裝、協議的定製,同時要非常注意降級處理,如果網路差拉不到後臺資料,那頁面如何做顯示,這點要處理好。
總結歸納
回看下面這張圖,框架方面毫無疑問Kraken最能應用於生產,但筆者想說的是這些框架都不成熟,想要應用於生產,團隊必須有能參與開源,填坑的能力。 另外順帶提一下,騰訊QQ音樂正在準備開源的Kant也可以關注下,基於Kraken進行改造,已經應用於內部生產,值得期待。
而webview增強,UI元件模板化則是相對靠譜的方式,一般團隊都有能力維護。這兩種方式,運營平臺的同事就不需要再去學習新的Api,管理後臺的配置動態內容的開發成本也小很多。