BIGO 技术 | Flutter 内存优化指南


*本文转载自 BIGO技术 公众号,对BIGO技术感兴趣的同学可以关注了解更多内容~


一、前言

Flutter 是谷歌推出的一款跨平台 UI 开发框架,开发效率高效,性能媲美原生,最近一年,逐步落地到 BIGO 旗下 Hello,HelloYo,电商等应用,带来开发效率提升的同时,也引入了大量的风险和挑战,其中 Flutter 内存占用提升带来的 App 稳定性风险最为突出。


BIGO 客户端基础架构组在 Flutter 内存方面做了大量的工作:

(1)质量监控方面;在灵雀 APM 原生性能监控的基础上,研发了 Dart 内存泄露监控,Dart 内存占用分析等全新的子系统,覆盖开发与发布阶段。

(2)基础设施方面;针对 Flutter 原生图片存在的种种问题,研发了基于外接纹理的图片框架作为替代品,内存峰值降低 50% 以上,大大减少了线上 OOM 发生几率。

除此之外,还引入了主动 GC,引擎共享,长列表图片加载优化等多项优化方案。


本文将从 Flutter 内存基础知识开始讲起,介绍下 Flutter 内存现状与风险,核心场景的内存优化,线下与线上全链路监控与分析等内容。



二、背景知识

首先来介绍下Flutter内存相关的基础知识,以 Android 为例,Flutter 的内存占用包括三大部分:Java 内存,Native 内存,Dart 内存,其中 Dart 内存属于 Native 内存的一部分。Android 原生的 Java/Native 内存相关理论想必大家比较熟悉,下面着重介绍下 Flutter 以及 Dart 相关的内存占用。

1、DartVM 运行时介绍
DartVM 运行时架构如下图所示,代码都运行在 Isolate 的环境里。Isolate 可以简单理解为 java 中的线程,不过却和线程有着一些差异。Dart 可以同时运行多个  Isolate,不过  Isolate  间相互隔离,内存不能共享,也就没有死锁的问题,这也是和线程最大的区别。Isolate 间的通信需要借助 Port -- 一种 Dart 提供的异步消息通信机制进行通信来完成。Java 线程中,每个线程使用的是进程的堆内存空间,同进程内的线程共享内存。而 Dart 中每个 Isolate 都有自己独立的的堆内存空间。除了特殊的 VmIsolate 堆内存是共享的之外,其余 Isolate 内存不能相互访问。

图1  DartVM运行时示例图

2、Flutter 引擎内存占用分析

Flutter 应用的内存占用主要包括三大部分(如下图所示)

(1)Flutter 引擎和 Dart VM 本身的内存占用。

(2)Dart 对象的内存占用,这部分内存会分配在 Dart 堆内存上。和 Java 的堆内存结构类似,Dart 也将堆内存分为新生代和老年代:新生代存放生命周期较短、内存占用较小的对象;老年代存放生命周期长、内存占用大的对象。

(3)External 空间,这部分内存空间并不会分配在 Dart VM 的堆内存空间上,但是这些内存占用的大小会被 Dart VM 跟踪。读取的文件、加载的图片都会占用 external 空间。


图2  Flutter应用内存占用示例图

那么如何确定 DartVM 占用的具体内存大小呢?

我们设计了一个混合编译的测试方案,从启动 Flutter 后又退回到原生页面后,仍然有大约 28 MB 的内存占用,其中包括 3.5 MB 的 Native 内存增长,7.53 MB 的 code 内存增长,6.5 MB 的 private other 内存增长,以及 10 MB 的 system 内存增长。当我们尝试调用了 Flutter 提供的引擎销毁接口(如下图所示,ANDROID_SHELL_HOLDER 只是一个共享指针,只会释放 Flutter 引擎本身运行时内存,不会释放 DartVM ),内存继续降低 6 MB 左右( Flutter 引擎本身运行时内存),基本来自 private other 部分内存的释放。



那么可以得出 Flutter 引擎的内存占用大约为 6 MB 左右,Dart VM 约为 3.5 MB,code 资源内存占用 7 MB 左右。


三、Flutter 内存现状和问题

所谓“知己知彼,百战不殆”,为了对 Flutter 带来的内存占用有一个全方位的认识,我们对 Flutter 线上应用内存占用进行了数据收集。

