4

一直以来,跨平台开发都是困扰移动客户端开发的难题。

在马蜂窝旅游 App 很多业务场景里,我们尝试过一些主流的跨平台开发解决方案,比如 WebView 和 React Native,来提升开发效率和用户体验。但这两种方式也带来了新的问题。

比如使用 WebView 跨平台方式,优点确实非常明显。基于 WebView 的框架集成了当下 Web 开发的诸多优势:丰富的控件库、动态化、良好的技术社区、测试自动化等等。但是缺点也同样明显:渲染效率和 JavaScript 的执行能力都比较差,使页面的加载速度和用户体验都不尽如人意。

而使用以 React Native(简称 RN)为代表的框架时,维护又成了大难题。RN 使用类 HTML+JS 的 UI 创建逻辑,生成对应的原生页面,将页面的渲染工作交给了系统,所以渲染效率有很大的优势。但由于 RN 代码是通过 JS 桥接的方式转换为原生的控件,所以受各个系统间的差异影响非常大,虽然可以开发一套代码,但对各个平台的适配却非常的繁琐和麻烦。

为什么是 Flutter

2018 年 12 月初,Google 正式发布了开源跨平台 UI 框架 Flutter 1.0 Release 版本,马蜂窝电商客户端团队进行了调研与实践,发现 Flutter 能很好的帮助我们解决开发中遇到的问题。

  1. 跨平台开发针对 Android 与 iOS 的风格设计了两套设计语言的控件实现(Material & Cupertino)。这样不但能够节约人力成本,而且在用户体验上更好的适配 App 运行的平台。
  2. 重写了一套跨平台的 UI 框架,渲染引擎是依靠 Skia 图形库实现。Flutter 中的控件树直接由渲染引擎和高性能本地 ARM 代码直接绘制,不需要通过中间对象(Web 应用中的虚拟 DOM 和真实 DOM,原生 App 中的虚拟控件和平台控件)来绘制,使它有接近原生页面的性能,帮助我们提供更好的用户体验。
  3. 同时支持 JIT 和 AOT 编译。JIT 编译方式使其在开发阶段有个备受欢迎的功能——热重载(HotReload),这样在开发时可以省去构建的过程,提高开发效率。而在 Release 运行阶段采用 AOT 的编译方式,使执行效率非常高,让 Release 版本发挥更好的性能。

于是,电商客户端团队决定探索 Flutter 在跨平台开发中的新可能,并率先应用于商家端 App 中。在本文中,我们将结合 Flutter 在马蜂窝商家端 App 中的应用实践,探讨 Flutter 架构的实现原理,有何优势,以及如何帮助我们解决问题。

Flutter 架构和实现原理

Flutter 使用 Dart 语言开发,主要有以下几点原因:

  • Dart 一般情况下是运行 DartVM 上,但是也可以编译为 ARM 代码直接运行在硬件上。
  • Dart 同时支持 AOT 和 JIT 两种编译方式,可以更好的提高开发以及 App 的执行效率。
  • Dart 可以利用独特的隔离区(Isolate)实现多线程。而且不共享内存,可以实现无锁快速分配。
  • 分代垃圾回收,非常适合 UI 框架中常见的大量 Widgets 对象创建和销毁的优化。
  • 在为创建的对象分配内存时,Dart 是在现有的堆上移动指针,保证内存的增长是程线性的,于是就省了查找可用内存的过程。

Dart 主要由 Google 负责开发和维护。目前 Dart 最新版本已经是 2.2,针对 App 和 Web 开发做了很多优化。并且对于大多数的开发者而言,Dart 的学习成本非常低。

Flutter 架构也是采用的分层设计。从下到上依次为:Embedder(嵌入器)、Engine、Framework。

<center>图 1: Flutter 分层架构图</center>

Embedder是嵌入层,做好这一层的适配 Flutter 基本可以嵌入到任何平台上去; Engine层主要包含 Skia、Dart 和 Text。Skia 是开源的二位图形库;Dart 部分主要包括 runtime、Garbage Collection、编译模式支持等;Text 是文本渲染。Framework在最上层。我们的应用围绕 Framework 层来构建,因此也是本文要介绍的重点。

Framework

1.【Foundation】在最底层,主要定义底层工具类和方法,以提供给其他层使用。

2.【Animation】是动画相关的类,可以基于此创建补间动画(Tween Animation)和物理原理动画(Physics-based Animation),类似 Android 的 ValueAnimator 和 iOS 的 Core Animation。

