注:此流程图出自 复杂业务如何保证Flutter的高性能高流畅度?| 闲鱼技术,可以较为清晰的表达 Flutter 核心的绘制流程了。
1.4.1 Build
执行 build 方法时,根据组件的类型,存在两种不同的逻辑。
我们知道,Flutter 内的 Widget 可以分为 StatelessWidget 与 StatefulWidget,即无状态组件与有状态组件。
所谓 StatelessWidget,就是它 build 的信息完全由配置参数(入参)组成,换句话说,它们一旦创建成功就不再关心、也不响应任何数据变化进行重绘。
StatelessWidget
所谓 StatefulWidget,除了父组件初始化时传入的静态配置之外,还要处理用户的交互与内部数据变化(如网络数据回包)并体现在 UI 上,这类组件就需要以 State 类打来 Widget 构建的设计方式来实现。它由 State 的 build 方法构建 UI, 最终调用 buildScope
方法。其会遍历 _dirtyElements
,对其调用 rebuild/build。
StatefulWidget
注:以上两图出自 《Flutter 核心技术与实战 | 陈航》
1.4.2 Layout
只有布局类 Widget 会触发 layout(如 Container、Padding、Align 等)。
每个 RenderObject 节点需要做两件事:
-
调用自己的 performLayout 来计算 layout
-
调用 child 的 layout,把 parent 的限制传入
/// 实际计算 layout 的实现void performLayout() { _size = configuration.size; if (child != null) { child.layout(BoxConstraints.tight(_size)); }}void layout(Constraints constraints, { bool parentUsesSize = false }) { /// …省略无关逻辑 RenderObject relayoutBoundary; if (!parentUsesSize || sizedByParent || constraints.isTight || parent is! RenderObject) { relayoutBoundary = this; } else { relayoutBoundary = (parent as RenderObject)._relayoutBoundary; } if (!_needsLayout && constraints == _constraints && relayoutBoundary == _relayoutBoundary) { return; } _constraints = constraints; _relayoutBoundary = relayoutBoundary; if (sizedByParent) { performResize(); } RenderObject debugPreviousActiveLayout; performLayout(); markNeedsSemanticsUpdate(); _needsLayout = false; markNeedsPaint();}
如此递归一轮,每个节点都受到父节点的约束并计算出自己的 size,然后父节点就可以按照自己的逻辑决定各个子节点的位置,从而完成整个 Layout 环节。
layout
1.4.3 Paint
渲染管道中首先找出需要重绘的 RenderObject,如果有实现了 CustomPainter 则调用 CustomPainter paint 方法 再调用 child 的 paint 方法;如果未实现 CustomPainter,则直接调用 child 的 paint。
在调用 paint 的时候,经过一串的转换后,layer->PaintingContext->Canvas
,最终 paint 就是描绘在 Canvas 上。
void paint(PaintingContext context, Offset offset) { if (_painter != null) { // 只有持有 CustomPainter 情况下,才继续往下调用自定义的 CustomPainter 的 paint 方法,把 canvas 传过去 _paintWithPainter(context.canvas, offset, _painter); _setRasterCacheHints(context); } super.paint(context, offset); //调用父类的paint的方法 if (_foregroundPainter != null) { _paintWithPainter(context.canvas, offset, _foregroundPainter); _setRasterCacheHints(context); }}// 在父类的 paint 里面继续调用 child 的 paint,实现父子遍历void paint(PaintingContext context, Offset offset) { if (child != null){ context.paintChild(child, offset); }void _paintWithPainter(Canvas canvas, Offset offset, CustomPainter painter) { int debugPreviousCanvasSaveCount; canvas.save(); if (offset != Offset.zero) canvas.translate(offset.dx, offset.dy); // 在调用 paint 的时候,经过一串的转换后,layer->PaintingContext->Canvas,最终 paint 就是描绘在 Canvas 上 painter.paint(canvas, size); /// … canvas.restore();}
1.4.4 Composite
合成主要做三件事情:
-
把所有 Layer 组合成 Scene
-
通过
ui.window.render
方法,把 Scene 提交给 Engine。 -
Engine 把计算所有的 Layer 最终的显示效果,渲染到屏幕上。
final ui.Window _window;void compositeFrame() { // 省略计时逻辑 final ui.SceneBuilder builder = ui.SceneBuilder(); final ui.Scene scene = layer.buildScene(builder); if (automaticSystemUiAdjustment) _updateSystemChrome(); _window.render(scene); scene.dispose();}void addToScene(ui.SceneBuilder builder, [ Offset layerOffset = Offset.zero ]) { addChildrenToScene(builder);}void addChildrenToScene(ui.SceneBuilder builder, [ Offset childOffset = Offset.zero ]) { Layer child = firstChild; while (child != null) { if (childOffset == Offset.zero) { child._addToSceneWithRetainedRendering(builder); } else { child.addToScene(builder, childOffset); } child = child.nextSibling; }}
2. 跨端方案对比
跨端开发是必然趋势,从本质上来说,它增加业务代码的复用率,减少因为适配不同平台带来的工作量,从而降低开发成本。在各平台差异抹平之前,要想“多快好省”地开发出各端体验接近一致的程序,那便是跨端开发了。
总得来说,业内普遍认同跨端方案存在以下三种:
-
Web 容器方案
-
泛 Web 容器方案
-
自绘引擎方案
下面来一一讲解。
2.1 Web 容器
跨端方案1
所谓 Web 容器,即是基于 Web 相关技术通过浏览器组件来实现界面和功能,包括我们通常意义上说的基于 WebView 的 “H5”、Cordova、Ionic、微信小程序。
这类 Hybrid 开发模式,只需要将开发一次 Web,就可以同时在多个系统的浏览器组件中运行,保持基本一致的体验,是迄今为止热度很高的跨端开发模式。而 Web 与 原生系统之间的通信,则通过 JSBridge 来完成,原生系统通过 JSBridge 接口暴露能力给 Web 调用。而页面的呈现,则由浏览器组件按照标准的浏览器渲染流程自行将 Web 加载、解析、渲染。
这类方案的优点:简单、天然支持热更新、生态繁荣、兼容性强、开发体验友好。
当然,缺点也很明显,否则就没有后面两个方案什么事了,主要是体验上的问题:
-
浏览器渲染流程复杂,页面需要在线加载,体验受限于网络。所以 Web 存在白屏时间(PWA 例外)、且交互上体验上与原生体验有着非常非常明显区别。
-
双端需要分别实现 JSBridge 接口,且 JSBridge 的通信效率一般。
2.2 泛 Web 容器
跨端方案2
所以轮到泛 Web 容器方案出场了,代表性框架是 React Native,Weex,Hippy。
-
它放弃了浏览器渲染,而采用原生控件,从而保证交互体验;
-
它支持内置离线包,来规避加载耗时避免长时间白屏;
-
它依然采用前端友好的 JavaScript 语言,来保证开发体验。
在跨端通信上,React Native 依然通过 Bridge 的方式来调用原生提供的方法。
这套方案理想是美好的,但现实确实骨感的,它在实践下来之后也依然发现了问题:
-
直接调用原生控件虽然提升了体验和性能,但是不同端相同的原生控件的渲染结果是存在差异的,跨端的差异需要巨大的工作量来抹平。
-
Bridge 的通信效率一般,在需要高频通信的场景下会造成丢帧。
2.3 自绘引擎
那我们究竟能不能既简单地抹平差异,又同时保证性能呢?
答案是可以,那就是自绘引擎。不调用原生控件,我们自己去画。那就是 Flutter。好比警察问 React Native 嫌疑犯长什么样子,React Native 只能绘声绘色地去描绘嫌疑犯的外观,警察画完之后再拿给 React Native 看,React Native 还要回答像不像;但 Flutter 自己就是一个素描大师,它可以自己将嫌疑犯的画像画好然后交给警察看。这两者的效率和表现差异,不言而喻。
-
其通过 Skia 图形库直接调用 OpenGL 渲染,保证渲染的高性能,同时抹平差异性。
-
开发语言选择同时支持 JIT 和 AOT 的 Dart,保证开发效率的同时,较 JavaScript 而言,更是提升了数十倍的执行效率。
跨端方案3
通过这样的思路,Flutter 可以尽可能地减少不同平台之间的差异, 同时保持和原生开发一样的高性能。并且对于系统能力,可以通过开发 Plugin 来支持 Flutter 项目间的复用。所以说,Flutter 成了三类跨端方案中最灵活的那个,也成了目前业内受到关注的框架。
至于通信效率,Fluter 跨端的通信效率也是高出 JSBridge 许许多多。Flutter 通过 Channel 进行通信,其中:
-
BasicMessageChannel,用于传递字符串和半结构化的信息,是全双工的,可以双向请求数据。
-
MethodChannel,用于传递方案调用,即 Dart 侧可以调用原生侧的方法并通过 Result 接口回调结果数据。
-
EventChannel:用户数据流的通信,即 Dart 侧监听原生侧的实时消息,一旦原生侧产生了数据,立即回调给 Dart 侧。
其中,MethodChannel 在开发中用的比较多,下图是一个标准的 MethodChannel 的调用原理图:
MethodChannel
但为什么我们说 Channel 的性能高呢?梳理一下 MethodChannel 调用时的调用栈,如下图所示:
通信效率
可以发现,整个流程中都是机器码的传递,而 JNI 的通信又和 JavaVM 内部通信效率一样,整个流程通信的流程相当于原生端的内部通信。但是也存在瓶颈。我们可以发现,methodCall 需要编解码,其实主要的消耗都在编解码上了,因此,MethodChannel 并不适合传递大规模的数据。
比如我们想调用摄像头来拍照或录视频,但在拍照和录视频的过程中我们需要将预览画面显示到我们的 Flutter UI中,如果我们要用 MethodChannel 来实现这个功能,就需要将摄像头采集的每一帧图片都要从原生传递到 Dart 侧中,这样做代价将会非常大,因为将图像或视频数据通过消息通道实时传输必然会引起内存和 CPU 的巨大消耗。为此,Flutter 提供了一种基于 Texture 的图片数据共享机制。
Texture 和 PlatformView 不在本文的探讨范围内,这里就不再深入展开了,有兴趣的读者可以自行查阅相关资料作为扩展知识了解。
那接下来,我们就进入本文的第三篇章吧,Flutter 混合开发模式的探索。
3. Flutter 混合开发模式
3.1 混合模式
Flutter 混合工程的结构,主要存在以下两种模式:
-
统一管理模式
-
三端分离模式
两种混合模式
所谓统一管理模式,就是一个标准的 Flutter Application 工程,而其中 Flutter 的产物工程目录(ios/
和 android/
)是可以进行原生混编的工程,如 React Native 进行混合开发那般,在工程项目中进行混合开发就好。但是这样的缺点是当原生项目业务庞大起来时,Flutter 工程对于原生工程的耦合就会非常严重,当工程进行升级时会比较麻烦。因此这种混合模式只适用于 Flutter 业务主导、原生功能为辅的项目。但早期 Google 未支持 Flutter Module 时,进行混合开发也只存在这一种模式。
后来 Google 对混合开发有了更好的支持,除了 Flutter Application,还支持 Flutter Module。所谓 Flutter Module,恰如其名,就是支持以模块化的方式将 Flutter 引入原生工程中,**它的产物就是 iOS 下的 Framework 或 Pods、Android 下的 AAR,原生工程就像引入其他第三方 SDK 那样,使用 Maven 和 Cocoapods 引入 Flutter Module 即可。**从而实现真正意义上的三端分离的开发模式。
3.2 混合栈原理
混合栈
为了问题的简洁性,我们这里暂时不考虑生命周期的统一性和通信层的实现,而除此之外,混合导航栈主要需要解决以下四种场景下的问题:
-
Native 跳转 Flutter
-
Flutter 跳转 Flutter
-
Flutter 跳转 Native
-
Native 跳转 Native
3.2.1 Native 跳转 Flutter
Native -> Flutter,这种情况比较简单,Flutter Engine 已经为我们提供了现成的 Plugin,即 iOS 下的 FlutterViewController 与 Android 下的 FlutterView(自行包装一下可以实现 FlutterActivity),所以这种场景我们直接使用启动了的 Flutter Engine 来初始化 Flutter 容器,为其设置初始路由页面之后,就可以以原生的方式跳转至 Flutter 页面了。
// Existing code omitted.// 省略已经存在的代码- (void)showFlutter { FlutterViewController *flutterViewController = [[FlutterViewController alloc] initWithProject:nil nibName:nil bundle:nil]; [self presentViewController:flutterViewController animated:YES completion:nil];}@end
3.2.2 Flutter 跳转 Flutter
Flutter -> Flutter,业内存在两种方案,后续我们会详细介绍到,分别是:
-
使用 Flutter 本身的 Navigator 导航栈
-
创建新的 Flutter 容器后,使用原生导航栈
3.2.3 Flutter 跳转 Native
Flutter -> Native,需要注意的时,这里的跳转其实是包含了两种情况:
-
打开原生页面(open,包括但不限于 push)
-
回退到原生页面(close,包括但不限于 pop)。
如上图,这种情况相对复杂,我们需要使用 MethodChannel 让 Dart 与 Platform 侧进行通信,Dart 发出 open 或 close 的指令后由原生侧执行相应的逻辑。
3.2.4 Native 跳转 Native
Native -> Native,这种情况没有什么好说的,直接使用原生的导航栈即可。
3.3 混合模式
为了解决混合栈问题,以及弥补 Flutter 自身对混合开发支持的不足,业内提出了一些混合栈框架,总得来说,离不开这四种混合模式:
-
Flutter Boost 为代表的类 WebView 导航栈
-
Flutter Thrio 为代表的 Navigator 导航栈
-
多 Engine 混合模式
-
View 级别的混合模式
下面,一一来谈谈它们的原理与优缺点。
3.3.1 Flutter Boost
Flutter Boost 是闲鱼团队开源的 Flutter 混合框架,成熟稳定,业内影响力高,在导航栈的处理思路上没有绕开我们在 3.2 节中谈及的混合栈原理,但需要注意的是,当 Flutter 跳转 Flutter 时,它采用的是 new 一个新的 FlutterViewController 后使用原生导航栈跳转的方式,如下图所示:
flutter boost
这么做的好处是使用者(业务开发者)操作 Flutter 容器就如同操作 WebView 一样,而 Flutter 页面就如同 Web 页面,逻辑上简单清晰,将所有的导航路由逻辑收归到原生侧处理。如下图,是调用 open 方法时 Flutter Boost 的时序图(关键函数路径),这里可以看到两点信息:
-
混合导航栈的逻辑主要包括原生层、通信层、Dart 层。
-
Flutter Boost 的 open 方法实现逻辑相对简单。
flutter boost open 时序图
但是它也有缺点,就是每次打开 Flutter 页面都需要 new 一个 ViewController,在连续的 Flutter 跳转 Flutter 的场景下有额外的内存开销。针对这个问题,又有团队开发了 Flutter Thrio。
3.3.2 Flutter Thrio
上面我们说到,Flutter 跳转 Flutter 这种场景 Flutter Boost 存在额外的内存开销,故哈啰出行团队今年4月开源了 Flutter Thrio 混合框架,其针对 Flutter Boost 做出的最重要的改变在于:Flutter 跳转 Flutter 这种场景下,Thrio 使用了 Flutter Navigator 导航栈。如下图所示:
flutter thrio
在连续的 Flutter 页面跳转场景下,内存测试图表如下:
内存增量
从这张图表中我们可以得到以下几点信息:
-
红色区域是启动 Flutter Engine 的内存增量,基本接近 30MB,Flutter Engine 是一个比较重的对象。
-
FlutterViewController 带来的内存增量普遍在 12~15MB 左右。
可见,在这种场景下,Thrio 还是做出了一定的优化的。但与之带来的,就是实现的复杂性。我们谈到 Flutter Boost 的优点是简单,路由全部收归原生导航栈。而 Flutter Thrio 混用了原生导航栈和 Flutter Navigator,因此实现会相对更复杂一下。这里我梳理了一下 Flutter Thrio open 时关键函数路径,可以看到,Thrio 的导航管理确实是复杂了一些。
thrio 时序图
3.3.3 多 Engine 模式
以上我们谈及的两种混合框架都是单引擎的,对应的,也存在多引擎的框架。在谈多引擎之前,还是需要先介绍一下关于 Engine、Dart VM、isolate 几个前置知识点。
在第一篇章中我们没有涉及到 Engine 层的源码分析,而着重篇幅去讲解 Framework 层的原理,一是为了第一章的连贯性,二是此处也会单独说到 Engine,还是最好放在此时讲解会更便于记忆与理解。
Dart VM、Engine 与 isolate
(a)Dart 虚拟机创建完成之后,需要创建 Engine 对象,然后会调用 DartIsolate::CreateRootIsolate()
来创建 isolate。(b)每一个 Engine 实例都为 UI、GPU、IO、Platform Runner 创建各自新的 Thread。(c)isolate,顾名思义,内存在逻辑上是隔离的。(d)isolate 中的 code 是按顺序执行的,任何 Dart 程序的并发都是运行多个 isolate 的结果。当然我们可以开启多个 isolate 来处理 CPU 密集型任务。
根据(a)我们可以推出:(1) 每个 Engine 对应一个 isolate 对象,即 Root Isolate。根据(b)我们可以推出:(2) Engine 是一个比较重的对象(前文也有所提及)。根据(c)和 (1) 我们可以推出:(3) Engine 与 Engine 之间相互隔离。根据(d)和 (3) 我们可以推出:(4) Engine 没有共享内存的并发,没有竞争的可能性,不需要锁,也就不存在死锁问题。
好啦,记住这四个结论,我们再来看看 window。
Window
window 是绘图的窗口,也是连接 Flutter Framework(Dart)与 Flutter Engine(C++)的窗口 (5)。
从类的定义上来看,window 是连接 Framework 与 Engine 的窗口。在 Framework 层,window 指的是 ui.window
单例对象,源码文件是 window.dart。而在 Engine 层,源码文件是 window.cc,两者交互的 API 很少,但是一一对应:
/// window.dartclass Window { String/!/ _defaultRouteName() native ‘Window_defaultRouteName’; void scheduleFrame() native ‘Window_scheduleFrame’; String _sendPlatformMessage(String/!/ name, PlatformMessageResponseCallback/?/ callback, ByteData/?/ data) native ‘Window_sendPlatformMessage’; ByteData/?/ getPersistentIsolateData() native ‘Window_getPersistentIsolateData’; /// …}
// window.ccvoid Window::RegisterNatives(tonic::DartLibraryNatives* natives) { natives->Register({ {“Window_defaultRouteName”, DefaultRouteName, 1, true}, {“Window_scheduleFrame”, ScheduleFrame, 1, true}, {“Window_sendPlatformMessage”, _SendPlatformMessage, 4, true}, {“Window_respondToPlatformMessage”, _RespondToPlatformMessage, 3, true}, {“Window_render”, Render, 2, true}, {“Window_updateSemantics”, UpdateSemantics, 2, true}, {“Window_setIsolateDebugName”, SetIsolateDebugName, 2, true}, {“Window_reportUnhandledException”, ReportUnhandledException, 2, true}, {“Window_setNeedsReportTimings”, SetNeedsReportTimings, 2, true}, {“Window_getPersistentIsolateData”, GetPersistentIsolateData, 1, true}, });}
可以发现,这些主要是 Framework 层调用 Engine 层中 Skia 库封装后的相关 API。那就不得不说说它的第二层含义——作为绘图的窗口。
从功能上来看,在界面绘制交互意义上,window 也是绘图的窗口。在 Engine 中,绘图操作输出到了一个 PictureRecorder
的对象上;在此对象上调用 endRecording()
得到一个 Picture
对象,然后需要在合适的时候把 Picture
对象添加(add)到 SceneBuilder
对象上;调用 SceneBuilder
对象的 build()
方法获得一个 Scene
对象;最后,在合适的时机把 Scene
对象传递给 window.render()
方法,最终把场景渲染出来。
window
该图出自:Flutter Framework 源码解析( 1 )—— 开篇和绘图引擎的用法
实例代码如下:
import ‘dart:ui’;void main(){ PictureRecorder recorder = PictureRecorder(); Canvas canvas = Canvas(recorder); Paint p = Paint(); p.strokeWidth = 30.0; p.color = Color(0xFFFF00FF); canvas.drawLine(Offset(300, 300), Offset(800, 800), p); Picture picture = recorder.endRecording(); SceneBuilder sceneBuilder = SceneBuilder(); sceneBuilder.pushOffset(0, 0); sceneBuilder.addPicture(new Offset(0, 0), picture); sceneBuilder.pop(); Scene scene = sceneBuilder.build(); window.onDrawFrame = (){ window.render(scene); }; window.scheduleFrame();}
多 Engine 模式
综上,根据(1)(3)(5)我们可以得出下图的多引擎模式:
多 engine 模式
它有以下几个特征:
-
App 内存在多个引擎
-
每个引擎内有若干个 FlutterVC
-
Engine 与 Engine 之间是隔离的
根据这三个特征,我们可以设想一下其通信层的实现,假设存在两个引擎,每个引擎内又存在两个 FlutterVC,每个 FlutterVC 内又存在两个 Flutter 页面,那这种场景下的跳转就会变得非常复杂(下图出自 Thrio 开源仓库中的README):
多 engine 模式
所以显而易见的,我们不可否认 Engine 之间的逻辑隔离带来了模块间天然的隔离性,但是问题也有许多:
首先如上图所示,通信层设计会异常复杂,而且通信层的核心逻辑依然是需要放在原生侧来实现,如此便一定程度上失去了跨端开发的优势。
其次,我们反复提到 Engine 是一个比较重的对象,启动多个 Flutter Engine 会导致资源消耗过多。
最后,由于 Engine 之间没有共享内存,这种天然的隔离性其实弊大于利,在混合开发的视角下,一个 App 需要维护两套缓存池——原生缓存池与 DartVM 所持有的缓存池,但是随着开启多 Engine 的介入,后者缓存池的资源又互不相通,导致资源开销变得更加巨大。
为了解决传统的多 Engine 模式所带来的这些问题,又有团队提出了基于 View 级别的混合模式。
3.3.4 View 级别的混合模式
基于 View 级别的混合模式,核心是为每个 window 加入 windowId 的概念,以便它们去共享同一份 Root Isolate。我们刚才说到,一个 isolate 具有一个 ui.window
单例对象,那么只需要做一点修改,把 Flutter Engine 加入 ID 的概念传给 Dart 层,让 Dart 层存在多个 window,就可以实现多个 Flutter Engine 共享一个 isolate 了。
如下图所示:
View 级别的混合模式
这样就可以真正实现 View 级别的混合开发,可以同时持有多份 FlutterViewController,且这些 FlutterVC 可以内存共享。
那缺点也比较明显,我们需要对 Engine 代码做出修改,维护成本会很高。其次,多 Engine 的资源消耗问题在这种模式下也是需要通过对 Engine 不断裁剪来解决的。
4. 工程化探索
4.1 编译模式
Dart 天然支持两种编译模式,JIT 与 AOT。
4.1.1 JIT 与 AOT
所谓 JIT,Just In Time,即时编译/运行时编译,在 Debug 模式中使用,可以动态下发和执行代码,但是执行性能受运行时编译影响。
JIT
所谓 AOT,Ahead Of Time,提前编译/运行前编译,在 Release 模式中使用,可以为特定平台生成二进制代码,执行性能好、运行速度快,但每次执行都需要提前编译,开发调试效率低。
AOT
4.1.2 Debug、Release、Profile
对应的 Flutter App 存在三种运行模式:
-
Debug
-
Release
-
Profile
运行模式
因此,我们可以看出,在开发调试过程中,我们需要使用支持 JIT 的 Debug 模式,而在生产环境中,我们需要构建包为支持 AOT 的 Release 模式以保证性能。
那么,这对我们的集成与构建也提出了一定的要求。
4.2 集成与构建
所谓集成,指的是混合项目中,将 Flutter Module 的产物集成到原生项目中去,存在两种集成方式,区别如下:
源码集成 vs 产物集成
可以发现源码集成是 Flutter dev 分支需要的,但是产物集成是 Flutter dev 以外的分支需要的。在这里,我们的混合项目需要同时支持两种不同的集成工程,在 Flutter dev 分支上进行源码集成开发,然后依赖抽取构建产物发布到远程,如 iOS 构建成 pods 发布到 Cocoapods 对应的仓库,而 Android 构建成 AAR 发布到 Maven 对应的云端。于是,其他分支的工程直接 gradle 或者 pod install 就可以更新 Flutter 依赖模块了。