1、线上数据
目前 Likee,Bigo Live,Hello 等应用都接入了灵雀 APM 的线上内存水平实时监控,可以统计到各个页面在销毁和创建之间的内存增量。借助这个工具,我们统计了线上 Flutter 页面内存增量的变化(如下图所示)。以 Android 为例,我们可以看到Flutter页面的增量主要来自于 Native,Graphic,Other PSS 三部分。



我们通过实验设计,将 Flutter 引擎创建的时间提前到 Flutter 页面创建之前,排除掉引擎内存消耗的影响(统计到的页面数据如下图所示):从 50 分位的数据来看,Native 内存降低了10 MB,Other 内存降低了 16 MB,Total PSS 降低了 42 MB 左右,可以认为这部分内存增量的改变是 Flutter 引擎 + Dart VM 的内存影响。



2、引擎内部的内存问题

除了 Flutter 相对原生组件带来的内存提升,开发过程中也遇到很多引擎内部引发的内存问题,比如 OOM,内存泄露,内存回收缓慢等,官方的 Issue 中也有很多类似的反馈。

最常见的比如 iOS 上展示 Emoji 表情带来的内存飙升(https://github.com/flutter/flutter/issues/36358),Skia 自身的内存泄露(https://github.com/flutter/flutter/issues/47108,快速滑动图片列表导致的 OOM 等。

除了内部 Flutter 引擎版本即时同步官方修改来解决问题外,BIGO 内部也针对很多场景进行了专项优化,比如长列表图片内存优化,基于外接纹理的图片管理框架,引擎共享等,下面会详细介绍。



四、Flutter 内存优化

首先来看图片组件,作为项目开发的最基础组件之一,同样也是内存消耗大户,对一个应用的质量有举足轻重的影响。在 Flutter 开发中,Image 作为 Flutter 的原生图片组件,存在很多天然的缺陷:

(1)单独资源目录,无法访问 Native 端的资源文件,混编项目可能会有同一份资源两份内存占用的情况。

(2)Flutter 本身内存缓存较为简陋,没有磁盘缓存。

(3)长列表滑动卡顿严重。

(4)图片网络下载方式不可配置。

下面着重介绍下基于外接纹理的图片框设计。

1、基于外接纹理的图片框架设计

为了进一步减少图片内存占用,复用原生端成熟的图片下载、缓存组件,我们决定采用外接纹理的方案,来代替 Flutter Image 组件,以获得更好的体验。

组件核心是利用 Flutter 的外接纹理组件 Texture,配合 Dart 层的缓存管理策略+复用原生端的图片库实现的,原理如下:


另外,我们也采用了几种优化策略(整体架构如下),增加内存复用,减少内存占用,降低峰值:

(1)纹理缓存:采用引用计数和 LRU 缓存来进行纹理的复用,减少内存抖动。

(2)长列表优化:主要针对之前发现的在原生 Image 组件在长列表场景下的缺陷进行优化,包括:串行队列分发,多阶段拦截,缓存策略优化。

(3)纹理复用:同一个图片使用同一个 textureID。


图3   基于外接纹理的图片框架 架构图

测试结果表明,采用外接纹理实现的图片组件,有着更少的内存占用,内存峰值也有较大幅度的降低,快速滑动过程中,内存也更加平稳,加载大尺寸图片也不会出现原生 Image 组件极易出现的 OOM 问题(以 Android 为例,数据如下)。

图4  Android 小尺寸图片(10KB~200KB)


图5  Android 大尺寸图片(>1M)

2、引擎共享方案
在纯 Flutter 项目中,应用整个生命周期只有一个 FlutterView,依靠 Flutter Framework 的路由在 FlutterView 完成界面跳转,整个 Flutter 内存占用为如下三部分:


但在混编项目中,很容易出现 FlutterView 和 NativeView 多级嵌套的现象,如下图所示,其中多个 FlutterView 的 Engine 是可以考虑共享,以达到减少内存占用的目的。



BIGO 采用了闲鱼开源的 FlutterBoost 作为引擎共享方案,在保证稳定性的同时降低引擎不复用带来的内存消耗。



3、主动释放内存的尝试

上述的方案均聚焦于降低 Flutter 引擎和核心组件带来的内存消耗,除此之外,我们也尝试了一些主动释放内存的方法,在适当的时机(比如 Android 的 onTrimMemory 和 onLowMemory 回调)时主动回收内存,降低 OOM 发生几率。

首先尝试了 Engine 自带的 Destroy 接口,该接口会释放除了 DartVM(被缓存)的所有内存,但是在实际的测试过程中发现,某些情况下会导致 APP 崩溃,存在一定的风险。

另外一种方式就是尝试显式调用 GC,虽然大多数场景下,开发者并不需要关心 DartVM 的 GC 时机,但是在某些极端场景下,显式调用 GC 可以有效的降低应用发生 OOM 的几率。

前面提到过,Flutter 与 Dart 扩展库并没有显式暴露任何 GC 接口,如果我们需要使用,则必须修改官方引擎代码,为后续的引擎升级带来潜在成本。对此,我们参考了 Flutter 中对 Dart 层接口进行扩展的方式,新建一个类对已有的接口进行扩展和导出(如下图所示),从物理上进行隔离,方便快速完成引擎升级。



五、Flutter 内存监控

工欲善其事必先利其器,为了快速的定位和分析 Flutter 内存问题,BIGO 客户端基础架构组结合已有的基础设施,研发出一套覆盖开发期间,测试期间和线上全链路的监控分析系统和工具。

1、线上内存水位实时监控

Android/iOS 原生占用内存监控已经非常成熟,BIGO 内部主要使用已有的灵雀 APM 进行实时监控。

DartVM 的内存消耗监控却没有那么直接,官方 SDK 没有可以获取到这些信息的接口,所以我们也和扩展 GC 接口一样,扩展了获取 Dart VM 信息的接口,并采用类似 native 端的策略,来取到各个 Flutter 页面的 Dart VM 的内存增量信息。

Dart 层的内存水位监控主要包括(数据格式如下):

(1)内存总容量。

(2)已使用内存。

(3)External 内存使用量 。



2、Dart 内存泄漏检测

在 Flutter 开发中,我们可以使用 Observatory 直接进行 Dart 内存对象的分析,但Observatory 展示的数据是所有 Dart 对象的信息,有时候并不是那么直观。我们需要一个工具来自动监控并检测内存泄露。

Dart 内存泄漏检测主要使用了弱引用 Expando+GC 的原理来判断是否有内存泄漏对象存在。

首先我们把需要检测的对象,放入到 Expando 中,Expando 会根据被检测对象的_identityHashCode 生成一个 key,并进而封装成一个 _WeakProperty 对象,然后弱引用持有key。_WeakProperty 是对 C 层 WeakProperty 对象的一个封装类,具体的弱引用实现是在 C 层实现的。之后主动触发一次 GC,再遍历 Expando 集合中的数据,如果 key 的对象不为空,就说明被检测对象仍然存在着引用链,存在 Dart 对象的内存泄漏。

由于 Expando 存储引用的 map 是一个私有变量,Dart 又不支持反射,一般无法获取。我们可以借助 Dart SDK 提供的 vm_service 服务实现这个私有变量获取,Observatory 的实现基础也是依赖这个服务。基于以上原理,最终实现效果如下图所示:


图7  Flutter 内存泄露检测工具效果图

3、Native 内存泄漏分析

Flutter 的内存泄露除了一部分属于上述的 Dart 内存泄露之外,还存在一些原生平台 Native 内存的泄露。

以 Android 为例,主要使用了客户端基础架构组研发的 NativeLeakCanary,通过对关键内存操作的记录和分析,定位到泄露发生的场景,使用效果如下:





五、总结

内存优化只是 Flutter 基础质量建设的冰山一角,整个基础质量还有很多其它方面的工作也在不断的探索和建设,包括卡顿、启动耗时、包大小等等,以覆盖更多的业务场景,提供更好的开发和产品体验。另外我们也在提高基础设施的自动化和流程化,使开发同学可以聚焦于业务开发, 提高研发效率。

我们相信 Flutter 的 UI 跨端一致性、几乎接近原生的性能,以及不断成长的生态,会在将来的客户端开发模式中占据 C 位,使所有的业务以及开发者从中受益。



全部评论

相关推荐

4 3 评论
分享
牛客网
牛客企业服务