赞
踩
原文链接:满帮动态化Flutter框架“Thresh”,现在开源了
移动端技术栈自诞生以来,其双端开发成本和发布效率一直广受诟病。为了解决这些问题,前端跨端技术一直在不断尝试,希望能一次开发、多端运行并且能做到快速发布。期间经历了多个技术发展阶段。
第一阶段:以H5为代表,基于webview渲染
只需一次开发即可运行在双端,解决了开发效率低下的问题。但是webview存在严重的性能问题,用户的交互体验相比Native渲染有明显差距。
第二阶段:以RN和Weex为代表,前端技术栈开发,Native渲染
这些方案使用前端技术开发,最终映射到Native组件渲染,用户体验相比H5方案有了巨大的提升。但是这一阶段的方案同样存在不足。由于框架的渲染最终还是依赖双端Native组件,存在双端体验不一致性和平台兼容问题,极端情况下开发成本甚至超过双端Native开发。
第三阶段:Flutter,自绘引擎渲染
Google基于Skia渲染引擎,推出了Flutter跨平台框架,支持了Android/iOS/Web三个平台(尤其2.0的发布支持了全平台)。
基于自绘引擎,Flutter抹平了各个平台的差异,真正做到了一处开发,多端运行。业内对于Flutter彻底解决跨端开发的问题也寄予厚望。但是Flutter也并非完美,其动态能力不足,无法像H5、RN等技术一样快速发布。
为了解决动态能力不足的问题,满帮大前端团队从2019年开始对Flutter动态化能力进行探索,自研了动态化Flutter框架,在内部不断优化迭代,已上线20+页面,包括核心页面订单详情、货主货源详情、导航地图等等,并且于2020年底进行了开源。
Thresh项目推出的初心是为了能提供一种基于Flutter的完全跨端动态化方案,性能能达到甚至优于React Native,再加上其多端渲染一致性以及即将推出的Google Fuchsia系统默认开发语言为Flutter,都表明Thresh未来将会充满想象力。
实现Flutter的动态化,通常需要考虑以下几点:
Google原本打算在2019年推出Code Push方案,后来放弃了,主要两个原因:违反应用商店的规定和安全方面考虑;但目前android是可以通过产物替换来做到动态化,iOS端则无法做到。
通过Dart来定义部分核心通用组件,在平台下发已有的组件列表拼装的页面JSON,端上再通过解析渲染成页面。这种方案能满足轻交互场景,但只能支持有限动态性。
通过自定义一套Dart规范以及通过转换器生成JSON来做到动态更新,性能损失小,但是逻辑动态性需要提前预埋,且前端开发同学需要一定的学习成本。
类似于RN/Weex,通过自定义动态化UI描述 + JS引擎的解释运行转换思路,最终构建成页面和执行动态逻辑。这个方案对于前端开发非常友好,零学习成本,但是由于在JS引擎运行,会有一些性能损耗。
满帮的实际使用场景,业务快速迭代,需要Android和iOS都要支持动态性,所以产物替换的思路不能完全解决问题。随后又考虑使用组件化思路,拼接多个业务组件虽然能搭建出页面,但是弊端也很明显,复杂交互逻辑时无法实现。另外自定义Dart描述UI方案虽然满足了动态更新的要求,但是逻辑动态性依旧不强,而且Dart开发对于前端开发同学有一定的学习成本。
最终,综合考虑了开发效率、学习成本、多端性能和一致性等因素,我们选择了自定义JS描述UI + JS引擎的解释运行转换思路,类React语法结构,开发语言使用JS/TS。
在 Flutter 中描述视图组成的基本单位是 Widget,每一个 Widget 只包含当前部件的配置信息,它是一个轻量的、可被高效创建并销毁的数据结构。而许许多多的 Widgets 组合在一起,构建出了一个包含视图所有信息的 WidgetTree。之后 Flutter 会从 WidgetTree 中生成 ElementTree,再由 ElementTree 生成 RenderObjectTree。ElementTree 中的 Element 会同时持有其对应的 Widget 与 renderObject。
三棵树中,WidgetTree 会被频繁创建于销毁,但是 ElementTree 和 RenderObjectTree 只会在发生状态改变的时候才会改变,ElementTree 负责元素的更新与 diff,RenderObjectTree 则负责实际的布局与绘制。
核心思路是把 Flutter 的页面渲染逻辑中的三棵树中的第一棵树Widget,通过JS 来构造。这其中要完成JS与 Flutter 层完成基础组件映射,再通过JS引擎来生成UI描述,并传递给Dart层的 UIEngine,UIEngine 把UI描述转换为 Flutter 控件,最终渲染成页面。
Thresh框架完成了常用基础组件的定义与开发,能支撑95%以上业务场景的接入,语法定义规则支持React,对前端开发人员零成本接入。现支持的组件列表以及其部分属性如下
Flutter 是由 main() 函数开始程序执行的,主要完成以下几个工作:
依据 Flutter 中对 Widget 注册的所有拦截函数,JS 中会提供一套与之相对应的原子组件,以便在两种不同的 DSL 之间进行组件的互相转换。在 JS 中 UI 的构建通过 JSX 实现,借鉴了 React 的写法。
通过在 JS 中构建 UI 的描述层,再将 UI 描述转换为 JSON 格式字符串,经由 Native 发送到 Flutter ,由 Flutter 对 JSON 字符串进行解析后创建对应的 WidgetTree 并执行后续渲染操作。
在 JS 代码执行之前,Native 会向 JS 代码的执行环境中注册两个通信方法,一个为 JS 向 Flutter 传递消息的通道,另一个则是 Flutter 向 JS 传递消息的通道。通过这两个通道,就可以实现所有数据在 JS 与 Flutter 之间的流转(后面3.2章节会详细介绍)。
对于当完成所有链路的数据转换后就会拿到ModelTree & WidgetTree,ModelTree会持有并缓存WidgetTree,最终构建一个Widget页面并渲染显示。页面构建渲染流程主要是:
Flutter 接收到渲染 JSON 数据后,会通过递归遍历的方式从最底层开始,将每一个独立的渲染数据节点解析为 Model 对象。Model 将会持有所有的渲染数据,同时会关联自己的父节点;同时 Model 会携带所有的渲染数据,通过 Widget 拦截函数生成其对应的 Widget 实例,并持有该 Widget 实例。
比如, JS 中的 <Container />
组件在 Flutter 中经过拦截函数将会被创建为一个名叫 DFContainer 的 widget 实例。DFContainer 等 widgets 是使用 Flutter 提供的原子组件封装的一套自定义组件。
当通过 model 创建 Widget 时,如果发现其 isStateful = true
,则会在该 Widget 实例外层包裹一个 StatefulWidget,同时让 model 持有该 StatefulWidget 及其 state,以便之后进行更新操作。也就是说,如果一个 model 具有 isStateful = true
,则其会同时拥有 Widget & statefulWidget & state的特性。
在遍历过程中,原先的 JSON 数据会被转换为两个树 —— ModelTree & WidgetTree。其中 WidgetTree 中的每个节点都会被 ModelTree 中对应的节点所持有。
对于首次显示的页面来说,会使用被创建的 WidgetTree 直接替换初始化时创建的承载页面的内容;而非首页则会直接通过 Navigator.push(),使用 WidgetTree 创建并显示一个新页面。整个流程如下图:
JS 与 Flutter 是依赖于 Native 又完全独立的两端:JS 中的数据运算与流转不会直接影响到 Flutter 页面的渲染;Flutter 的渲染过程也不会阻塞 JS 的代码执行。
为了让完全独立的两者产生联系,我们找到了一个既能与 JS 产生联系,又能与 Flutter 传递消息的媒介 —— Native. 通过将一个消息从一端传递给 Native,再由 Native 完整传递给另一端,就实现了 JS 与 Flutter 之间的通信。
动态化Flutter 框架主要由这三部分构成,每一部分都处理不同的逻辑和绑定事件通信来更新渲染页面、事件响应,其核心渲染通信流程:Flutter ⇋ Native ⇋ JS 。
Flutter 初始化时,Flutter会与Native通过 methodChannel 建立通信关系,methodChannel 是一条双向通信的链路,既可以在 Flutter 中接收到 Native 的消息,也可以主动向 Native 发出消息。
同时,Native 在执行 JS 代码之前会向 JS 的 context 中注入一个方法,我们将这个方法命名为 methodChannel_js_call_flutter,用来使 JS 能够向 Flutter 传递消息。因此,在 Flutter 动态化中的通信链路如下图。
从上面两个链路中会发现,JS到Native的消息是可以顺利到达 Flutter;但是Flutter到JS没有直接的的通讯链路,在 Native 中断掉了。为了解决这个问题,JS 会在 context 中暴露一个名为 methodChannel_flutter_call_js 的方法,该方法的参数即为消息内容,这样 Native 就能够直接调用该方法将消息传递到 JS。
在Thresh中,几乎所有的三端通信需求都是“半双工”的。此处的“半双工”指的是,当一方作为消息传递方时,无法通过当前传递消息的通道获得消息接受方的反馈。这就表示当传递方发送出一条消息后就会结束自己的通信行为,它们不需要去关心自己是否会得到反馈,而实际上也不会有任何反馈。
基于以上情况,Thresh中的所有通信链路都会使用这种模式进行通信:消息传递方只需要传递数据而不需要关心回调,消息接收方只需要处理数据而不需要返回处理结果。这种模式对于跨越三端的通信来说更便于管理和约束,也使得 Native 成为了一个完全的数据中转站,否则 Native 除了需要传送数据外,还需要处理结果的反馈工作。即【数据传递方】 -> 【数据中转方】 -> 【数据接收方】是单向的。
但是并不是所有的通信都不需要反馈,例如与 Native 通信的双端通信链路 bridge,在向 Native 发出通信消息后需要获得 Native 的处理结果。对于这种情况,简单粗暴的单向通信将无法直接满足需求。但如果换成携带回调的“全双工”通信,从而能够在同一个通信通道上实现结果的接收,将会破坏原有的通信模式,也为通信的管理增加了难度。
为了解决在“半双工”通信模式上的通信反馈问题,我们通过在传递方为每一个需要反馈的通信加上标识符,再将反馈处理方法通过标识符缓存;当接收方处理完成后,携带标识符通过另一个通信通道将处理结果作为一个新的消息传递给原本的传递方后(在这个新的通道中,原本的数据传递和接收方将会互换身份),传递方会根据标识符在缓存中查找到处理方法并执行处理逻辑。
JS 与 Flutter 的通信是 Flutter 动态化的基石,而首次通信的成功与否又是通信能否成功建立的首要条件。
由于所有的跨三端通信都是“半双工”的,而 JS 与 Flutter 的环境准备又各自完全独立,这也就导致如果任一方环境准备完成前,另一方就发送了消息,这就会出现环境未完成的一方无法接收到消息的情况,从而影响后面所有的通信,导致通信中断或错乱。
为了解决这种情况,JS 与 Flutter 中采取了以下策略来保证首次通信的顺利执行(以下以 A / B 代指 JS 与 Flutter 中的任一方):
在将 JS 中的事件函数转换为 id 后,这个 id 也会与节点所属页面名称、节点 id 一起被携带到 Flutter 中,最终这三个信息会被包装为一个 Flutter 中的事件函数。
当在 Flutter 中触发事件时,首先会触发这个函数,该函数会向 JS 发送一条携带了页面名称、节点 id、事件 id 以及事件参数的消息。JS 接收到该消息后,首先会根据页面名称与节点 id 查找到触发了事件的节点,接着通过事件 id 在节点事件池中查找到对应的事件,传入参数并执行该事件。
触发事件的目的大部分都是为了更新页面上的内容,在 JS 中,组件更新的基本单位是自定义组件。
当一个自定义组件触发 setState() 后,会将该组件推入更新队列中等待更新。在节点进入队列之前会进行去重,从队列中进入第一个组件开始后的 16ms,队列将执行更新操作。在这 16ms 内进入队列中的其他待更新组件将会一同触发更新。
在实际进行更新操作前,会先对队列中的元素进行父节点的去重,即:依次获取所有待更新节点,同时向上获取该节点的父节点,如果其父节点存在于当前队列中,则从队列中移除该待更新节点,不存在则保留。这样做是因为只要队列中存在了父组件,则子组件就一定会被更新;其目的是为了执行最少次数操作,但实现尽可能多组件的更新。
组件的更新借鉴了 React 的组件更新 diff 算法,但是由于引入了 Flutter StatefulWidget 和 StatelessWidget 的概念,因此相比 React 的 diff 算法,thresh.js 的 diff 算法是粗粒度的。
两者相同的地方在于:都会对每一个节点进行对比,以保证每一个节点的状态都正确,最终被正确更新。
不同点在于:React 除了会对同类型节点进行属性和状态的合并外,也会将新创建或被删除的节点在旧节点数组中进行插入或删除操作,操作和更新的基本单位是原子组件;而 thresh.js 只会关注那些更新前后依然保留的同类型节点,在完成属性与状态的合并后,会直接抛弃旧节点,保留新节点,最终新节点将替换待更新自定义组件中的旧节点,并使用更新后的自定义组件的数据向 Flutter 发出更新消息——更新的基本单位是自定义组件。
JS 发送的更新消息有两部分组成:需要被更新的页面名称、更新节点 id 以及更新节点的 JSON 数据。当 Flutter 收到 JS 发送的更新消息后,首先会重复 json 转换为 Model 步骤,创建出 ModelTree & WidgetTree. 之后通过更新的页面名称和节点 id 在缓存中查找到需要被更新的 Model。
由于更新以 JS 中的自定义组件为最小单位,而每个自定义组件在 Flutter 中都会被创建为 StatefulWidget,因此在获取到新旧两个 Model 后会进行如下操作:
Thresh的整体工程化架构如下图:
如上图所示,自下而上,CI/CD + 基础服务 + 监控上报等支撑了Thresh业务,最上面为架构图。
Thresh动态化跨平台方案虽然在设计上有高性能渲染、一致性、开发效率、前端同学零成本接入等优势,但是考虑未来多业务方接入以及提升开发调试效率,推进了Thresh周边基础设施建设,下面简单介绍开发期、调试期、发布期。
开发期
支持plugin方式接入,业务方接入提供一套模板工程,能快速进入业务开发;另外Thresh兼容TS,能较低成本的让前端开发融入。
调试期
通过支持HotReload模式,秒级编译,极大的提升开发和调试效率,另外提供调试面板 + 动态调试能力也能极大地辅助提高调试效率。
发布期
依赖满帮自研的X-RAY灰度发布系统,具备分钟级别动态发版能力,能快速支撑业务和问题修复。
Thresh的开发集成形成了一整套流程,涵盖三方集成、多业务模块接入、开发调试等等,其中涉及细节比较多,这个在开源仓库里面有详细介绍。
至此,Thresh的架构设计和开发集成能力都基本完成,相比于其他动态化跨平台开发框架,Thresh有如下优势:
通过 JS 构建 Flutter 应用程序的基本原理并不复杂,主要是 JS 中的数据处理、Flutter 中的数据转换,以及实现数据在 JS 和 Flutter 中的流转通道。这类方案大提都类似,比如MXFlutter、美团外卖 MTFlutter。不过,这种方案目前看来还是比较鸡肋,偏离了Flutter跨平台涉及的初衷。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。