万字长文,请耐心看完!美团外卖Flutter动态化实践,5w+人已阅

那 Flap 的 DSL 具体是什么?对于开发者而言,那这个 DSL 就是 Dart Code。而对于机器或 App 而言,那这个 DSL 就是 JSON。

前面的技术选型中提到:

利用 Dart-lang 官方提供了 Analyzer 分析库,官方的 Analyzer 的能力可以拿来直接用,该库提供了一组 API 能对 Dart source 进行分析,按照文件粒度生成 AST 对象,该数据结构包含了 input 的 Dart 文件的所有信息。

我们的 DSL 的基本原理就是对 AST 内数据的一个描述, 并附带一些其他操作。

图2 DSL-JSON 的转换步骤

因为用 Analyzer 的 API 跑出的 AST 也叫 CompilationUnit,实际上是一个编译单元,里面还存有很多编译相关的属性例如 lineInfo、beginToken 等。但使用 DSL 的方式不依赖编译,所以很多不需要的属性会被裁剪或忽略。

在转换器入口会对大类(identifier、statementImpl、literal、methodInvocation 等等)进行分发,每一个大类的数据结构使用一种中间结构 Dart model 来传输,然后对于大类中细分的类型(IfStatement、AssignmentStatement、DoStatement、SwitchStatement 等等),配有足够细粒度的转换接口,以 AST 结构作为输入,以 Map 节点作为输出。最终定义并提炼了 10 种标准的 Map 结构(class、method、variable、stmt 等等)来承载所有类型。

举个例子

一个简单的 Widget 节点经过转换后得到这样的 DSL-JSON,可以看到 DSL 的可读性还是 OK 的(默认下发时产物是一个压缩成单行并加密的二进制文件,这里是解密后 Format 换行后展示的)。我们在转换中会区分普通的字符串、变量名引用、系统枚举等类型,加以不同的符号表示。

图3 常规 Widget 组件的源码与 DSL 示例

关于逻辑

举一个简单的四则运算的例子,可以看出在对于“乘法应当先计算”这个规则上,我们的 DSL 能够自动遵循, 其中的奥秘是 Analyzer 帮我们做了这种运算优先级的判断,归根结底还是一种描述 AST 的工作,我们自己不会去根据静态代码做分析过程。

图4 简单逻辑的代码与 DSL 示例

关于语法糖

语法糖往往画风清奇,结构与众不同,但是在 AST 中还是很诚实的,该什么结构就是什么结构。所以语法糖应该在转换器侧进行展开为常规结构再转 DSL,而不是对特殊格式设置特殊的 DSL 传到运行时再去解析。

图5 部分语法糖的展开情况

这里只举了一些简单的例子,只是 DSL 体系中的一个片段,实际在项目落地时有很多较为复杂的逻辑,类似于循环套循环内进行集合操作或是异步回调内加多重三目逻辑等等。这里因为篇幅原因和涉及到业务代码相关就不展开详细的介绍了,其中的原理是一样的,都是描述 AST 的过程中增加一些特殊处理,最终会将转换产物的 Map 节点根据原有 AST 的层级结构组装起来,再通过 JSONEncode 转为 JSON。

图6 DSL 内部结构层级

转换器侧能够完整的描述一个 Dart 文件的所有信息,如图 6 所示。值得一提的是,不同的节点还可能出现任意结构,method 里的 Argument 里可能是一个全局变量,条件表达式的右边又可能是一个方法。对于这种相同的结构即使出现在不同的位置也应当使用一套处理逻辑来转换,因此转换器是以迭代为主加小范围递归的设计思路。

将细粒度转换接口按照具体类别分在不同文件中(statement_factory、class_factory、function_factory) 等待解析生产总线的调用。实际操作中各个类之间是近似于网状的调用,因此所有调用应当都是 Static 的,并且内部隔离,不引用不修改外部变量,做到无副作用。

DSL 转换器是一个命令行程序,因此可以无缝的部署到自动化的机器上。新代码合入主干后, 接下来的 Bundle 生成与分发逻辑都可以使用各种图形化界面的发布系统来操作。

3.2 运行时原理

Prepare & Running

运行时相关的操作是在 App 内发生的,包括初始化,拉取 DSL,解析与使用。 简言之可以分为 Prepare 和 Running 两个阶段。Prepare 是准备各种运行时所需的符号,包括系统类符号与自定义符号,属性符号与方法符号(这里所说的符号实际就是 Dart 内的对象)。Prepare 阶段完成才能进行后续的 Running 相关操作,具体是页面的构建,事件的绑定,交互与逻辑的正常运转。

