打通先后端逻辑,客户端Flutter代码一天上线

1、前沿

​ 随着闲鱼的业务快速增加,运营类的需求也愈来愈多,其中不乏有不少界面修改或运营坑位的需求。闲鱼的版本如今是每2周一个版本,如何快速迭代产品,跳过窗口期来知足这些需求?另外,闲鱼客户端的包体也变的很大,企业包的大小,iOS已经到了94.3M,Android也到了53.5M。Android的包体大小,相比2016年,已经增加了近1倍,怎么能将包体大小降下来?首先想到的是如何动态化的解决此类问题。java

​ 对于原生的能力的动态化,Android平台各公司都有很完善的动态化方案,甚至Google还提供了Android App Bundles让开发者们更好地支持动态化。因为Apple官方担心动态化的风险,所以并不太支持动态化。所以动态化能力就会考虑跟Web结合,从一开始基于 WebView 的 Hybrid 方案 PhoneGap、Titanium,到如今与原生相结合的 React Native 、Weex。android

​ 但Native和JavaScript Context之间的通信,频繁的交互就成了程序的性能瓶颈。于此同时随着闲鱼Flutter技术的推广,已经有10多个页面用Flutter实现,上面提到的几种方式都不适合Flutter场景,如何解决这个问题Flutter的动态化的问题?git

2、动态方案

咱们最初调研了Google的动态化方案CodePush。程序员

2.1 CodePush

​ CodePush是谷歌官方推出的动态化方案,目前只有在Android上面实现了。Dart VM在执行的时候,加载isolate_snapshot_data 和isolate_snapshot_instr 2个文件,经过动态更改这些文件,就达到动态更新的目的。官方的Flutter源码当中,已经有相关的提交来作动态更新的内容,具体内容能够参考 ResourceExtractor.javagithub

​ 根据官方给出的Guide,咱们这边也作了相关的测试,patch的包体大小会很大(939kb)。为了下降包体大小,还能够经过增量的修改snapshot文件的方式来更新。经过bsdiff生成的snapshot的差别文件,2个文件分别能够缩小到48kb和870kb。shell

​ 目前看来,CodePush还不能作到很好的工程化。并且如何管理patch文件,须要制定baseline和patch文件的规则。数据库

2.2 动态模板

​ 动态模板,就是经过定义一套DSL,在端侧解析动态的建立View来实现动态化,好比LuaViewSDKTangram-iOSTangram-Android。这些方案都是建立的Native的View,若是想在Flutter里面实现,须要建立Texture来桥接;Native端渲染完成以后,再将纹理贴在Flutter的容器里面,实现成本很高,性能也有待商榷,不适合闲鱼的场景。express

​ 因此咱们提出了闲鱼本身的Flutter动态化方案,前面已经有同事介绍过方案的原理:《作了2个多月的设计和编码,我梳理了Flutter动态化的方案对比及最佳实现》,下面看下具体的实现细节。数组

3、模板编译

自定义一套DSL,维护成本较高,怎么能不自定义DSL来实现模板下发?闲鱼的方案就是直接将Dart文件转化成模板,这样模板文件也能够快速沉淀到端侧。缓存

3.1 模板规范

​ 先来看下一个完整的模板文件,以新版个人页面为例,这个是一个列表结构,每一个区块都是一个独立的Widget,如今咱们指望将“卖在闲鱼”这个区块动态渲染,对这个区块拆分以后,须要3个子控件:头部、菜单栏、提示栏;由于这3部分界面有些逻辑处理,因此先把他们的逻辑内置。

内置的子控件分别是MenuTitleWidgetMenuItemWidgetHintItemWidget,编写的模板以下:

@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都须要用newconst来修饰
  2. 数据访问以data开头,数组形式以[]访问,字典形式以.访问

​ 模板写好以后,就要考虑怎么在端上渲染,早期版本是直接在端侧解析文件,可是考虑到性能和稳定性,仍是放在前期先编译好,而后下发到端侧。

