cover_image

Soul Flutter内存治理经验谈

Soul 客户端 Soul技术团队 2023年03月14日 03:25

1 背景

Soul App 整体使用混合技术栈的方式研发,除了原生、Web 容器等技术栈外,还涉及到了 Flutter 开发。使用 Flutter 开发,在保证高性能、体验的情况下,提升了开发效率。但在采用 Flutter 技术过程中,我们逐步发现 App 的内存使用存在上升,在一些场景和调查中发现 Flutter 的内存使用相对较高,随着业务的迭代和发展,这一风险也逐步暴露。在后来的分析调查中,发现 Flutter 内存占用存在较大的比重,也导致在一些业务中触发了低内存的稳定性问题较为突出。

本文基于 iOS 平台、 Flutter 3.0.5版本,就 Soul 在 Flutter 图片组件、内存清理、检测工具三方面如何治理和落地的进行分享。

2 内存疑难问题和处理方案

在 Flutter 内存占用分析发现了两个主要的影响因素:
1.图片组件作为项目开发的最基础组件之一,原生图片组件 Image,存在很多天然的缺陷,其中内存占用明显过高。
2. PlatformView 允许将原生页面嵌入到 Flutter Widget 体系中,但是在混编模式下,会出现内存泄漏问题。

2.1 系统图片组件内存占用过高

2.1.1 问题背景

Flutter 官方也提供了一个图片组件 Image,但是有些天然缺陷:
1.无法访问 Native 端的资源文件。
2.图片缓存缺乏本地缓存能力。
3.图片网络下载方式不可配置。
4.无法复用原生端图片下载库能力。

除此之外,发现在设置了cacheWidth 和 cacheHight 情况下,内存占用依然偏高。下面是在一个图片列表页面中,使用系统组件 Image 来加载网络图片。查看内存占用情况,可以发现增加了300M+内存。

图片

进入页面前

图片

进入页面后

2.1.2 解决方案:外接纹理

目前社区相应场景方案主要有:

  1. 基于官方 Image 组件,扩展缓存能力。

  2. 复用原生图片库功能,外接纹理方案。

考虑后面扩展性以及复用原生图片的缓存管理,我们决定使用外接纹理方式。

2.1.2.1 实现流程

纹理可以理解为 GPU 层代表图像数据的对象。Flutter 提供了 Texture 控件将纹理绘制到显示屏。 以 iOS 为例:
a.创建实现 FlutterTextrure 接口的对象,该对象负责管理纹理数据。
b.使用 FlutterTextureRegister 来注册创建的对象,生成一个纹理 Id。
c.将该 Id 通过 channel 传递给 Flutter 侧,Flutter 侧就能使用对应纹理了。

本质上原生侧和 Flutter 侧通过纹理id来对齐使用哪个对象,同时通过 CVPixelBuffer 来传递具体纹理数据,这个数据 Flutter 侧是无感的,只需要原生侧提供即可。
在上述基础流程的基础上,采用引用计数进行纹理的复用,同一个图片使用同一个 textureID,减少内存抖动。

2.1.2.2 核心收益

采用外接纹理实现的图片组件,有着更少的内存占用,内存峰值也有较大幅度的降低。对比前面同样页面,发现退出后从641M降为520M。

图片

进入页面前

图片

进入页面后

2.2  PlatformView 内存泄漏

2.2.1 问题背景

下面是进入某个页面前,以及退出后内存占用情况,可以发现退出页面还残留了100M+内存, 同时发现第二次进入页面居然比进入前内存还少。

图片

进入页面前

图片

第一次进入页面

图片

退出页面后

图片

第二次进入页面

针对该页面使用 Memory Graph 查看,发现有内存泄漏。这里是 FlatformView 被 FlutterPlatformViewsController 对象持有了。
图片

在 Flutter 层打断点,发现 PlatformView 对应的 dispose 是调用了,但是原生端没有触发对象的 dealloc。

查看 Flutter 源码发现,PlatformView 对象的 dispose 里面会调用对应 Channel ('flutter/platform_views')的 dispose 事件。

@overrideFuture<void> dispose() async {  if (_initialized) {    await SystemChannels.platform_views.invokeMethod<void>('dispose', viewId);  }}

同时 Flutter 引擎原生端会监听,一旦收到这个 dispose 事件,会获取到参数中 viewId,并放到一个 views_to_dispose_ 的集合里面,等待后续被清理。

