一、前沿
随着闲鱼的业务快速增长,运营类的需求也越来越多,其中不乏有很多界面修改或运营坑位的需求。闲鱼的版本现在是每 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
本文作者:闲鱼技术 - 景松阅读原文
本文为云栖社区原创内容,未经允许不得转载。