3.2 编译流程

​ 编译模板就要用到Dart的Analyzer库,经过parseCompilationUnit函数直接将Dart源码解析成为以CompilationUnit为Root节点的AST树中,它包含了Dart源文件的语法和语义信息。接下来的目标就是将CompilationUnit转换成为一个JSON格式。

​ 上面的模板解析出来build函数孩子节点是ReturnStatementImpl,它又包含了一个子节点InstanceCreationExpressionImpl,对应模板里面的new Container(…),它的孩子节点中,咱们最关心的就是ConstructorNameImplArgumentListImpl节点。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树。

4、渲染引擎

端侧拿到编译好的模板JSON后,就是解析模板并建立Widget。先看下,整个工程的框架和工做流:

工做流程:

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

对于Native测,主要负责模板的管理,经过桥接输出到Flutter侧。

4.1 模板获取

模板获取分为2部分,Native部分和Flutter部分;Native主要负责模板的管理,包括下载、降级、缓存等。

程序启动的时候,会先获取模板列表,业务方须要本身实现,Native层获取到模板列表会先存储在本地数据库中。Flutter侧业务代码用到模板的时候,再经过桥接获取模板信息,就是咱们前面提到的JSON格式的信息,Flutter也会有缓存,已减小Flutter和Native的交互。

4.2 Widget建立

Flutter侧当拿到JSON格式的,先解析出ConstructorNode树,而后递归建立Widget。

建立每一个Widget的过程,就是解析节点中的argumentsListarguments 并作数据绑定。例如,建立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 事件处理

在写交互的时候,通常都会经过GestureDectorInkWell等来处理点击事件。交互事件怎么作动态化?

​ 以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、 效果

新版个人页面添加了动态化渲染能力以后,若是有需求新添加一种组件类型,就能够直接编译发布模板,服务端下发新的数据内容,就能够渲染出来了;动态化能力有了,你们会关心渲染性能怎么样。

5.1 帧率

在加了动态加载逻辑以后,已经开放了2个动态卡片,下图是新版本个人页面近半个月的的帧率数据:

从上图能够看到,帧率并无下降,基本保持在55-60帧左右,后续能够多添加动态的卡片,观察下效果。

注:由于个人页面会有本地的一些业务判断,从其余页面回到个人tab,都会刷新界面,因此帧率会有损耗。

​ 从实现上分析,由于每一个卡片,都须要遍历ConstructorNode树来建立,并且每一个构建都须要解析出里面的参数,这块能够作一些优化,好比缓存相同的Widget,只须要映射出数据内容并作数据绑定。

5.2 失败率

如今监控了渲染的逻辑,若是本地没有对应的Widget建立函数,会主动抛Error。监控数据显示,渲染的流程中,尚未异常的状况,后续还须要对桥接层和native层加错误埋点。

6、展望

​ 基于Flutter动态模板,以前须要走发版的Flutter需求,均可以来动态化更改。并且以上逻辑都是基于Flutter原生的体系,学习和维护成本都很低,动态的代码也能够快速的沉淀到端侧。

​ 另外,闲鱼正在研究UI2Code的黑科技,不了解的老铁,能够参考闲鱼大神的这篇文章《重磅系列文章!UI2CODE智能生成Flutter代码——总体设计篇》。能够设想下,若是有个需求,须要动态的显示一个组件,UED出了视觉稿,经过UI2Code转换成Dart文件,再经过这个系统转换成动态模板,下发到端侧就能够直接渲染出来,程序员都不须要写代码了,作到自动化运营,看来之后程序员失业也不是没有可能了。

​ 基于Flutter的Widget,还能够拓展更多个性化的组件,好比内置动画组件,就能够动态化下发动画了,更多好玩的东西等待你们来一块儿探索。


原文连接 本文为云栖社区原创内容,未经容许不得转载。

相关文章
相关标签/搜索