一直以来,跨平台开发都是困扰移动客户端开发的难题。前端
在马蜂窝旅游 App 不少业务场景里,咱们尝试过一些主流的跨平台开发解决方案,好比 WebView 和 React Native,来提高开发效率和用户体验。但这两种方式也带来了新的问题。android
好比使用 WebView 跨平台方式,优势确实很是明显。基于 WebView 的框架集成了当下 Web 开发的诸多优点:丰富的控件库、动态化、良好的技术社区、测试自动化等等。可是缺点也一样明显:渲染效率和 JavaScript 的执行能力都比较差,使页面的加载速度和用户体验都不尽如人意。ios
而使用以 React Native(简称 RN)为表明的框架时,维护又成了大难题。RN 使用类 HTML+JS 的 UI 建立逻辑,生成对应的原生页面,将页面的渲染工做交给了系统,因此渲染效率有很大的优点。但因为 RN 代码是经过 JS 桥接的方式转换为原生的控件,因此受各个系统间的差别影响很是大,虽然能够开发一套代码,但对各个平台的适配却很是的繁琐和麻烦。git
2018 年 12 月初,Google 正式发布了开源跨平台 UI 框架 Flutter 1.0 Release 版本,马蜂窝电商客户端团队进行了调研与实践,发现 Flutter 能很好的帮助咱们解决开发中遇到的问题。github
因而,电商客户端团队决定探索 Flutter 在跨平台开发中的新可能,并率先应用于商家端 App 中。在本文中,咱们将结合 Flutter 在马蜂窝商家端 App 中的应用实践,探讨 Flutter 架构的实现原理,有何优点,以及如何帮助咱们解决问题。web
Flutter 使用 Dart 语言开发,主要有如下几点缘由:xcode
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 层来构建,所以也是本文要介绍的重点。
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 在不一样平台上提供接近原生的用户体验。
<center>图 10: 马蜂窝商家端使用 Flutter 开发的页面</center>
因为商家端已是一款成熟的 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/'
该脚本主要负责:
c. 在 iOS 构建阶段 Build Phases 中注入构建时须要执行的 xcode_backend.sh (位于 FlutterSDK/packages/flutter_tools/bin) 脚本:
"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh" build
该脚本主要负责:
d. 与 Native 通讯
以上就是官方提供的集成方案。咱们最终没有选择此方案的缘由,是它直接依赖于 FlutterModule 工程以及 Flutter 环境,使 Native 开发同窗没法脱离 Flutter 环境开发,影响正常的开发流程,团队合做成本较大;并且会影响正常的打包流程。(目前 Flutter 团队正在重构嵌入 Native 工程的方式)
最终咱们选择另外一种方案来解决以上的问题:远端依赖产物。
<center>图 11 :远端依赖产物</center>
经过对官方混编方案的研究,咱们了解到 iOS 工程最终依赖的实际上是 FlutterModule 工程构建出的产物(Framework,Asset,Plugin),只需将产物导出并 push 到远端仓库,iOS 工程经过远端依赖产物便可。
依赖产物目录结构以下:
Android Nativite 集成是经过 Gradle 远程依赖 Flutter 工程产物的方式完成的,如下是具体的集成流程。
a.建立 Flutter 标准工程
$ flutter create flutter_demo
默认使用 Java 代码,若是增长 Kotlin 支持,使用以下命令:
$ flutter create -a kotlin flutter_demo
b.修改工程的默认配置
在集成过程当中 Flutter 依赖了三方 Plugins 后,遇到 Plugins 的代码没有被打进 Library 中的问题。经过如下配置解决(这种方式略显粗暴,后续的优化方案正在调研)。
subprojects { project.buildDir = "${rootProject.buildDir}/app" }
$ 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'
使用平台通道(Platform Channels)在 Flutter 工程和宿主(Native 工程)之间传递消息,主要是经过 MethodChannel 进行方法的调用,以下图所示:
<center>图 12 :Flutter 与 iOS、Android 交互</center>
为了确保用户界面不会挂起,消息和响应是异步传递的,须要用 async 修饰方法,await 修饰调用语句。Flutter 工程和宿主工程经过在 Channel 构造函数中传递 Channel 名称进行关联。单个应用中使用的全部 Channel 名称必须是惟一的; 能够在 Channel 名称前加一个惟一的「域名前缀」。
咱们分别使用 Native 和 Flutter 开发了两个列表页,如下是页面效果和性能对比:
Flutter 页面:
iOS Native 页面:
能够看到,从使用和直观感觉都没有太大的差异。因而咱们采集了一些其余方面的数据。
Flutter 页面:
iOS Native 页面:
另外咱们还对比了商家端接入 Flutter 先后包体积的大小:39Mb → 44MB
在 iOS 机型上,流畅度上没有什么差别。从数值上来看,Flutter 在 内存跟 GPU/CPU 使用率上比原生略高。Demo 中并无对 Flutter 作更多的优化,能够看出 Flutter 总体来讲仍是能够作出接近于原生的页面。
下面是 Flutter 与 Android 的性能对比。
Flutter 页面:
Android Native 页面:
从以上两张对比图能够看出,不考虑其余因素,单纯从性能角度来讲,原生要优于 Flutter,可是差距并不大,并且 Flutter 具备的跨平台开发和热重载等特色极大地节省了开发效率。而且,将来的热修复特性更是值得期待。
首先先介绍下 Flutter 路由的管理:
<center>图 14 :Flutter 路由管理</center>
若是是纯 Flutter 工程,页面栈无需咱们进行管理,可是引入到 Native 工程内,就须要考虑如何管理混合栈。而且须要解决如下几个问题:
1. 保证 Flutter 页面与 Native 页面之间的跳转从用户体验上没有任何差别
2. 页面资源化(马蜂窝特有的业务逻辑)
3. 保证生命周期完整性,处理相关打点事件上报
4. 资源性能问题
参考了业界内的解决方法,以及项目自身的实际场景,咱们选择相似于 H5 在 Navite 中嵌入的方式,统一经过 openURL 跳转到一个 Native 页面(FlutterContainerVC),Native 页面经过 addChildViewController 方式添加 FlutterViewController(负责 Flutter 页面渲染),同时经过 channel 同步 Native 页面与 Flutter 页面。
Flutter 一经发布就很受关注,除了 iOS 和 Android 的开发者,不少前端工程师也都很是看好 Flutter 将来的发展前景。相信也有不少公司的团队已经投入到研究和实践中了。不过 Flutter 也有不少不足的地方,值得咱们注意:
目前阿里的闲鱼开发团队已经将 Flutter 用于大型实践,并应用在了比较重要的场景(如产品详情页),为后来者提供了良好的借鉴。马蜂窝的移动客户端团队关于 Flutter 的探索才刚刚起步,前面还有不少的问题须要咱们一点一点去解决。不过不管从 Google 对其的重视程度,仍是咱们从实践中看到的这些优势,都让咱们对 Flutter 充满信心,也但愿在将来咱们能够利用它创造更多的价值和奇迹。
路途虽远,犹可期许。
本文做者:马蜂窝电商研发客户端团队。
(马蜂窝技术原创内容,转载务必注明出处保存文末二维码图片,禁止商业用途,谢谢配合。)
参考文献:
关注马蜂窝技术,找到更多你想要的内容