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 系统图片组件内存占用过高
Flutter 官方也提供了一个图片组件 Image,但是有些天然缺陷:
1.无法访问 Native 端的资源文件。
2.图片缓存缺乏本地缓存能力。
3.图片网络下载方式不可配置。
4.无法复用原生端图片下载库能力。
除此之外,发现在设置了cacheWidth 和 cacheHight 情况下,内存占用依然偏高。下面是在一个图片列表页面中,使用系统组件 Image 来加载网络图片。查看内存占用情况,可以发现增加了300M+内存。
进入页面前
进入页面后
基于官方 Image 组件,扩展缓存能力。
复用原生图片库功能,外接纹理方案。
考虑后面扩展性以及复用原生图片的缓存管理,我们决定使用外接纹理方式。
纹理可以理解为 GPU 层代表图像数据的对象。Flutter 提供了 Texture 控件将纹理绘制到显示屏。 以 iOS 为例:
a.创建实现 FlutterTextrure 接口的对象,该对象负责管理纹理数据。
b.使用 FlutterTextureRegister 来注册创建的对象,生成一个纹理 Id。
c.将该 Id 通过 channel 传递给 Flutter 侧,Flutter 侧就能使用对应纹理了。
本质上原生侧和 Flutter 侧通过纹理id来对齐使用哪个对象,同时通过 CVPixelBuffer 来传递具体纹理数据,这个数据 Flutter 侧是无感的,只需要原生侧提供即可。
在上述基础流程的基础上,采用引用计数进行纹理的复用,同一个图片使用同一个 textureID,减少内存抖动。
采用外接纹理实现的图片组件,有着更少的内存占用,内存峰值也有较大幅度的降低。对比前面同样页面,发现退出后从641M降为520M。
进入页面前
进入页面后
下面是进入某个页面前,以及退出后内存占用情况,可以发现退出页面还残留了100M+内存, 同时发现第二次进入页面居然比进入前内存还少。
进入页面前
第一次进入页面
退出页面后
第二次进入页面
针对该页面使用 Memory Graph 查看,发现有内存泄漏。这里是 FlatformView 被 FlutterPlatformViewsController 对象持有了。
在 Flutter 层打断点,发现 PlatformView 对应的 dispose 是调用了,但是原生端没有触发对象的 dealloc。
查看 Flutter 源码发现,PlatformView 对象的 dispose 里面会调用对应 Channel ('flutter/platform_views')的 dispose 事件。
@override
Future<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 就不会释放。
我们很自然想到可以在退出 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];
});
}];
空页面重绘方案在一些简单的 Flutter 页面中,的确可以奏效的,直到我们遇到一个需要快速切换的页面。该页面业务上支持一键切换房间,表现形式上需要退出当前房间页面,然后自动加入另一个房间页面,这个过程要自然流畅。这个时候发现针对该场景,偶尔会出现切换完成页面后,页面无法交互的问题。其实原因很简单,就是针对上面关键流程都是异步的,很有可能跳转下一个页面时候,前面流程还没有完成。通过查看页面层级,发现是空白页没有移除。事实上该方案除了这个问题,还有一个比较突出的问题。在使用 PlatformView 做 RTC 视频流渲染的时候,发现有不少 IOSurface 大对象残留。
下面是通过 vmmap -summary 命令,分析页面退出后的内存快照 memory graph, 发现 IOSurface 占用明显过大。IOSurface 占用了230M +。
继续通过 vmmap 命令分析 IOSurface 内存区域, 发现有多个 IOSurface 大内存对象。
结合这些问题,我们最终尝试了新方案:引擎重置。
引擎重置是先把引擎销毁,再重新构建。重新构建的好处相当于预热,减少白屏情况。引擎重置也是有一定成本的,主要是重新创建引擎的过程。但是面临日益严峻的内存问题,在当时引擎重置方案是最有效,也是成本最低的一套方案。另外针对重新创建引擎,我们做了监听优化、插件缓存、延时重置,将重置过程的影响尽量最低。
页面退出的时候根据 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);
});
});
}
a. Soul 是动态注册 Flutter 插件,重置会重新注册。这里会在第一次注册完成的时候,将插件类型缓存起来,后续直接使用缓存注册。
b. 重置期间,也有可能收到 Flutter 消息。容器内部会判断当前是否在重置,重置期间会先把调用方法缓存起来,等重置结束后再调用缓存方法
该方案上线后,降低了线上内存水位和 OOM 率。对比前面同样页面,发现退出后从416M 降为305M。
进入页面前
第一次进入页面
退出页面后
第二次进入页面
引擎重置方案,一定程度上解决我们内存泄漏的问题,但是无法定位出到底哪边有问题,无法形成正向推动,算是一个兜底方案。不同于原生开发的经验,在开发 Flutter 时候,往往写完代码,我们都无法确定代码有没有问题。这个时候拥有一个 Flutter 内存检测工具就变得尤为重要了。
目前市面上内存泄漏检测的方案主要有:
a. Expando + VM Service 的弱引用检测。
b. Dart VM 定制的底层弱引用检测。
c. OpenGL 图形资源监控。
得益于社区,通过调研,考虑成本和实用性,决定采用第一种方式来完成。
移动端平台上都有各自平台的一套内存检测方式,比如 Android 有 LeakCanary,iOS 有MLeaksFinder。Dart 中内存回收采用的是垃圾回收机制,有没有可能参考 LeakCanary 使用弱引用检查对象是否泄露?答案是肯定的。我们可以对需要监控的实例,通过弱引用对其进行引用,当该实例生命周期结束后,通过弱引用再去访问该实例,如果还在,说明泄漏了,如果为空,说明没有发生泄漏问题。
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
T? 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去获取里面的对象。
class _WeakProperty {
// * 省略 *
external get key;
// * 省略 *
}
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 实现流程
找到程序 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 VM
Future<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;
}
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。
// 生成 key
int _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;
这一步其实就比较简单了,获取到 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 信息,而且我们正常页面的跳转都会走对应回调。
该工具上线后,陆续发现了页面内存泄漏的情况,这里列举2个。
监听没有移除(图1)
路由参数(图2)
3.4.1 页面PageVisibilityBinding监听没有移除(图1)
这类事件注册没有移除的问题,在检测过程中发现很多,是特别需要注意的。一般情况下只需要在 dispose 方法中记得移除就可以了。
@override
void 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);
a. 图片组件:图片组件目前一旦下载无法取消,同时图片离屏时候没有及时移除,这些是后续需要优化的。
b. 检测工具:提供检测工具不只是用在开发期间,后续打算结合到测试期,防止开发期有内存泄漏遗漏的情况。
c. 线上监控:添加 Dart 内存泄露线上监控能力,内存占用分析线上分析,覆盖开发与发布阶段。
以上即为本次分享的内容。
感谢您的阅读,如果喜欢欢迎多多关注/留言/点赞。
微信扫一扫
关注该公众号