图7 运行时原理的两大阶段

万能方法 Function.apply()

Flutter 期望线上产品是编译后的“完全体现”,同时为了避免生成过大的包,并不支持 Dart:Mirror。“Flutter apps are pre-compiled for production, and binary size is always a concern with mobile apps, we disabled dart:mirrors.”那么,在这种前提下,如何将外部符号转内部符号?Function() 对象提供了这样一个万能方法。

// function.dart

external static apply(Function function, List positionalArguments,

[Map<Symbol, dynamic> namedArguments]);

第一个参数是 Function 类型,后两个参数是该函数所需的参数(位置参数与命名参数,这两者在 DSL 中都可以取到),因此只要能获取到某个 Function,那就能在任何时候调用它。

此 Function 若为 Constructor Function 那返回值则为构造出的对象类型。

Proxy-Mirror

DSL 后只能得到字符串的标识,因此需要建立一个 String 与 Function 的映射关系,考虑到类名方法名,数据结构应该是 {String:{String:Function}},通过 className 和 functionName 两个 String Key 即可取得一一对应的 Function(),下面给出一个系统类的类方法(构造方法)的代码片段:

{

‘EdgeInsets’:

{

‘fromLTRB’: (left, top, right, bottom) => EdgeInsets.fromLTRB(left, top, right, bottom),

// …other function

},

// …other class

};

然后对于系统类的实例方法、getter、setter 则需要在外部多传一个 instance 参数,instance 是外部通过该类的构造方法的 func 创建后传入。

// instance method

“inflateSize”: (instance, size) => instance.inflateSize(size),

// getter

“horizontal”: (instance) => instance.horizontal,

// setter

“last”: (List instance, dynamic value) => instance.last = value,

Custom Class’s meta

对于自定义类,我们需要构建一个模拟的元类系统,存放所有符号信息,在解析时将所有的 JSON 节点转成可处理的对象。所有的属性声明都会构建成 FlapVariable 类型,所有的方法声明都会构建成 FlapFunction 类型。

如图 8 所示,父类和元类也是有相应的指针,父类的成员变量也会填充到子类,并且通过 mixin 的方式将类相关属性注入到派生类类型,例如 FlapState,FlapState 继承自 state,这样既可以让系统类的生命周期方法留个调用链的开口,也可以使用注入的运行时类属性。

image

图8 运行时模拟的元类系统

Evaluate

如下面代码的例子,一个 if 语句的 JSON 节点下发后,经过 parser 之后会得到一个 IfStatement 对象,这类对象都有一个特点就是包含几个属性,和一个运行时入口方法 evaluate(Scope scope)。这个方法在抽象类 Evaluative 类中,所有语句和表达式的类都会继承于此,自动获得 evaluate 方法,其中属性部分是在解析过程中解析成 Dart 对象后通过构造方法的参数传入的。

class IfStatement extends Statement {

dynamic condition = undefined;

Body thenBody;

Body elseBody;

IfStatement(this.condition, this.thenBody, [this.elseBody]);

// 简化版代码

ProcessResult evaluate(Scope scope) {

bool conditionValue = condition.evaluate(scope)

if (conditionValue){

return thenBody(Scope);

}else{

return elseBody(Scope);

}

}

}

属性中的条件对象与语句对象在解析的过程中并不会被触发, 真正的触发是方法被调用时从运行时的入口方法 evaluate 进入,此时才会通过作用域 Scope 判定条件是 true or false,然后调用到其他需要 evaluate 的 Dart 对象,如下图 9 所示:

图9 运行时 evaluate 触发链路

经过表达式的堆叠,实现了语句,经过语句的堆叠实现了 body,再补充上形参和返回值,则就构成了我们运行时中的自定义方法 FlapFunction。这里要用到一下仿真函数的概念,FlapFunction 要实现 call 方法,这样在外部调用时就真的和 Function 画风一致了。

动态化页面运行时,Flap 会维持一套作用域体系。Scope 的结构相当于双向链表,每一个 Scope 有 outer 和 inner 两个指针。全局作用域的 outer 为 null,inner 为类作用域;类作用域的 inner 为局部作用域;局部作用域的 inner 可能为 null 也可能又是一个局部作用域;随便哪一个作用域顺着 outer 一直往上找,肯定能找到全局作用域。