3.【Painting】封装了 Flutter Engine 提供的绘制接口,例如绘制缩放图像、插值生成阴影、绘制盒模型边框等。

4.【Gesture】提供处理手势识别和交互的功能。

5.【Rendering】是框架中的渲染库。控件的渲染主要包括三个阶段:布局(Layout)、绘制(Paint)、合成(Composite)。

从下图可以看到,Flutter 流水线包括 7 个步骤。

<center>图 2: Flutter 流水线</center>

首先是获取到用户的操作,然后你的应用会因此显示一些动画,接着 Flutter 开始构建 Widget 对象。

Widget 对象构建完成后进入渲染阶段,这个阶段主要包括三步:

  • 布局元素:决定页面元素在屏幕上的位置和大小;
  • 绘制阶段:将页面元素绘制成它们应有的样式;
  • 合成阶段:按照绘制规则将之前两个步骤的产物组合在一起。

最后的光栅化由 Engine 层来完成。

在渲染阶段,控件树(widget)会转换成对应的渲染对象(RenderObject)树,在 Rendering 层进行布局和绘制。

在布局时 Flutter 深度优先遍历渲染对象树。数据流的传递方式是从上到下传递约束,从下到上传递大小。也就是说,父节点会将自己的约束传递给子节点,子节点根据接收到的约束来计算自己的大小,然后将自己的尺寸返回给父节点。整个过程中,位置信息由父节点来控制,子节点并不关心自己所在的位置,而父节点也不关心子节点具体长什么样子。

<center>图 3: 数据流传递方式</center>

为了防止因子节点发生变化而导致的整个控件树重绘,Flutter 加入了一个机制——Relayout Boundary,在一些特定的情形下 Relayout Boundary 会被自动创建,不需要开发者手动添加。

例如,控件被设置了固定大小(tight constraint)、控件忽略所有子视图尺寸对自己的影响、控件自动占满父控件所提供的空间等等。很好理解,就是控件大小不会影响其他控件时,就没必要重新布局整个控件树。有了这个机制后,无论子树发生什么样的变化,处理范围都只在子树上。

<center>图 4: Relayout Boundary 机制</center>

在确定每个空间的位置和大小之后,就进入绘制阶段。绘制节点的时候也是深度遍历绘制节点树,然后把不同的 RenderObject 绘制到不同的图层上。

这时有可能出现一种特殊情况,如下图所示节点 2 在绘制子节点 4 时,由于其节点 4 需要单独绘制到一个图层上(如 video),因此绿色图层上面多了个黄色的图层。之后再需要绘制其他内容(标记 5)就需要再增加一个图层(红色)。再接下来要绘制节点 1 的右子树(标记 6),也会被绘制到红色图层上。所以如果 2 号节点发生改变就会改变红色图层上的内容,因此也影响到了毫不相干的 6 号节点。

<center>图 5: 绘制节点与图层的关系</center>

为了避免这种情况,Flutter 的设计者这里基于 Relayout Boundary 的思想增加了Repaint Boundary。在绘制页面时候如果遇见 Repaint Boundary 就会强制切换图层。

如下图所示,在从上到下遍历控件树遇到 Repaint Boundary 会重新绘制到新的图层(深蓝色),在从下到上返回的时候又遇到 Repaint Boundary,于是又增加一个新的图层(浅蓝色)。

<center>图 6: Repaint Boundary 机制</center>

这样,即使发生重绘也不会对其他子树产生影响。比如在 Scrollview 上,当滚动的时候发生内容重绘,如果在 Scrollview 以外的地方不需要重绘就可以使用 Repaint Boundary。Repaint Boundary 并不会像 Relayout Boundary 一样自动生成,而是需要我们自己来加入到控件树中。

6.【Widget】控件层。所有控件的基类都是 Widget,Widget 的数据都是只读的, 不能改变。所以每次需要更新页面时都需要重新创建一个新的控件树。每一个 Widget 会通过一个 RenderObjectElement 对应到一个渲染节点(RenderObject),可以简单理解为 Widget 中只存储了页面元素的信息,而真正负责布局、渲染的是 RenderObject。

在页面更新重新生成控件树时,RenderObjectElement 树会尽量保持重用。由于 RenderObjectElement 持有对应的 RenderObject,所有 RenderObject 树也会尽可能的被重用。如图所示就是三棵树之间的关系。在这张图里我们把形状当做渲染节点的类型,颜色是它的属性,即形状不同就是不同的渲染节点,而颜色不同只是同一对象的属性的不同。

