天天看點

打通前後端邏輯,用戶端Flutter代碼一天上線一、前沿二、動态方案三、模闆編譯四、渲染引擎五、 效果六、展望參考文獻

作者:閑魚技術-景松

一、前沿

​ 随着閑魚的業務快速增長,營運類的需求也越來越多,其中不乏有很多界面修改或營運坑位的需求。閑魚的版本現在是每2周一個版本,如何快速疊代産品,跳過視窗期來滿足這些需求?另外,閑魚用戶端的包體也變的很大,企業包的大小,iOS已經到了94.3M,Android也到了53.5M。Android的包體大小,相比2016年,已經增長了近1倍,怎麼能将包體大小降下來?首先想到的是如何動态化的解決此類問題。

​ 對于原生的能力的動态化,Android平台各公司都有很完善的動态化方案,甚至Google還提供了Android App Bundles讓開發者們更好地支援動态化。由于Apple官方擔憂動态化的風險,是以并不太支援動态化。是以動态化能力就會考慮跟Web結合,從一開始基于 WebView 的 Hybrid 方案 PhoneGap、Titanium,到現在與原生相結合的 React Native 、Weex。

​ 但Native和JavaScript Context之間的通訊,頻繁的互動就成了程式的性能瓶頸。于此同時随着閑魚Flutter技術的推廣,已經有10多個頁面用Flutter實作,上面提到的幾種方式都不适合Flutter場景,如何解決這個問題Flutter的動态化的問題?

二、動态方案

我們最初調研了Google的動态化方案CodePush。

2.1 CodePush

​ CodePush是谷歌官方推出的動态化方案,目前隻有在Android上面實作了。Dart VM在執行的時候,加載

isolate_snapshot_data

isolate_snapshot_instr

2個檔案,通過動态更改這些檔案,就達到動态更新的目的。官方的Flutter源碼當中,已經有相關的送出來做動态更新的内容,具體内容可以參考

ResourceExtractor.java

​ 根據官方給出的Guide,我們這邊也做了相關的測試,patch的包體大小會很大(939kb)。為了降低包體大小,還可以通過增量的修改snapshot檔案的方式來更新。通過

bsdiff

生成的snapshot的差異檔案,2個檔案分别可以縮小到48kb和870kb。

​ 目前看來,CodePush還不能做到很好的工程化。而且如何管理patch檔案,需要制定baseline和patch檔案的規則。

2.2 動态模闆

​ 動态模闆,就是通過定義一套DSL,在端側解析動态的建立View來實作動态化,比如

LuaViewSDK

Tangram-iOS Tangram-Android

。這些方案都是建立的Native的View,如果想在Flutter裡面實作,需要建立Texture來橋接;Native端渲染完成之後,再将紋理貼在Flutter的容器裡面,實作成本很高,性能也有待商榷,不适合閑魚的場景。

​ 是以我們提出了閑魚自己的Flutter動态化方案,前面已經有同僚介紹過方案的原理:《

做了2個多月的設計和編碼,我梳理了Flutter動态化的方案對比及最佳實作

》,下面看下具體的實作細節。

三、模闆編譯

自定義一套DSL,維護成本較高,怎麼能不自定義DSL來實作模闆下發?閑魚的方案就是直接将Dart檔案轉化成模闆,這樣模闆檔案也可以快速沉澱到端側。

3.1 模闆規範

​ 先來看下一個完整的模闆檔案,以新版我的頁面為例,這個是一個清單結構,每個區塊都是一個獨立的Widget,現在我們期望将“賣在閑魚”這個區塊動态渲染,對這個區塊拆分之後,需要3個子控件:頭部、菜單欄、提示欄;因為這3部分界面有些邏輯處理,是以先把他們的邏輯内置。

打通前後端邏輯,用戶端Flutter代碼一天上線一、前沿二、動态方案三、模闆編譯四、渲染引擎五、 效果六、展望參考文獻

内置的子控件分别是

MenuTitleWidget

MenuItemWidget

HintItemWidget

,編寫的模闆如下:

@override
Widget build(BuildContext context) {
    return new Container(
        child: new Column(
            children: <Widget>[
                new MenuTitleWidget(data),    // 頭部
                new Column(    // 菜單欄
                    children: <Widget>[
                        new Row(
                            children: <Widget>[
                                new MenuItemWidget(data.menus[0]),
                                new MenuItemWidget(data.menus[1]),
                                new MenuItemWidget(data.menus[2]),
                            ],
                        )
                    ],
                ),
                new Container(    // 提示欄
                    child: new HintItemWidget(data.hints[0])),
            ],
        ),
    );
}           

中間省略了樣式描述,可以看到寫模闆檔案就跟普通的widget寫法一樣,但是有幾點要注意:

  1. 每個Widget都需要用

    new

    const

    來修飾
  2. 資料通路以

    data

    開頭,數組形式以

    []

    通路,字典形式以

    .

    通路