Scope

Scope 在逻辑的执行中实际就是充当了 Context 上下文的作用,因为每个方法或表达式被 evalute 时需要一个 Scope 入参,这个 Scope 是从外部传入的,并且这一行语句对象执行后 Scope 还会作为入参传给下一行语句。比如第一行语句声明了一个 “code” 的变量,第二行语句对这个 “code” 进行修改,则需要先通过引用从 Scope 中取出这个 “code” 的值,不但可以从 Scope 中取出声明的属性,也可以取出声明过的方法,方法内也是可以调用方法的。这也就解释了为什么我们可以处理自定义方法中的逻辑。

图10 Scope的寻找与构建

图 10 描述了 Scope 在实际运用中的两种场景。左半部分是点击按钮触发 onTap 回调,需要找到 confirm 方法,此时会先从局部作用域的方法列表里找,没找到,则会 outer 一层去类作用域里寻找,此时找到了该方法的实现。

右半部分展示了执行该方法的 body 时是需要传入的 Scope 是如何构建的。先从符号大本营中获取全局变量、全局属性构成全局作用域,再从此类的元类中取出属性和方法构成类作用域,再构建局部作用域,当然参数也是会放到局部作用域里的,以此构建了完整的 Scope 传入 body 的 evaluate 方法支撑后面的逻辑执行。

3.3 遇到的挑战

工作量大,需要长期有耐心

首先解释下,这里的工作量大并不是指系统方法映射等这种体力活的工作量大,这些我们都是有自动生成且按需生成的(生态部分会提到)。我们所说的工作量大,主要是指涵盖转换器、运行时的研发以及生态相关建设等,我们要尽可能的满足所有的 Dart 语法才能让业务代码能够低成本的转换,并且有众多的脚本与工具支撑。

项目复杂,需要设计合理的架构以支撑扩展

在项目的分模块开发中,各个模块(parser、intermediate、runtime 等等)严格遵守单一职责原则与最小知道原则,最大化的杜绝了模块间耦合,模块与模块的通信由一些标准的数据结构进行(map 或继承自 ASTNode 的结构)。 这就使得任何一个模块出现重大重构时不会影响到其他模块,其中底层核心的几个类的单侧覆盖率接近100%,有专人负责优化。并且在项目中随处可以抽象类、接口类、mixin 类等,这也就使得随着支持的能力越来越复杂时,项目的可读性不会成反比,代码不会变“恶心”,而是以整齐的方式扩张,文件多而不乱。

疑难杂症较多,对问题保持足够的信心

有时候会遇到一些诸如静态方法调用构造方法时作用域被覆盖、循环语句嵌套时内侧 continue 之后外侧语句也会跟着停、某方法参数的 Function 取完引用之后 Function 也跟着执行了等等的 Bug,解 Bug 是开发中必不可少的一部分,有时候加个 if else 用 easy way 可以很快解决,但我们不会那么做,探索优雅 Right Way 的乐趣是研发过程中的一个重要组成部分。

相比于草草了事之后,每晚睡前都会面临这段代码“灵魂”拷问,我们更愿意多花时间思考把代码写的像 Mac pro 主机的包装那样“丝滑”。这样的工作氛围培养了每位同学的信心,只要是必现问题,基本都能优雅地解决。

四、生态支撑

虽然 Flap 的设计理念使得其在开发效率与执行效率上有一定的亮点,但这还不足以让其在业务中快速推广。因此我们建设了一套完整的 Flap 生态体系,涵盖了开发、发布、测试、运维各阶段。

图11 Flap在美团内网生态

如图 11 所示,Flap 生态的特点可以用 稳、快、准、狠 四个字来表达。

4.1 稳

稳,意为可靠的质量管理体系。在①IDE 开发中②提测阶段③线上监控④降级容灾,我们都有对应的策略。其中②和③的基本是和 Native 类似的 PR 检查、QA、日志、上报之类的这里就不做赘述了,下面主要提一下①和④。

IDE 语法检测插件

这个功能的意义是尽早地将不支持的语法以编译错误的方式暴露出来,以便同学在开发期就能发现及时修改。 设想一下当你代码写完了,Code Review 也逃过了同学的眼睛,PR 的 Dart 检测也过了,开开心心下班了,突然一个电话打来说发 Bundle 的时候错了,有的语法 Flap 不支持,需要返工去改,此时你的内心一定会“万马奔腾”。

