乐趣区

打通前后端逻辑,客户端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 部分界面有些逻辑处理,所以先把他们的逻辑内置。

内置的子控件分别是 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 写法一样,但是有几点要注意:

每个 Widget 都需要用 new 或 const 来修饰
数据访问以 data 开头,数组形式以 [] 访问,字典形式以. 访问

​ 模板写好之后,就要考虑怎么在端上渲染,早期版本是直接在端侧解析文件,但是考虑到性能和稳定性,还是放在前期先编译好,然后下发到端侧。
3.2 编译流程
​ 编译模板就要用到 Dart 的 Analyzer 库,通过 parseCompilationUnit 函数直接将 Dart 源码解析成为以 CompilationUnit 为 Root 节点的 AST 树中,它包含了 Dart 源文件的语法和语义信息。接下来的目标就是将 CompilationUnit 转换成为一个 JSON 格式。

​ 上面的模板解析出来 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。先看下,整个工程的框架和工作流:

工作流程:

开发人员编写 dart 文件,编译上传到 CDN
端侧拿到模板列表,并在端侧存库
业务方直接下发对应的模板 id 和模板数据
Flutter 侧再通过桥接获取到模板,并创建 Widget 树

对于 Native 测,主要负责模板的管理,通过桥接输出到 Flutter 侧。
4.1 模板获取
模板获取分为 2 部分,Native 部分和 Flutter 部分;Native 主要负责模板的管理,包括下载、降级、缓存等。

程序启动的时候,会先获取模板列表,业务方需要自己实现,Native 层获取到模板列表会先存储在本地数据库中。Flutter 侧业务代码用到模板的时候,再通过桥接获取模板信息,就是我们前面提到的 JSON 格式的信息,Flutter 也会有缓存,已减少 Flutter 和 Native 的交互。
4.2 Widget 创建
Flutter 侧当拿到 JSON 格式的,先解析出 ConstructorNode 树,然后递归创建 Widget。

创建每个 Widget 的过程,就是解析节点中的 argumentsList 和 arguments 并做数据绑定。例如,创建 HintItemWidget 需要传入提示的数据内容,new HintItemWidget(data.hints[0]),在解析 argumentsList 时,会通过 key-path 的方式从原始数据中解析出特定的值。

解析出来的值都会存储在 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 个动态卡片,下图是新版本我的页面近半个月的的帧率数据:

从上图可以看到,帧率并没有降低,基本保持在 55-60 帧左右,后续可以多添加动态的卡片,观察下效果。
注:因为我的页面会有本地的一些业务判断,从其他页面回到我的 tab,都会刷新界面,所以帧率会有损耗。
​ 从实现上分析,因为每个卡片,都需要遍历 ConstructorNode 树来创建,而且每个构建都需要解析出里面的参数,这块可以做一些优化,比如缓存相同的 Widget,只需要映射出数据内容并做数据绑定。
5.2 失败率
现在监控了渲染的逻辑,如果本地没有对应的 Widget 创建函数,会主动抛 Error。监控数据显示,渲染的流程中,还没有异常的情况,后续还需要对桥接层和 native 层加错误埋点。
六、展望
​ 基于 Flutter 动态模板,之前需要走发版的 Flutter 需求,都可以来动态化更改。而且以上逻辑都是基于 Flutter 原生的体系,学习和维护成本都很低,动态的代码也可以快速的沉淀到端侧。
​ 另外,闲鱼正在研究 UI2Code 的黑科技,不了解的老铁,可以参考闲鱼大神的这篇文章《重磅系列文章!UI2CODE 智能生成 Flutter 代码——整体设计篇》。可以设想下,如果有个需求,需要动态的显示一个组件,UED 出了视觉稿,通过 UI2Code 转换成 Dart 文件,再通过这个系统转换成动态模板,下发到端侧就可以直接渲染出来,程序员都不需要写代码了,做到自动化运营,看来以后程序员失业也不是没有可能了。
​ 基于 Flutter 的 Widget,还可以拓展更多个性化的组件,比如内置动画组件,就可以动态化下发动画了,更多好玩的东西等待大家来一起探索。
参考文献

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

本文作者:闲鱼技术 - 景松阅读原文
本文为云栖社区原创内容,未经允许不得转载。

退出移动版