​ 模闆寫好之後,就要考慮怎麼在端上渲染,早期版本是直接在端側解析檔案,但是考慮到性能和穩定性,還是放在前期先編譯好,然後下發到端側。

3.2 編譯流程

​ 編譯模闆就要用到Dart的

Analyzer

庫,通過

parseCompilationUnit

函數直接将Dart源碼解析成為以

CompilationUnit

為Root節點的AST樹中,它包含了Dart源檔案的文法和語義資訊。接下來的目标就是将

CompilationUnit

轉換成為一個JSON格式。

打通前後端邏輯,用戶端Flutter代碼一天上線一、前沿二、動态方案三、模闆編譯四、渲染引擎五、 效果六、展望參考文獻

​ 上面的模闆解析出來build函數孩子節點是

ReturnStatementImpl

,它又包含了一個子節點

InstanceCreationExpressionImpl

,對應模闆裡面的

new Container(…)

,它的孩子節點中,我們最關心的就是

ConstructorNameImpl

ArgumentListImpl

節點。

ConstructorNameImpl

辨別建立節點的名稱,

ArgumentListImpl

辨別建立參數,參數包含了參數清單和變量參數。

定義如下結構體,來存儲這些資訊:

class ConstructorNode {
    // 建立節點的名稱
    String constructorName;
    // 參數清單
    List<dynamic> argumentsList = <dynamic>[];
    // 變量參數
    Map<String, dynamic> arguments = <String, dynamic>{};
}           

遞歸周遊整棵樹,就可以得到一個

ConstructorNode

樹,以下代碼是解析單個Node的參數:

ArgumentList argumentList = astNode;

for (Expression exp in argumentList.arguments) {
    if (exp is NamedExpression) {
        NamedExpression namedExp = exp;
        final String name = ASTUtils.getNodeString(namedExp.name);
        if (name == 'children') {
            continue;
        }

        /// 是函數
        if (namedExp.expression is FunctionExpression) {
            currentNode.arguments[name] =
                FunctionExpressionParser.parse(namedExp.expression);
        } else {
            /// 不是函數
            currentNode.arguments[name] =
                ASTUtils.getNodeString(namedExp.expression);
        }
    } else if (exp is PropertyAccess) {
        PropertyAccess propertyAccess = exp;
        final String name = ASTUtils.getNodeString(propertyAccess);
        currentNode.argumentsList.add(name);
    } else if (exp is StringInterpolation) {
        StringInterpolation stringInterpolation = exp;
        final String name = ASTUtils.getNodeString(stringInterpolation);
        currentNode.argumentsList.add(name);
    } else if (exp is IntegerLiteral) {
        final IntegerLiteral integerLiteral = exp;
        currentNode.argumentsList.add(integerLiteral.value);
    } else {
        final String name = ASTUtils.getNodeString(exp);
        currentNode.argumentsList.add(name);
    }
}           

端側拿到這個

ConstructorNode

節點樹之後,就可以根據Widget的名稱和參數,來生成一棵Widget樹。

四、渲染引擎

端側拿到編譯好的模闆JSON後,就是解析模闆并建立Widget。先看下,整個工程的架構和工作流:

打通前後端邏輯,用戶端Flutter代碼一天上線一、前沿二、動态方案三、模闆編譯四、渲染引擎五、 效果六、展望參考文獻

工作流程:

  1. 開發人員編寫dart檔案,編譯上傳到CDN
  2. 端側拿到模闆清單,并在端側存庫
  3. 業務方直接下發對應的模闆id和模闆資料
  4. Flutter側再通過橋接擷取到模闆,并建立Widget樹

對于Native測,主要負責模闆的管理,通過橋接輸出到Flutter側。

4.1 模闆擷取

模闆擷取分為2部分,Native部分和Flutter部分;Native主要負責模闆的管理,包括下載下傳、降級、緩存等。

打通前後端邏輯,用戶端Flutter代碼一天上線一、前沿二、動态方案三、模闆編譯四、渲染引擎五、 效果六、展望參考文獻

程式啟動的時候,會先擷取模闆清單,業務方需要自己實作,Native層擷取到模闆清單會先存儲在本地資料庫中。Flutter側業務代碼用到模闆的時候,再通過橋接擷取模闆資訊,就是我們前面提到的JSON格式的資訊,Flutter也會有緩存,已減少Flutter和Native的互動。

4.2 Widget建立

Flutter側當拿到JSON格式的,先解析出

ConstructorNode

樹,然後遞歸建立Widget。

打通前後端邏輯,用戶端Flutter代碼一天上線一、前沿二、動态方案三、模闆編譯四、渲染引擎五、 效果六、展望參考文獻

建立每個Widget的過程,就是解析節點中的