<center>图 7:Widget、Element 和 Render 之间的关系</center>

如果想把方形的颜色换成黄色,将圆形的颜色变成红色,由于控件是不能被修改的,需要重新生成两个新的控件 Rectangle yellow 和 Circle red。由于只是修改了颜色属性,所以 Element 和 RenderObject 都被重用,而之前的控件树会被释放回收。

<center>图 8: 示例</center>

那么如果把红色圆形变成三角形又会怎样呢?由于这里发生变化的是类型,所以对应的 Element 节点和 RenderObject 节点都需要重新创建。但是由于黄色方形没有发生改变,所以其对应的 Element 节点和 RenderObject 节点没有发生变化。

<center>图 9: 示例</center>

7. 最后是【Material】 & 【Cupertino】,这是在 Widget 层之上框架为开发者提供的基于两套设计语言实现的 UI 控件,可以帮助我们的 App 在不同平台上提供接近原生的用户体验。

Flutter 在马蜂窝商家端App 中的应用实践

<center>图 10: 马蜂窝商家端使用 Flutter 开发的页面</center>

开发方式:Flutter + Native

由于商家端已经是一款成熟的 App,不可能创建一个新的 Flutter 工程全部重新开发,因此我们选择 Native 与 Flutter 混编的方案来实现。
在了解 Native 与 Flutter 混编方案前,首先我们需要了解在 Flutter 工程中,通常有以下 4 种工程类型:

1. Flutter Application

标准的 Flutter App 工程,包含标准的 Dart 层与 Native 平台层。

2. Flutter Module

Flutter 组件工程,仅包含 Dart 层实现,Native 平台层子工程为通过 Flutter 自动生成的隐藏工程(.ios /.android)。

3. Flutter Plugin

Flutter 平台插件工程,包含 Dart 层与 Native 平台层的实现。

4. Flutter Package

Flutter 纯 Dart 插件工程,仅包含 Dart 层的实现,往往定义一些公共 Widget。

