Flutter混合栈路由实践与优化转载
导语
在 Flutter 和原生混合开发的场景里,路由是绕不开的一个话题。但业内的方案中仍存在内存异常,对官方底层的修改也需要不断踩坑。我们在项目实践中,抽离出了一套混合栈路由框架。对内存进行了进一步优化,清晰了对底层代码的修改,同时更易于 Flutter SDK 升级。
一、背景及综述
Flutter 在目前跨平台方案中有更好的平台一致性以及更优的体验。但对于本身已有成熟的业务代码的项目来说,更多的是采用混合栈的方式,在不变更原有 App 业务的基础上,将 Flutter 能力扩展为子模块进行接入和开发。这样并不影响原有的业务和原生能力,又可以结合业务需求进行技术选择。
混合栈涉及到 Flutter 页面与原生页面的跳转。而官方的路由方案,在多引擎下有着通信隔离,资源不共享,极大的内存损耗等缺陷。
业内采用较广泛是单引擎复用方案,但这仍有不少痛点,体现在两个方面:
- 混合栈路由在使用时,仍有内存异常;
- 底层代码的修改,需要不断踩坑。
为了解决这些问题,心悦抽离出了一套混合栈路由框架 TRouter。
- 单引擎下内存进一步优化,解决了打开多个 Flutter 页面时内存异常增长(Boost 等方案下仍有内存异常);
- 规避底层代码修改不可见导致的项目风险,解决过度耦合 io.flutter 包导致的 sdk 更新困难。
本文的目标是阐述 Flutter 实践混合栈路由中遇到的痛点,以及 TRouter 是如何去解决的。最后会对目前的方案进行横向对比,讲述下一步的计划。
二、混合集成面临的问题
项目最终明确选用了单引擎复用的方案,业内未解决而我们面临的痛点有两个:
- iOS侧的内存增长异常;
- 2. Android侧 底层修改不透明给项目带来风险。在介绍TRouter之前,本节会讨论问题的成因,以及为什么说业内方案存在缺陷。
官方并没有很好解决混合栈路由所遇到的问题。
Flutter 的技术链路是建立在 C++ 编写的 Engine 和 Dart 编写的 Framework 层组成。主要构成如下图所示:
可以明确的是:
Engine 管理着 Flutter 所使用的四个线程,本身是一个较重的一个对象。
isolate 管理着 Dart 层内存和单线程控制的运行实体。isolate 本身意思是“隔离”,每个 isolate 之间的内存和逻辑是隔离的,所以对应的 Engine 也是资源不共享的。
Engine 依赖于原生的某个视图组件提供渲染的能力,比如纯 Flutter 应用就只在单独一个 Activity/ViewController 上创建了 Engine 以提供 Flutter 的视图渲染。
在混合栈路由上,虽然 Dart 层本身有提供 navigator 等路由方式,但当我们把 Flutter 集成为原生的模块或能力时,一定会出现 Native -> Flutter -> Native -> Flutter… 这种混合页面跳转情况。
这样存在问题是:如何保存 Flutter 页面的状态,并且在页面回退或跳转时,在正确的时机恢复或切换 Flutter 的渲染内容。
1. 多引擎方案
Google 官方提供的是 keep it simple 的方案,即间隔的 Flutter 页面单独使用一个新的 Engine 来单独维持一份视图渲染,跳转时就无需考虑 Dart 层页面切换。
这种方案弊端很多,首先是 Engine 的线性增多,带来内存的极大损耗。如下图所示,Android 端多引擎下打开 5 个页面内存增量对比:
其次由于 isolate 隔离,Dart 侧图片缓存等资源也无法共享,所有通信都需要经过原生,使通信有极高的复杂度。
所以多引擎不能满足项目的性能要求。
2. 单引擎浏览器方案
由于多引擎的缺陷,业内的做法一般是对 isolute 或 Engine 进行复用来解决。影响力较大的是以 FlutterBoost 和 Thrio 为代表的单引擎浏览器方案。
即把 Activity/ViewController 作为承载 Dart 页面的浏览器,在页面切换时对单引擎进行 detach/attach,同时通知 Dart 层页面切换,来实现 Engine 的复用。
Thrio与Boost区别在于:在Flutter页面连续跳转时,只使用同一个 Activity/ViewController 承载。
由于只持有了一个 Engine 单例,仅创建一份 isolate,Dart 层是通信和资源共享的,内存损耗也得以有显著的降低。下图所示是 Android 侧单引擎下打开 5 个页面内存增量对比:
可以看出 Android 侧跳转 Flutter 页面的内存消耗已降低到接近原生。
痛点一:iOS侧内存增长异常
但在 iOS 侧,我们发现了打开新的承载 Flutter 页面的 ViewController 仍会有 10M 左右的内存增量。
对此,Boost 的建议是同一时间下,人为控制 Flutter 页面在 5 个以内,来避免内存过大的问题。哈啰单车的 Thrio 就是在 Boost 基础上提出的优化方案,即在 Flutter->Flutter 的情景下,避免创建 ViewController,而是在 Dart 层进行路由切换。但可以看出,该方案在增加双端路由复杂度的同时,并没有解决 Native->Flutter 的内存大幅增长。
这两个方案都没有真正解决内存的异常问题。
痛点二:Android侧,底层不可见的修改给项目带来风险
此外,在 Android 侧,单引擎实现依赖于修改官方的 io.flutter 包。但我们并不清楚外部方案具体做了哪些底层修改,这给项目带来风险。
在预研单引擎路由方案的时候,我们发现大多是直接拉取官方 io.flutter 包来进行底层改造。这对于使用者就像一个黑盒子,并不知道什么地方做了什么修改,对出现的 bug 更无法排查。并且这种耦合依赖 io.flutter 包的方式,也会对 Flutter SDK 升级带来困难。
事实上,Github上 Boost 目前仍还有 160+ 的 issue 未解决,支持 Flutter SDK 版本的更新速度也不尽人意。所以我们打算自己踩一遍坑,寻求对官方代码最小的修改,并使修改可见,来保证路由的稳定性,问题可排查性。
三、实现方式及痛点解决
在明确业内方案和面临的痛点之后。我们聚焦于痛点的解决,推出了一套更优的混合栈路由方案 TRouter。
1. 整体框架
整体框架上,仍采用单引擎浏览器方案。用 Activity/ViewController 承载 Dart 页面的方式,把路由收归原生,维持唯一的单引擎实例。
在页面生命周期变更时对单 Engine 进行 attach/detach,同时传递 url、params 通知 Dart 层进行页面切换。
值得注意的是,Dart 和 Native 层是职责分离的。
Dart 层只负责接收原生端生命周期信息,并得到页面的 url 与 params,来进行 Flutter 的页面渲染。
而 Native 层统一接管了页面的跳转和 url 解析,在跳转 Flutter 页面时,感知上仍是打开一个 Activity/ViewController。
这样,混合栈路由与原生路由的体验并无区别,可以轻松接入原有项目的路由逻辑。
2. 内存优化
iOS 端即使实现了单引擎复用,但仍会在创建 Flutter ViewContoller 时有 10M 的内存异常增长。这就需要我们从底层来理解 Flutter 的渲染过程。
Flutter 渲染是由 Vsync 信号触发 UI 刷新,再在 Dart 层进行 Widget 布局、绘制生成 LayerTree。然后渲染线程进行栅格化及合成,最终把渲染的结果设置到 layer.contents 里进行屏幕显示。
定位到最后一步,由于渲染出的结果是位图,内存占用比较大。当每次新建一个 FlutterViewController 时会有一个渲染后的位图与之对应,会导致每次新增一个页面时会有一个较大的内存增长。
由此,可以确定内存的优化思路。即在页面完全退出(viewDidDisappear)后,将 FlutterView.layer.contents 对象设置为 nil,回收当前页面的位图对象,在页面即将展示(viewWillAppear)时重新渲染出新页面。
这样,在保证路由体验的同时,避免了 iOS 侧的内存异常。优化效果如下:
在连续打开 Flutter 页面里,内存也能平稳保持在正常水平。
3. 底层改造
Android 端 io.flutter 包的代码,并没有支持 Engine 的复用,所以会涉及到官方代码的修改。
- 从项目风险考虑,我们在方案设计时有三个核心的诉求:
- 对官方代码做最小的修改,避免有引入额外 bug 的风险;
- 对代码的变更是明确清晰的,在遇到线上问题时,可以第一时间进行分析和排查;
可复用的诉求,易于 Flutter SDK 的迭代更新。
在理解底层代码和不断踩坑后,我们明确了 Engine 可以在外部初始化,并且对引擎切换的代码修改是有限的,这是实现诉求的前提。最终我们把底层改造逻辑分离,集合到 FlutterFixPlugin 插件里。
使用操纵字节码 Hook 的方式,把每一个问题点的修改封装为一个策略,一个策略包含多个代码改动片段,从而达到改动可见,与 SDK 版本适配的目的。
FlutterFixPlugin 插件对代码的改造是非侵入式的,仅需要在 .gradle 文件中进行依赖。
apply plugin: 'com.tencent.fixflutter'
插件支持根据不同 Flutter 版本进行策略的增减与变更,工程结构如下:
方案优势体现在如下两方面:
(1)修改可见和问题覆盖
可以清晰明确底层代码的修改内容,并细分到了每条执行语句。到目前为止,除开对 Engine 复用的必要修改外,插件已经对跳转时页面跳屏,页面白屏,跳转时动画不延续的等问题以及一些官方 issue 进行了适配修改。
(2)多版本的支持
得益于对 io.flutter 包非侵入式修改,我们验证了 Flutter SDK v1.17、v1.20、v1.22,v2.0 等版本上,都可以良好运行。
4. 方案对比
最后,对方案进行一次对比总结:
总结来看,TRouter 混合栈的路由优势在于:
路由方式简单,Dart 层资源共享,有更优的内存性能表现;
项目风险可控,底层代码修改是可见的,Flutter SDK 版本适配更易行。
四、下一步做的事情
Flutter v2.0 升级与 View 级别的支持
3月4日,Google 发布 Flutter v2.0 稳定版,除了对 Web 更高质量的支持与引入空安全外。其中一个重要更新就是提供了多引擎下使用 FlutterEngineGroup 来创建新的 Engine,官方宣称内存损耗仅占 180K。
其本质是使 Engine 可以共享 GPU 上下文、font metrics 和 isolate group snapshot,从而实现了更快的初始速度和更低的内存占用。
虽然目前看起来仍未稳定,也有比较多的问题尚未解决,比如 Dart 层还是是资源隔离的,一套图片资源可能被加载多次。但这让我们看到了混合栈路由回归官方方案的可能。
下一步我们将继续探究 v2.0 的特性,用 v2.0 对多引擎的加持来实现 View 级别的支持。
结语
TRouter 是心悦项目解决 Flutter 路由痛点后的产物。在最开始的接入时,我们想法是能引入稳定可靠的方案,但官方对混合栈的支持偏向薄弱。
而从流传的文章来看,业内的方案跟随 Flutter 版本的更新也不断的在调整。最后应该会趋近于同一套被广泛认可的方式。
从这一角度上讲,所有技术都是不断演进的,最终导向的是更高的性能表现,与最佳的项目实践。
更多思考:
看了本篇文章希望大家对Flutter有更清楚的了解,如果想要学习更多关于Flutter的内容可以阅读以下内容:
美团FlutterWeb性能优化探索与实践
京东在Flutter加载大量图片导致的内存溢出的优化实践