所以,我们将这种暂不支持的语法提前暴露,并推荐使用什么方式代替,可以有效的减少返工, 得到一份满足 Dart 规范和 Flap 规范的代码。同样的 Lint 检测规则后续也配置到了 PR 阶段,如果真出现插件规则更新不及时场景,也会被拦在 PR 阶段。

图12 IDE语法检测插件

不过,目前 Flap 不支持的语法已经很少了,目前基本就是 await、as 和超过 2 个 with 等场景, 其中 await 和多个 with 的理论上也能支持,但会让项目有较大的重构和多处的分别对待,不利于后期的维护,考虑到 await 完全可以使用 future.then 代替,所以这个语法就禁了。对于 mixin 的特性,在 Dart 侧本身就是排列组合的关系。超过 2 个 with 会产生多个派生类,动态化的实现类似,所以为了不让简单问题复杂化,我们也禁用了 2 个以上 with 的写法,还有一些写法上的限制,例如 import 不使用全路径也会报错。

目前开发中 Flap 动态化已经与 AOT 共用一份业务代码了,为了不让 Flap 的规则影响到项目中还未覆盖到动态化的页面,让其满屏报错,我们使用 @Flap 注解作为是否开启当前页面的 Flap 规范检测的开关。这也很好理解,当这个页面内没有 @Flap 时,肯定是个 AOT 模块则还是默认的 Dart 检测规则, 一旦加上了 @Flap(‘pageID’),说明此页面会被动态发版,所以会自动开启 Flap 检测规则。

降级容灾

Flap 接入了美团内部统一的动态化发布平台 DD,并利用 DD 平台的能力实现了 App 版本、平台类型、UUID、Flutter SDK 版本等细粒度的下发规则管控。业务方可以根据实际情况选择不同的策略灰度发布方案,如果发生了严重异常,Flap 也支持撤包操作。

图13 Bundle 发布系统的各项边界控制

某一个页面加了标记支持了动态化之后,也会继续进行 AOT 编译过渡2个版本, 前置页面点击跳转是跳 AOT 页还是跳 Flap 页完全由 URL 里的参数控制,这个 URL 不是完全由云端下发的,是代码中先写上默认的 URL,若需要在配置平台修改后,下发的配置信息会让这个 URL 在路由侧完成替换。即使配置平台挂了,顶多丧失 URL 的替换能力而不是无法前往落地页。

图14 URL 动态替换与条件配置

对于 Flap 还有个更犀利的功能,在过渡期间(Flap 已经上线且 AOT 代码还没删时),一旦 Flap 出现 Dart 异常, 当用户退出页面再进入时会自行进入该 pageID 下的 Flutter AOT 页面,最大化降低对用户的干扰。

4.2 快

快,意为快速发版,快速更新。Flap 动态化改造使应用具备了分钟级动态发版的能力,为了更全面地释放这个能力,客户端业务迭代的流程也做了相应的调整。

当业务包发版上线,到了应用运行阶段,Flap 主要面对的问题变成敏捷质量的平衡,即:如何保证动态代码能够尽快生效,同时又要保证加载性能和稳定性。

对于此问题,Flap 的解法是二级缓存实时更新相结合,线上环境使用内存 + 磁盘二级缓存,进入页面之后再预拉取更新包,平衡加载性能与更新实时性。而线下环境则强制加载远程包,实现测试代码的快速交付。

图15 Flap 二级缓存策略

得益于这种机制,Flap 在线上可以实现接近 Web 的触达效率:应用会在启动时和具体业务入口处发起更新请求,每当业务有动态发布,新版本页面即可在用户下一次打开时触达至用户。在加载性能方面,二级缓存加持下的页面加载时间仅为数十毫秒,而远程加载的时间也只有 1 秒左右。

4.3 准

细粒度动态化

准,指哪打哪,可以页面级动态化,也可以局部 Widget 级别的细粒度动态化。事实上在 Flutter 的世界中,“页面”本身也是一个 Widget,业务方在实际开发中,只需要增加一行注解,即可实现对应 Widget 或页面的动态化。

@Flap(‘close_protect’)

class CloseProtectWidget extends StatelessWidget {

// …Widget 的 UI 和逻辑实现

}