argumentsList

arguments

并做資料綁定。例如,建立

HintItemWidget

需要傳入提示的資料内容,

new HintItemWidget(data.hints[0])

,在解析

argumentsList

時,會通過key-path的方式從原始資料中解析出特定的值。

打通前後端邏輯,用戶端Flutter代碼一天上線一、前沿二、動态方案三、模闆編譯四、渲染引擎五、 效果六、展望參考文獻

解析出來的值都會存儲在

WidgetCreateParam

裡面,當遞歸周遊每個建立節點,每個widget都可以從

WidgetCreateParam

裡面解析出需要的參數。

/// 建構widget用的參數
class WidgetCreateParam {
  String constructorName;    /// 建構的名稱
  dynamic context;    /// 建構的上下文
  Map<String, dynamic> arguments = <String, dynamic>{}; /// 字典參數
  List<dynamic> argumentsList = <dynamic>[]; /// 清單參數
  dynamic data; /// 原始資料
}           

​ 通過以上的邏輯,就可以将

ConstructorNode

樹轉換為一棵

Widget

樹,再交給Flutter Framework去渲染。

至此,我們已經能将模闆解析出來,并渲染到界面上,互動事件應該怎麼處理?

4.3 事件處理

在寫互動的時候,一般都會通過

GestureDector

InkWell

等來處理點選事件。互動事件怎麼做動态化?

​ 以

InkWell

元件為例,定義它的

onTap

函數為

openURL(data.hints[0].href, data.hints[0].params)

。在建立

InkWell

時,會以

OpenURL

作為事件ID,查找對應的處理函數,當使用者點選的時候,會解析出對應的參數清單并傳遞過去,代碼如下:

...
final List<dynamic> tList = <dynamic>[];
// 解析出參數清單
exp.argumentsList.forEach((dynamic arg) {
    if (arg is String) {
        final dynamic value = valueFromPath(arg, param.data);
        if (value != null) {
            tList.add(value);
        } else {
            tList.add(arg);
        }
    } else {
        tList.add(arg);
    }
});

// 找到對應的處理函數
final dynamic handler =
    TeslaEventManager.sharedInstance().eventHandler(exp.actionName);
if (handler != null) {
    handler(tList);
}
...           

五、 效果

新版我的頁面添加了動态化渲染能力之後,如果有需求新添加一種元件類型,就可以直接編譯釋出模闆,服務端下發新的資料内容,就可以渲染出來了;動态化能力有了,大家會關心渲染性能怎麼樣。

5.1 幀率

在加了動态加載邏輯之後,已經開放了2個動态卡片,下圖是新版本我的頁面近半個月的的幀率資料:

打通前後端邏輯,用戶端Flutter代碼一天上線一、前沿二、動态方案三、模闆編譯四、渲染引擎五、 效果六、展望參考文獻

從上圖可以看到,幀率并沒有降低,基本保持在55-60幀左右,後續可以多添加動态的卡片,觀察下效果。

注:因為我的頁面會有本地的一些業務判斷,從其他頁面回到我的tab,都會重新整理界面,是以幀率會有損耗。

​ 從實作上分析,因為每個卡片,都需要周遊

ConstructorNode

樹來建立,而且每個建構都需要解析出裡面的參數,這塊可以做一些優化,比如緩存相同的Widget,隻需要映射出資料内容并做資料綁定。

5.2 失敗率

現在監控了渲染的邏輯,如果本地沒有對應的Widget建立函數,會主動抛Error。監控資料顯示,渲染的流程中,還沒有異常的情況,後續還需要對橋接層和native層加錯誤埋點。

六、展望

​ 基于Flutter動态模闆,之前需要走發版的Flutter需求,都可以來動态化更改。而且以上邏輯都是基于Flutter原生的體系,學習和維護成本都很低,動态的代碼也可以快速的沉澱到端側。

​ 另外,閑魚正在研究UI2Code的黑科技,不了解的老鐵,可以參考閑魚大神的這篇文章《

重磅系列文章!UI2CODE智能生成Flutter代碼——整體設計篇

》。可以設想下,如果有個需求,需要動态的顯示一個元件,UED出了視覺稿,通過UI2Code轉換成Dart檔案,再通過這個系統轉換成動态模闆,下發到端側就可以直接渲染出來,程式員都不需要寫代碼了,做到自動化營運,看來以後程式員失業也不是沒有可能了。

​ 基于Flutter的Widget,還可以拓展更多個性化的元件,比如内置動畫元件,就可以動态化下發動畫了,更多好玩的東西等待大家來一起探索。

參考文獻

  1. https://github.com/flutter/flutter/issues/14330
  2. https://www.dartlang.org/
  3. https://mp.weixin.qq.com/s/4s6MaiuW4VoHr_7f0S_vuQ
  4. https://github.com/flutter/engine