了解了 Flutter 工程类型后,我们来看下官方提供的一种混编方案(https://github.com/flutter/flutter/wiki/Add-Flutter-to-existing-apps),即在现有工程下创建Flutter Module 工程,以本地依赖的方式集成到现有的 Native 工程中。

官方集成方案(以 iOS 为例)

a. 在工程目录创建 FlutterModule,创建后,工程目录大致如下:

b. 在 Podfile 文件中添加以下代码:

flutter_application_path = '../flutter_Moudule/'

该脚本主要负责:

  • pod 引入 Flutter.Framework 以及 FlutterPluginRegistrant 注册入口
  • pod 引入 Flutter 第三方 plugin
  • 在每一个 pod 库的配置文件中写入对 Generated.xcconfig 文件的导入
  • 修改 pod 库的 ENABLE_BITCODE = NO(因为 Flutter 现在不支持 bitcode)

c. 在 iOS 构建阶段 Build Phases 中注入构建时需要执行的 xcode_backend.sh (位于 FlutterSDK/packages/flutter_tools/bin) 脚本:

"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh" build

该脚本主要负责:

  • 构建 App.framework 以及 Flutter.framework 产物
  • 根据编译模式(debug/profile/release)导入对应的产物
  • 编译 flutter_asset 资源
  • 把以上产物 copy 到对应的构建产物中

d. 与 Native 通信

  • 方案一:改造 AppDelegate 继承自 FlutterAppDelegate
  • 方案二:AppDelegate 实现 FlutterAppLifeCycleProvider 协议,生命周期由 FlutterPluginAppLifeCycleDelegate 传递给 Flutter

以上就是官方提供的集成方案。我们最终没有选择此方案的原因,是它直接依赖于 FlutterModule 工程以及 Flutter 环境,使 Native 开发同学无法脱离 Flutter 环境开发,影响正常的开发流程,团队合作成本较大;而且会影响正常的打包流程。(目前 Flutter 团队正在重构嵌入 Native 工程的方式)

最终我们选择另一种方案来解决以上的问题:远端依赖产物。

<center>图 11 :远端依赖产物</center>

iOS 集成方案

通过对官方混编方案的研究,我们了解到 iOS 工程最终依赖的其实是 FlutterModule 工程构建出的产物(Framework,Asset,Plugin),只需将产物导出并 push 到远端仓库,iOS 工程通过远端依赖产物即可。

依赖产物目录结构如下:

  • App.framework: Flutter 工程产物(包含 Flutter 工程的代码,Debug 模式下它是个空壳,代码在 flutter_assets 中)。
  • Flutter.framework:Flutter 引擎库。与编译模式(debug/profile/release)以及 CPU 架构(arm*, i386, x86_64)相匹配。
  • lib.a & .h 头文件: FlutterPlugin 静态库(包含在 iOS 端的实现)。
  • flutter_assets: 包含 Flutter 工程字体,图片等资源。在 Flutter1.2 版本中,被打包到 App.framework 中。

Android 集成方案

Android Nativite 集成是通过 Gradle 远程依赖 Flutter 工程产物的方式完成的,以下是具体的集成流程。

a.创建 Flutter 标准工程

$ flutter create flutter_demo

默认使用 Java 代码,如果增加 Kotlin 支持,使用如下命令:

$ flutter create -a kotlin flutter_demo

b.修改工程的默认配置

  1. 修改 app module 工程的 build.gradle 配置 apply plugin: 'com.android.application' => apply plugin: 'com.android.library',并移除 applicationId 配置
  2. 修改 root 工程的 build.gradle 配置

    在集成过程中 Flutter 依赖了三方 Plugins 后,遇到 Plugins 的代码没有被打进 Library 中的问题。通过以下配置解决(这种方式略显粗暴,后续的优化方案正在调研)。

subprojects {
   project.buildDir = "${rootProject.buildDir}/app"
}
  1. app module 增加 maven 打包配置
  2. c. 生成 Android Flutter 产物
$ cd android
$ ./gradlew uploadArchives

官方默认的构建脚本在 Flutter 1.0.0 版本存在 Bug——最终的产物中会缺少 flutter_shared/icudtl.dat 文件,导致 App Crash。目前的解决方式是将这个文件复制到工程的 assets 下(在 Flutter 最新 1.2.1 版本中这个 Bug 已被修复,但是 1.2.1 版本又出现了一个 UI 渲染的问题,所以只能继续使用 1.0.0 版本)。

d.Android Native 平台工程集成,增加下面依赖配置即可,不会影响 Native 平台开发的同学

implementation 'com.mfw.app:MerchantFlutter:0.0.5-beta'

Flutter 和 iOS、Android 的交互

使用平台通道(Platform Channels)在 Flutter 工程和宿主(Native 工程)之间传递消息,主要是通过 MethodChannel 进行方法的调用,如下图所示:

<center>图 12 :Flutter 与 iOS、Android 交互</center>

为了确保用户界面不会挂起,消息和响应是异步传递的,需要用 async 修饰方法,await 修饰调用语句。Flutter 工程和宿主工程通过在 Channel 构造函数中传递 Channel 名称进行关联。单个应用中使用的所有 Channel 名称必须是唯一的; 可以在 Channel 名称前加一个唯一的「域名前缀」。

Flutter 与 Native 性能对比

我们分别使用 Native 和 Flutter 开发了两个列表页,以下是页面效果和性能对比:

iOS 对比(机型 6P 系统 10.3.3):

Flutter 页面:

iOS Native 页面:

可以看到,从使用和直观感受都没有太大的差别。于是我们采集了一些其他方面的数据。

Flutter 页面:

iOS Native 页面:

另外我们还对比了商家端接入 Flutter 前后包体积的大小:39Mb →  44MB

在 iOS 机型上,流畅度上没有什么差异。从数值上来看,Flutter 在 内存跟 GPU/CPU 使用率上比原生略高。Demo 中并没有对 Flutter 做更多的优化,可以看出 Flutter 整体来说还是可以做出接近于原生的页面。

下面是 Flutter 与 Android 的性能对比。

Flutter 页面:

Android Native 页面:

从以上两张对比图可以看出,不考虑其他因素,单纯从性能角度来说,原生要优于 Flutter,但是差距并不大,而且 Flutter 具有的跨平台开发和热重载等特点极大地节省了开发效率。并且,未来的热修复特性更是值得期待。

混合栈管理

首先先介绍下 Flutter 路由的管理:

  • Flutter 管理页面有两个概念:Route 和 Navigator。
  • Navigator 是一个路由管理的 Widget(Flutter 中万物皆 Widget),它通过一个栈来管理一个路由 Widget 集合。通常当前屏幕显示的页面就是栈顶的路由。
  • 路由 (Route) 在移动开发中通常指页面(Page),这跟 web 开发中单页应用的 Route 概念意义是相同的,Route 在 Android 中通常指一个 Activity,在 iOS 中指一个 ViewController。所谓路由管理,就是管理页面之间如何跳转,通常也可被称为导航管理。这和原生开发类似,无论是 Android 还是 iOS,导航管理都会维护一个路由栈,路由入栈 (push) 操作对应打开一个新页面,路由出栈 (pop) 操作对应页面关闭操作,而路由管理主要是指如何来管理路由栈。

<center>图 14 :Flutter 路由管理</center>

如果是纯 Flutter 工程,页面栈无需我们进行管理,但是引入到 Native 工程内,就需要考虑如何管理混合栈。并且需要解决以下几个问题:

1. 保证 Flutter 页面与 Native 页面之间的跳转从用户体验上没有任何差异

2. 页面资源化(马蜂窝特有的业务逻辑)

3. 保证生命周期完整性,处理相关打点事件上报

4. 资源性能问题

参考了业界内的解决方法,以及项目自身的实际场景,我们选择类似于 H5 在 Navite 中嵌入的方式,统一通过 openURL 跳转到一个 Native 页面(FlutterContainerVC),Native 页面通过 addChildViewController 方式添加 FlutterViewController(负责 Flutter 页面渲染),同时通过 channel 同步 Native 页面与 Flutter 页面。

  • 每一次的 push/pop 由 Native 发起,同时通过 channel 保持 Native 与 Flutter 页面同步——在 Native 中跳转 Flutter 页面与跳转原生无差异
  • 一个 Flutter 页面对应一个 Native 页面(FlutterContainerVC)——解决页面资源化
  • FlutterContainerVC 通过 addChildViewController 对单例 FlutterViewController 进行复用——保证生命周期完整性,处理相关打点事件上报
  • 由于每一个 FlutterViewController(提供 Flutter 视图的实现)会启动三个线程,分别是 UI 线程、GPU 线程和 IO 线程,使用单例 FlutterViewController 可以减少对资源的占用——解决资源性能问题

Flutter 应用总结

Flutter 一经发布就很受关注,除了 iOS 和 Android 的开发者,很多前端工程师也都非常看好 Flutter 未来的发展前景。相信也有很多公司的团队已经投入到研究和实践中了。不过 Flutter 也有很多不足的地方,值得我们注意:

  1. 虽然 1.2 版本已经发布,但是目前没有达到完全稳定状态,1.2 发布完了就出现了控件渲染的问题。加上 Dart 语言生态小,学习资料可能不够丰富。
  2. 关于动态化的支持,目前 Flutter 还不支持线上动态性。如果要在 Android 上实现动态性相对容易些,iOS 由于审核原因要实现动态性可能成本很高。
  3. Flutter 中目前拿来就用的能力只有 UI 控件和 Dart 本身提供能力,对于平台级别的能力还需要通过 channel 的方式来扩展。
  4. 已有工程迁移比较复杂,以前沉淀的 UI 控件,需要重新再实现一套。
  5. 最后一点比较有争议,Flutter 不会从程序中拆分出额外的模板或布局语言,如 JSX 或 XM L,也不需要单独的可视布局工具。有的人认为配合 HotReload 功能使用非常方便,但我们发现这样代码会有非常多的嵌套,阅读起来有些吃力。

目前阿里的闲鱼开发团队已经将 Flutter 用于大型实践,并应用在了比较重要的场景(如产品详情页),为后来者提供了良好的借鉴。马蜂窝的移动客户端团队关于 Flutter 的探索才刚刚起步,前面还有很多的问题需要我们一点一点去解决。不过无论从 Google 对其的重视程度,还是我们从实践中看到的这些优点,都让我们对 Flutter 充满信心,也希望在未来我们可以利用它创造更多的价值和奇迹。

路途虽远,犹可期许。

本文作者:马蜂窝电商研发客户端团队。

(马蜂窝技术原创内容,转载务必注明出处保存文末二维码图片,禁止商业用途,谢谢配合。)

参考文献:

关注马蜂窝技术,找到更多你想要的内容


马蜂窝技术
2.3k 声望8.3k 粉丝

欢迎关注马蜂窝技术,我们将定期分享马蜂窝技术团队先进的技术实践,期待您与马蜂窝一起见证技术对行业的赋能和改变。