Flap 打包发版时,解析引擎会从注解标记的 Widget 入手,递归解析所有依赖的文件,转化成对应的 DSL 并打包。App 线上运行时,每个动态化的页面或组件都会按照注解的 FlapId,通过FlapWidgetContainer 还原成对应的 UI。

图16 注解的扫描与 Widget 构建

实际调用时,只需传入注解中标记的 FlapId,即可实现动态化区域或页面的加载和渲染。

// 局部 Widget 级别的动态化,通过 FlapWidgetContainer 加载

Column(

children: [

MyAOTWidget(), // 原生 Flutter AOT Widget

FlapWidgetContainer(widgetId: ‘kangaroo_card’), // Flap widget

],

);

// 页面级别的动态化,通过 MTFlutterRoute 路由跳转:

RouteUtils.open(‘scheme://host/mtf?mtf_page=flap&flap_id=close_protect’);

精准的 Debug 能力

在 Debug 阶段加上一个注解 @Flap(‘pageId’),就会自动尝试转 DSL。如果该页面非常独立,且语法没有太花哨,则直接就能看到转换完成的字样。这个就说明该页面用到的语法既支持 Dart 又支持 Flap,不需要做任何修改。如果出现错误,则会在终端下精准打印出错误的位置。在此功能支持之前,基本都是“一崩就崩”到系统类的某某方法,开发同学只能通过自己的经验去堆栈中往上找。目前的精准 Debug 能力实现了转换器、运行时 parser、运行时 evaluate 三个阶段的全面覆盖。

图17 三个阶段的 Debug 定位

在转换器阶段的报错位置信息可直接在 Exception 中获得 AST 对象的 lineinfo 进而获取到列号行号信息。在 parser 与 evaluate 阶段的错误定位是根据对核心方法的 trycatch 与设置通用 Exception 类型逐层上抛实现的。因为 DSL-JSON 会被压缩且可以 format,行号列号并无意义,所以在运行时阶段的报错全是精确到某 class 中的某 method。

4.4 狠

狠,各种自动生成,实际转换步骤操作方式简单粗暴。Flap 在整个迭代流程环节都提供了便捷的自动化工具支撑。

imports 自动加载

基于 Flap 转换一个旧的 Flutter AOT 页面到 Flap 页面的操作是简单粗暴的,加上注解,一行终端指令就可以一把“梭”。但一个业务页面为了设计上的合理往往会分成多个文件,如果有 10 个文件是不是要重复 10 遍这样的工作?答案是否定的。Flap 无论是在 DSL 转换器侧,还是在运行时加载 DSL,都会做到 imports 的递归加载。

IDE 语言检测插件有一条限制是:import 必须使用 package 全路径,不能只 import 一个类名。因为多文件需要导入的位置都是根据全路径截取出的相对路径来计算的。

最后

以前一直是自己在网上东平西凑的找,找到的东西也是零零散散,很多时候都是看着看着就没了,时间浪费了,问题却还没得到解决,很让人抓狂。

后面我就自己整理了一套资料,还别说,真香!

资料有条理,有系统,还很全面,我不方便直接放出来,大家可以先看看有没有用得到的地方吧。

系列教程图片

2020Android复习资料汇总.png

flutter

NDK

设计思想开源框架

微信小程序

10 个文件是不是要重复 10 遍这样的工作?答案是否定的。Flap 无论是在 DSL 转换器侧,还是在运行时加载 DSL,都会做到 imports 的递归加载。

IDE 语言检测插件有一条限制是:import 必须使用 package 全路径,不能只 import 一个类名。因为多文件需要导入的位置都是根据全路径截取出的相对路径来计算的。

最后

以前一直是自己在网上东平西凑的找,找到的东西也是零零散散,很多时候都是看着看着就没了,时间浪费了,问题却还没得到解决,很让人抓狂。

后面我就自己整理了一套资料,还别说,真香!

资料有条理,有系统,还很全面,我不方便直接放出来,大家可以先看看有没有用得到的地方吧。

[外链图片转存中…(img-PlBriQSw-1720116150316)]

[外链图片转存中…(img-MplZTROd-1720116150317)]

[外链图片转存中…(img-ajYy0TJy-1720116150317)]

[外链图片转存中…(img-26HrmudL-1720116150318)]

[外链图片转存中…(img-pM4v0kGQ-1720116150318)]

[外链图片转存中…(img-cuHYEFVh-1720116150318)]

  • 13
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值