性能专题>大厂前端敲门砖——Flutter的应用实践>
1
1

大厂前端敲门砖——Flutter的应用实践分享专题

实战篇:Flutter混合栈路由实践与优化

导语  

在 Flutter 和原生混合开发的场景里,路由是绕不开的一个话题。但业内的方案中仍存在内存异常,对官方底层的修改也需要不断踩坑。我们在项目实践中,抽离出了一套混合栈路由框架。对内存进行了进一步优化,清晰了对底层代码的修改,同时更易于 Flutter SDK 升级。

 

一、背景及综述

Flutter 在目前跨平台方案中有更好的平台一致性以及更优的体验。但对于本身已有成熟的业务代码的项目来说,更多的是采用混合栈的方式,在不变更原有 App 业务的基础上,将 Flutter 能力扩展为子模块进行接入和开发。这样并不影响原有的业务和原生能力,又可以结合业务需求进行技术选择。

47980D67-F6C3-4095-B5AF-63588C9FB288.png

混合栈涉及到 Flutter 页面与原生页面的跳转。而官方的路由方案,在多引擎下有着通信隔离,资源不共享,极大的内存损耗等缺陷。

业内采用较广泛是单引擎复用方案,但这仍有不少痛点,体现在两个方面:

  • 混合栈路由在使用时,仍有内存异常;
  • 底层代码的修改,需要不断踩坑。

为了解决这些问题,心悦抽离出了一套混合栈路由框架 TRouter。

  • 单引擎下内存进一步优化,解决了打开多个 Flutter 页面时内存异常增长(Boost 等方案下仍有内存异常);
  • 规避底层代码修改不可见导致的项目风险,解决过度耦合 io.flutter 包导致的 sdk 更新困难。

本文的目标是阐述 Flutter 实践混合栈路由中遇到的痛点,以及 TRouter 是如何去解决的。最后会对目前的方案进行横向对比,讲述下一步的计划。

二、混合集成面临的问题

项目最终明确选用了单引擎复用的方案,业内未解决而我们面临的痛点有两个:

  1. iOS侧的内存增长异常;
  2. 2. Android侧 底层修改不透明给项目带来风险。在介绍TRouter之前,本节会讨论问题的成因,以及为什么说业内方案存在缺陷。


官方并没有很好解决混合栈路由所遇到的问题。

Flutter 的技术链路是建立在 C++ 编写的 Engine 和 Dart 编写的 Framework 层组成。主要构成如下图所示:

E63E1776-F1AB-4D19-91E4-1F97BC2AE92C.png

可以明确的是:

Engine 管理着 Flutter 所使用的四个线程,本身是一个较重的一个对象。

isolate 管理着 Dart 层内存和单线程控制的运行实体。isolate 本身意思是“隔离”,每个 isolate 之间的内存和逻辑是隔离的,所以对应的 Engine 也是资源不共享的。

Engine 依赖于原生的某个视图组件提供渲染的能力,比如纯 Flutter 应用就只在单独一个 Activity/ViewController 上创建了 Engine 以提供 Flutter 的视图渲染。

在混合栈路由上,虽然 Dart 层本身有提供 navigator 等路由方式,但当我们把 Flutter 集成为原生的模块或能力时,一定会出现 Native -> Flutter -> Native -> Flutter… 这种混合页面跳转情况。

6AAD0A2E-A531-4E6A-BBF7-9B165EF3B7DE.png

这样存在问题是:如何保存 Flutter 页面的状态,并且在页面回退或跳转时,在正确的时机恢复或切换 Flutter 的渲染内容。

1. 多引擎方案

Google 官方提供的是 keep it simple 的方案,即间隔的 Flutter 页面单独使用一个新的 Engine 来单独维持一份视图渲染,跳转时就无需考虑 Dart 层页面切换。

这种方案弊端很多,首先是 Engine 的线性增多,带来内存的极大损耗。如下图所示,Android 端多引擎下打开 5 个页面内存增量对比:

80E7D4FB-806A-4BF5-8B81-181E7180DABB.png

其次由于 isolate 隔离,Dart 侧图片缓存等资源也无法共享,所有通信都需要经过原生,使通信有极高的复杂度。

所以多引擎不能满足项目的性能要求。

2. 单引擎浏览器方案

由于多引擎的缺陷,业内的做法一般是对 isolute 或 Engine 进行复用来解决。影响力较大的是以 FlutterBoost 和 Thrio 为代表的单引擎浏览器方案。