viewId 是 Flutter 自己生成的一个自增 Id,每一个 FlatformView 对象会对应一个 viewId, 从0开始,用于标记是哪个对象的消息。

void FlutterPlatformViewsController::OnDispose(FlutterMethodCall* call, FlutterResult& result) {  NSNumber* arg = [call arguments];  int64_t viewId = [arg longLongValue];
if (views_.count(viewId) == 0) { result([FlutterError errorWithCode:@"unknown_view" message:@"trying to dispose an unknown" details:[NSString stringWithFormat:@"view id: '%lld'", viewId]]); return; } // We wait for next submitFrame to dispose views. views_to_dispose_.insert(viewId); result(nil);}

关键就是什么时机会触发这个清理。Flutte 源代码中有这样一段注释 " We wait for next submitFrame to dispose views ",其实这已经说明了,会在下一次调用 submitFrame 的时候去释放。实际调试也发现在页面重绘的时候会触发 submitFrame 方法,submitFrame 内部会调用 DisposeViews 来进行清理。

void FlutterPlatformViewsController::DisposeViews() {  // * 省略 *  views_to_dispose_.clear();}

我们项目中通过 flutter_boost 使用单引擎模式,大部分场景都是跳转单个 Flutter 页面,然后退出返回到原生页面。这导致 Flutter 页面退出后,没有重绘 Flutter 页面的时机,那么 PlatformView 就不会释放。

2.2.2 解决方案:空页面重绘

我们很自然想到可以在退出 Flutter 页面后,再跳转一个 Flutter 空页面,来强制触发重绘。但是这样会有一个问题:怎么算 Flutter 页面退出完成?因为我们使用了 flutter_boost,以 iOS 为例,flutter_boost 是在 原生页面生命周期函数 didMoveToParentViewController 触发的时候才算退出。

我们尝试在 didMoveToParentViewController 时候,创建一个 Window 用于跳转一个空页面,然后很快关闭。

// 打开一个空白页,渲染首帧/释放页面 SOFlutterFirstViewController* vc = SOFlutterFirstViewController.new; vc.view.backgroundColor = UIColor.clearColor; UIWindow* lastWindow = [UIApplication sharedApplication].keyWindow; [lastWindow.rootViewController presentViewController:vc animated:NO completion:^{     dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{         [vc dismissViewControllerAnimated:NO completion:nil];     }); }];

2.3 IOSurface 内存残留

2.3.1 问题背景

空页面重绘方案在一些简单的 Flutter 页面中,的确可以奏效的,直到我们遇到一个需要快速切换的页面。该页面业务上支持一键切换房间,表现形式上需要退出当前房间页面,然后自动加入另一个房间页面,这个过程要自然流畅。这个时候发现针对该场景,偶尔会出现切换完成页面后,页面无法交互的问题。其实原因很简单,就是针对上面关键流程都是异步的,很有可能跳转下一个页面时候,前面流程还没有完成。通过查看页面层级,发现是空白页没有移除。事实上该方案除了这个问题,还有一个比较突出的问题。在使用 PlatformView 做 RTC 视频流渲染的时候,发现有不少 IOSurface 大对象残留。

下面是通过 vmmap -summary 命令,分析页面退出后的内存快照 memory graph, 发现 IOSurface 占用明显过大。IOSurface 占用了230M +。

图片

继续通过 vmmap 命令分析 IOSurface 内存区域, 发现有多个 IOSurface 大内存对象。图片

结合这些问题,我们最终尝试了新方案:引擎重置。

2.3.2 解决方案:引擎重置

引擎重置是先把引擎销毁,再重新构建。重新构建的好处相当于预热,减少白屏情况。引擎重置也是有一定成本的,主要是重新创建引擎的过程。但是面临日益严峻的内存问题,在当时引擎重置方案是最有效,也是成本最低的一套方案。另外针对重新创建引擎,我们做了监听优化、插件缓存、延时重置,将重置过程的影响尽量最低。

图片

2.3.2.1 检测时机

页面退出的时候根据 pop 或者 dismiss 会调用下面2个方法中的一个,在这2个生命周期方法调用的时候检测即可。

- (void)didMoveToParentViewController:(nullable UIViewController *)parent;- (void)dismissViewControllerAnimated: (BOOL)flag completion: (void (^ __nullable)(void))completion;

2.3.3.2 检测方式

有可能有连续多个 Flutter 页面,如果只是页面退出就去重置,就会出现下层 Flutter 页面异常的情况。我们需要判断容器内已经没有 Flutter 页面了,才能去重置。

flutter_boost 里面有个 FBFlutterContainerManager 对象,该对象记录页面状态,包括容器内有多少个 flutter 页面 containerSize。可以通过判断 containerSize 是否为0,来控制是否重置。

有时候我们需要退出 Flutter 页面,然后再跳转下一个 Flutter 页面。这种情况上面的检测方式就不能满足了,针对这种情况,添加了一个是否重置的标记位,用于判断是否页面退出的时候 是否需要重置。为了使用方便,我们在 Flutter 层封装了对应方法。

/// 用于先退出页面,然后再弹出新页面void commonRouteAfterPop({  String nativePath = "",  String flutterPageId = "",  Map<String, dynamic>? params,  int delayMilliseconds = 500,  bool withContainer = false}) {
// 先限制不重置 EngineUtil.shouldResetEngine(reset: false);
// 退出当前页面 pop();
// 延时跳转 Future.delayed(Duration(milliseconds: delayMilliseconds), () { SoulNavigator route = commonRoute(nativePath: nativePath, flutterPageId: flutterPageId); route.navigate(withContainer: withContainer);
// 1S后设置重置标记位 // 重置标记位的时候,容器侧同样会检测是否去重置。 Future.delayed(Duration(milliseconds: 1000), () { EngineUtil.shouldResetEngine(reset: true); }); });}

2.3.3.3 重置优化

a. Soul 是动态注册 Flutter 插件,重置会重新注册。这里会在第一次注册完成的时候,将插件类型缓存起来,后续直接使用缓存注册。
b. 重置期间,也有可能收到 Flutter 消息。容器内部会判断当前是否在重置,重置期间会先把调用方法缓存起来,等重置结束后再调用缓存方法

2.3.3.4 核心收益

该方案上线后,降低了线上内存水位和 OOM 率。对比前面同样页面,发现退出后从416M 降为305M。

图片

进入页面前

图片

第一次进入页面

图片

退出页面后

图片

第二次进入页面

3 内存检测工具

引擎重置方案,一定程度上解决我们内存泄漏的问题,但是无法定位出到底哪边有问题,无法形成正向推动,算是一个兜底方案。不同于原生开发的经验,在开发 Flutter 时候,往往写完代码,我们都无法确定代码有没有问题。这个时候拥有一个 Flutter 内存检测工具就变得尤为重要了。

目前市面上内存泄漏检测的方案主要有:

a. Expando + VM Service 的弱引用检测。
b. 
Dart VM 定制的底层弱引用检测。
c. OpenGL 图形资源监控。

得益于社区,通过调研,考虑成本和实用性,决定采用第一种方式来完成。
移动端平台上都有各自平台的一套内存检测方式,比如 Android 有 LeakCanary,iOS 有MLeaksFinder。Dart 中内存回收采用的是垃圾回收机制,有没有可能参考 LeakCanary 使用弱引用检查对象是否泄露?答案是肯定的。我们可以对需要监控的实例,通过弱引用对其进行引用,当该实例生命周期结束后,通过弱引用再去访问该实例,如果还在,说明泄漏了,如果为空,说明没有发生泄漏问题。

3.1 Expando

Dart 语言可以使用 Expando 来作为弱引用,很像 NSHashTable / WeakHashMap,不会增加 key 属性的引用计数,同时 key 对象可以被正常 GC。

存入 Expando 里面的对象,会被 _WeakProperty 包裹存在一个名为 _data 的 List 里。

expando_patch.dart
https://github.com/dart-lang/sdk/blob/main/sdk/lib/_internal/vm/lib/expando_patch.dart#L45
@patchT? operator [](Object object) {  checkValidWeakTarget(object, 'object');
var mask = _size - 1; var idx = object._identityHashCode & mask; var wp = _data[idx];
while (wp != null) { if (identical(wp.key, object)) { return unsafeCast<T?>(wp.value); } else if (wp.key == null) { // This entry has been cleared by the GC. _data[idx] = _deletedEntry; } idx = (idx + 1) & mask; // 被检测对象的_identityHashCode 生成一个 key,封装成一个 _WeakProperty 对象 wp = _data[idx]; }
return null;}

_WeakProperty 是对 C 层 WeakProperty 对象的一个封装类,具体的弱引用实现是在 C 层实现的。

weak_property.dart
https://github.com/dart-lang/sdk/blob/master/sdk/lib/_internal/vm/lib/weak_property.dart

可以通过key去获取里面的对象。

@pragma("vm:entry-point")class _WeakProperty {  // * 省略 *   @pragma("vm:external-name", "WeakProperty_getKey")  external get key;  // * 省略 *}

3.2 VM Service

Dart 里面为了打包体积关闭了反射,所以正常方式是获取不到类似于 _WeakProperty这种私有属性的。不过我们可以通过 VM Service 来获取。VM Service 是用于开发时的虚拟机调试服务,Dev Tools 其实就使用了 VM Service,官方提供 vm_service库 。

vm_service
https://pub.flutter-io.cn/packages/vm_service

通过VM Service还可以获取ObjectId(对象标识符)ObjRef(引用类型)和 Obj(对象实例类型)等数据,方便生成对象链路信息不过 vm_service 也有一定缺陷,就是真机上无法连接 vm_service 问题。vm_service 存在 Single Clinet Mode 问题。

Single Client Mode
https://github.com/dart-lang/sdk/blob/master/runtime/vm/service/service.md#single-client-mode

当 DDS(Dart Development Service)连接到 vm_service 时,vm_service 进入单一客户端模式,之后不再接受其他的 WebSocket 连接,而是将 WebSocket 转发给 DDS,直到 DDS 与 vm_service 断开连接,则 vm_service 才能再次开始接受 WebSocket 请求。当我们真机调试的时候,电脑端的 DDS 会首先连接到手机的 vm_service 的 WebSocket 服务,从而导致手机无法连接到 vm_service

解决这个问题一种方式是关闭调试端的 DDS 服务,但是这种方式大大影响开发效率,因为无法热加载,不过可以在测试期间使用。还一种方式是使用模拟器,我们目前在开发期间会优先使用模拟器开发。

3.3 实现流程

3.3.1 找到主Isolate

找到程序 main.dart 所在的 Isolate,对象在这个 Isolate 里。参考源码

service_manager.dart
https://github.com/flutter/devtools/blob/v0.2.5/packages/devtools_app/lib/src/service_manager.dart#L450

遍历 isolates,找到名字为 main 的 isolate。

///find main Isolate in VMFuture<Isolate?> findMainIsolate() async {  IsolateRef? ref;  final vm = await getVM();  if (vm == null) return null;  vm.isolates?.forEach((isolate) {    if (isolate.name == 'main') {      ref = isolate;    }  });  final vms = await getVmService();  if (ref?.id != null) {    return vms?.getIsolate(ref!.id!);  }  return null;}

3.3.2 获取ObjectId

vm_service 中 api 大都需要 ObjectId 参数去调用,要获取对象信息首先就需要获取到 ObjectId。通过 invoke(isolateId, targetId, selector, argumentIds) 方法实现。

invoke(isolateId, targetId, selector, argumentIds) 
https://github.com/dart-lang/sdk/blob/master/runtime/vm/service/service.md#invoke

a. 通过 generateKey 方法生成一个 key 的引用关系。
b. 通过这个 key 的值存储要检查的 Expando 对象。
c. 通过 keyToObj 方法,传入之前的自增 key,会返回 Expando 对象。
注意这个时候返回的 Expando 对象会被一个 Response 包裹,Response 内部就有 Expando 对象对应的 id。

// 生成 keyint _key = 0;String generateKey() {  return "${++_key}";}
// 根据 key 返回指定对象Map<String, dynamic> _objCache = Map();dynamic keyToObj(String key) { return _objCache[key];}

// 获取ObjectId// 通过generateKey方法生成一个key的引用关系。Response keyResponse = await vms.invoke(mainIsolate.id!, library.id!, 'generateKey', []);final keyRef = InstanceRef.parse(keyResponse.json);
// 通过这个key的值存储要检查的Expando对象String? key = keyRef?.valueAsString;if (key == null) return null;_objCache[key] = obj;
/// 通过keyToObj方法,传入之前的自增key,会返回Expando对象。Response valueResponse = await vms .invoke(mainIsolate.id!, library.id!, "keyToObj", [keyRef!.id!]);final valueRef = InstanceRef.parse(valueResponse.json);return valueRef?.id;

3.3.3 生成引用关系

这一步其实就比较简单了,获取到 ObjectId 后,通过 getobject 方法可以获取对象实例类型 Obj, 通过 Obj 找到对应 _data 里面原始对象就可以了。最后遍历原始对象的 Elements,通过 getretainingpath 方法获取到对象到 GC Roots 的引用链信息。

getobject

https://github.com/dart-lang/sdk/blob/master/runtime/vm/service/service.md#getobject

getretainingpath

https://github.com/dart-lang/sdk/blob/master/runtime/vm/service/service.md#getretainingpath

3.3.4 检测时机

开启检测的时间点,其实和原生工具一样就可以。监听页面路由,push 的时候就记录页面对象, pop 的时候就开始检测页面对象。同时为了检测准确性,通过 vm_service 手动 GC 后,再检测页面对象前。

这里特别说明下使用 flutter_boost 的情况下怎么监听页面导航? 
flutter_boost 重写了页面导航,原生的页面导航是获取不到的。不管你是否通过 flutter_boost 导航类 BoostNavigator 控制页面,最终都会收拢到 flutter_boost 内部处理 (NavigatorExt)。所以你无法通过在上层添加 navigatorObservers 来监听。不过 flutter_boost 页面跳转的方式上做了扩展,包括 PageVisibilityBinding、PageVisibilityObserver、NativeRouterApi、BoostFlutterRouterApi 等。
调研了后最终选定 PageVisibilityBinding 来处理,PageVisibilityBinding 既有页面 route 信息,而且我们正常页面的跳转都会走对应回调。

3.4 业务实战

该工具上线后,陆续发现了页面内存泄漏的情况,这里列举2个。

图片

监听没有移除(图1)

图片

路由参数(图2)

3.4.1 页面PageVisibilityBinding监听没有移除(图1)

这类事件注册没有移除的问题,在检测过程中发现很多,是特别需要注意的。一般情况下只需要在 dispose 方法中记得移除就可以了。

@overridevoid dispose() {  PageVisibilityBinding.instance.removeObserver(this);  super.dispose();}

不过我们这里其实是在 dispose 里面移除了,但是也出现了没有释放。很明显的是页面退出的时候出现了问题,稍微跟踪了下代码,发现页面关闭的时候,页面是通过原生 viewControllers 重置的方式设置的,这就导致了 Flutter 层生命周期没有触发。页面退出的时候,Flutter 层可以先退到指定页面, 保证 Flutter 容器生命周期正常。比如:

BoostNavigator.instance.popUntil(route: "/");

这提醒我们,有时候 Flutter 侧内存泄漏可能是原生侧导致的。

3.4.2 useRootNavigator参数问题(图2)

useRootNavigator 是系统 Navigator 提供的一个参数,用于获取远端导航跳转页面,可以用于页面层级多,需要最上层显示页面。
图中发现其实是一个 dialog 回调引起的。这个回调本身没有什么特殊。唯一可能是 SOAlertView 没有被释放,导致内部回调也被持有了。(注释掉 SOAlertView,发现的确正常了)那么 show 方法就是很可疑的地方了,跟踪 show 方法,也很简单就是调用了系统的方法显示一个 Dialog。

void show(BuildContext context) {  showDialog(      useRootNavigator: true, context: context, builder: (context) => this);}

继续跟踪发现, 系统其实就是把传入的参数封装成一个 DialogRoute,然后通过Navigator Push 出来。在 Push 的时候系统会把封装成一个 _RouteEntry 对象,存储在 _history 数组里面。然后关闭 Dialog 的时候,监听 _RouteLifecycle.dispose 消息,移除 _history 里面记录。怀疑是这边没有收到消息,但在这边打印了日志发现也执行了。继续跟踪日志,发现同堆栈里面还有其他Flutter页面的内存泄漏。意识到这个不只是单个页面泄漏,很可能是某个全局对象持有了。再次查看代码,发现 useRootNavigator 这边传递 true 可能是疑似问题,useRootNavigator 本身会找最底层的导航去跳转页面,对应的离开页面的使用,也需要像下面一样通过最底层的导航离开。

Navigator.of(context, rootNavigator: true).pop(context);

4 后续规划

a. 图片组件:图片组件目前一旦下载无法取消,同时图片离屏时候没有及时移除,这些是后续需要优化的。
b. 检测工具:提供检测工具不只是用在开发期间,后续打算结合到测试期,防止开发期有内存泄漏遗漏的情况。
c. 线上监控:添加 Dart 内存泄露线上监控能力,内存占用分析线上分析,覆盖开发与发布阶段。


以上即为本次分享的内容。

感谢您的阅读,如果喜欢欢迎多多关注/留言/点赞。

微信扫一扫
关注该公众号

继续滑动看下一个
Soul技术团队
向上滑动看下一个