即把 Activity/ViewController 作为承载 Dart 页面的浏览器,在页面切换时对单引擎进行 detach/attach,同时通知 Dart 层页面切换,来实现 Engine 的复用。

72094D24-4313-4C73-A4D0-7AFEA5D6685E.png

Thrio与Boost区别在于:在Flutter页面连续跳转时,只使用同一个 Activity/ViewController 承载。

C0869300-EA7E-4432-939A-3882388A3361.png

由于只持有了一个 Engine 单例,仅创建一份 isolate,Dart 层是通信和资源共享的,内存损耗也得以有显著的降低。下图所示是 Android 侧单引擎下打开 5 个页面内存增量对比:

C3D72A1D-00E7-460A-A62B-FD80B59D1E57.png

可以看出 Android 侧跳转 Flutter 页面的内存消耗已降低到接近原生。

痛点一:iOS侧内存增长异常

但在 iOS 侧,我们发现了打开新的承载 Flutter 页面的 ViewController 仍会有 10M 左右的内存增量。

3A332BB5-E4AE-4494-8B52-A5CBCFE711CF.png

对此,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 层进行页面切换。

91FE5F94-0623-44E6-9E9D-1DA8408C28E4.png

值得注意的是,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 里进行屏幕显示。

3985F5BB-8AF2-4F2C-934C-01F80829ADD3.png

定位到最后一步,由于渲染出的结果是位图,内存占用比较大。当每次新建一个 FlutterViewController 时会有一个渲染后的位图与之对应,会导致每次新增一个页面时会有一个较大的内存增长。

A087FF42-AF73-4D14-9330-355DA7498A9C.png

由此,可以确定内存的优化思路。即在页面完全退出(viewDidDisappear)后,将 FlutterView.layer.contents 对象设置为 nil,回收当前页面的位图对象,在页面即将展示(viewWillAppear)时重新渲染出新页面。

这样,在保证路由体验的同时,避免了 iOS 侧的内存异常。优化效果如下:

176AEABA-A46E-4DED-8D59-D8293138455E.png

在连续打开 Flutter 页面里,内存也能平稳保持在正常水平。

3. 底层改造

Android 端 io.flutter 包的代码,并没有支持 Engine 的复用,所以会涉及到官方代码的修改。

  • 从项目风险考虑,我们在方案设计时有三个核心的诉求:
  • 对官方代码做最小的修改,避免有引入额外 bug 的风险;
  • 对代码的变更是明确清晰的,在遇到线上问题时,可以第一时间进行分析和排查;

可复用的诉求,易于 Flutter SDK 的迭代更新。

在理解底层代码和不断踩坑后,我们明确了 Engine 可以在外部初始化,并且对引擎切换的代码修改是有限的,这是实现诉求的前提。最终我们把底层改造逻辑分离,集合到 FlutterFixPlugin 插件里。 

使用操纵字节码 Hook 的方式,把每一个问题点的修改封装为一个策略,一个策略包含多个代码改动片段,从而达到改动可见,与 SDK 版本适配的目的。

CA793D1C-88A4-4DC2-B5E5-81F1D44C820A.png

FlutterFixPlugin 插件对代码的改造是非侵入式的,仅需要在 .gradle 文件中进行依赖。

apply plugin: 'com.tencent.fixflutter'

插件支持根据不同 Flutter 版本进行策略的增减与变更,工程结构如下:

6BFD1B98-6E01-430F-91D4-AFB31D89255E.png

方案优势体现在如下两方面:

(1)修改可见和问题覆盖

可以清晰明确底层代码的修改内容,并细分到了每条执行语句。到目前为止,除开对 Engine 复用的必要修改外,插件已经对跳转时页面跳屏,页面白屏,跳转时动画不延续的等问题以及一些官方 issue 进行了适配修改。

(2)多版本的支持

得益于对 io.flutter 包非侵入式修改,我们验证了 Flutter SDK v1.17、v1.20、v1.22,v2.0 等版本上,都可以良好运行。

4. 方案对比

最后,对方案进行一次对比总结:

21218F8B-5BC8-4A3E-8F0D-C49537DD3303.png

总结来看,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加载大量图片导致的内存溢出的优化实践

 

请先登录,感受更多精彩内容
快去登录吧,你将获得
  • 浏览更多精彩评论
  • 和开发者讨论交流,共同进步