当一个 APP 随着业务发展,无用功能越堆越多,参与开发的人员队伍越发壮大,代码大爆炸式膨胀,尽管有一系列的例如人工review、代码规范、静态lint检查之类流程上的克制手段,也止不住坍缩的小宇宙往深不可测无法捉摸的黑洞演进。其中,最为令人头疼的问题莫过于“内存泄漏”。

什么是内存泄漏?很常见却也很不起眼,你我随手写段代码就能轻松让一个对象被垃圾回收器(GC)视而不见。

如下面这一段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class WidgetA extends StatefulWidget {
...
@override
_WidgetAState createState() => _WidgetAState();
}

class _WidgetAState extends State<WidgetA> {
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () async {
var r = await fetchResult();
if (mounted) {
setState(() {
this.result = r;
});
}
},
child: ...
);
}
}

你能在阅读完这段代码的同时找到那个可能被泄漏的对象吗?

没错,context 可能会短时间泄漏
但,得益于 Dart 积极高效的 GC 策略,这种泄漏已经算是内存泄漏问题中的可以忽略不计的轻症了。(扩展阅读:Flutter: Don’t Fear the Garbage Collector

又如这一段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
class _WidgetAState extends State<WidgetA> {
@override
void initState() {
super.initState();
Provider.of<AuthStateBloc>(context).stream.listen(onAuthStateChanged);
}
void onAuthStateChanged(AuthState state) {
if (mounted) {
setState(() {
this.state = state;
});
}
}

有着丰富的 Flutter 开发经验的你,还是很轻松地就发现了,stream.listen 的返回对象StreamSubscription没有被cancel,导致_WidgetAState 和 它的 Element (context)可能都被泄漏了,直到祖节点的 Provider<AuthStateBloc> 被卸载才可能被 GC 回收。

那么回到标题,有没有工具可以程序化的发现内存泄漏呢?

首先要解决的问题是怎么发现内存泄漏

有没有可能在一个对象代码逻辑上被丢弃的时候,设法在不影响引用计数的情况下跟踪它,看它最终有没有被垃圾回收器所收集到即可。

如果你写过 Java,你肯定立马想到了 WeakReference
Dart 里也有类似的,只不过它实现的是 Java 里的 WeakHashMap : Expando。它使用上和普通的 Map 类似,不过有个特性,不会增加 key 属性的引用计数,亦即不会阻止 key 对象被 GC。

An Expando does not hold on to the added property value after an object becomes inaccessible.

通过 Expando 实现一个 DeadObjectWatcher,用于跟踪理论上需要被 GC 回收的对象:

1
2
3
4
5
6
7
8
9
10
class DeadObjectWatcher {
static DeadObjectWatcher instance = DeadObjectWatcher._();

DeadObjectWatcher._();
final Expando<Object> weakRef = Expando('weakreferences');

void watch(Object obj) {
instance.weakRef[obj] = obj.hashCode;
}
}

DeadObjectWatcher 是一个单例作为一个全局存活的对象,用于跟踪目标对象,如:

1
2
3
4
5
6
7
8
9
10
mixin LeakStateMixin<T extends StatefulWidget> on State<T> {
@override
void dispose() {
// State 已经 dispose,它自己和它所属的 Element (context) 应该要被回收
DeadObjectWatcher.instance
..watch(this.context)
..watch(this);
super.dispose();
}
}

如果你在用 flutter_bloc,则可以像我一样注册一个自动跟踪bloc对象的全局BlocObserver:

1
2
3
4
5
6
7
8
class DeadBlocObserver extends BlocObserver {
@override
void onClose(BlocBase bloc) {
super.onClose(bloc);
// bloc 已经 close,这个对象应该要被回收
DeadObjectWatcher.instance.watch(bloc);
}
}

接下来的问题是该如何分析 Expando 里对象呢?

作为 Flutter 资深开发的你,一定天天在用 Dart DevTools 分析内存、Timeline等等,其实这个工具本质上是通过 Dart VM Service Protocol 与 Flutter 底层的 Dart VM 服务通信实现的。那么当然可以通过这个协议,写一个简单的内存泄漏分析工具。

Dart 官方已经为我们提供了一个实现 Dart VM Service Protocol 的工具包:vm_service

ps. Dart DevTools 用的其实是 Dart VM Service Protocol 的扩展实现: Dart Development Service Protocol

通过 vm_service 工具连接 Dart VM Service 对外暴露的 Observatory Uri,通过协议找到 Flutter 程序 main.dart 所在的 Isolate:

1
2
3
4
5
6
7
8
9
10
11
12
var observatoryUri = "http://127.0.0.1:36489/g_8M0xR54Rg=/"
var wsUri = observatoryUri
.replace(
scheme: 'ws',
path: observatoryUri.path + 'ws',
)
.toString();
var vmService = await vmServiceConnectUri(wsUri)
var vm = await vmService!.getVM();
var isolate = vm.isolates?.firstWhereOrNull((i) => i.name == "main");
assert(isolate != null);
var isolateId = isolate!.id;

找到这个 isolateId 很重要,因为静态 DeadObjectWatcher的对象就存活在这个 Isolate 里:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Future<Instance?> _findDeadObjectWatcher(VmService vmService, String isolateId) async {
try {
// https://github.com/dart-lang/sdk/blob/main/runtime/vm/service/service.md#getclassList
// 找到名为目标isolate里 DeadObjectWatcher 的类引用
var classList = await vmService.getClassList(isolateId);
var classRef =
classList.classes?.firstWhere((classRef) => classRef.name == 'DeadObjectWatcher');
assert(classRef != null);
// 找到 DeadObjectWatcher 的类对象
var deadObjectWatcherClass = await vmService.getObject(isolateId, classRef!.id!) as Class;
// 找到 DeadObjectWatcher 类里的名为 instance 的静态字段
var instanceFieldRef = deadObjectWatcherClass.fields?.firstWhere((f) => f.isStatic! && f.name == 'instance');
assert(instanceField != null);
var instanceField = await vmService.getObject(isolateId, instanceFieldRef!.id!) as Field;
// instance 是个 static field,它的值就是 staticValue,也就是 DeadObjectWatcher 的对象
var instance = await vmService.getObject(isolateId!, instanceField.staticValue.id);
return instance is Instance && instance.kind != InstanceKind.kNull ? instance : null;
} catch (_) {
return null;
}
}

再找到 DeadObjectWatcher 里的 Expando 对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
Future<Instance?> _findExpando(VmService vmService, String isolateId, Instance watcher) async {
try {
// 找到名为 weakRef field 的 value,其为 Expando 对象引用
var ref = watcher.fields?.firstWhereOrNull((f) => f.decl?.name == 'weakRef')?.value;
if (ref == null) return null;
//
var obj = await vmService.getObject(isolateId, ref.id!);
assert(obj.kind != InstanceKind.kNull);
return obj;
} catch (_) {
return null;
}
}

通过阅读代码 dart-sdk/lib/_internal/vm/lib/expando_patch.dart 可知,跟踪的对象都会被_WeakProperty包裹存在一个名为_data的 List 里。

那么找到这个 _data,即可找到被跟踪的对象引用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Future<List<InstanceRef>> _findWatchingObjects(VmService vmService, String isolateId, Instance expando) async {
var dataField = expando.fields?.firstWhereOrNull((f) => f.decl?.name == '_data');
if (dataField == null) return [];
final InstanceRef value = dataField.value;
final _data = await vmService.getObject(isolateId, ref.id);
if (_data == null || _data.kind == InstanceKind.kNull) return [];
// 遍历 List 里的 _WeakProperty 对象
List<InstanceRef?> refs = await Future.wait(
_data.elements!
.where((e) => e != null && e.kind == InstanceKind.kWeakProperty)
.map((e) async {
final instance = await vmService.getObject(isolateId, e.id);
// propertyKey 就是被 _WeakProperty 包裹的需要分析的对象
InstanceRef? ref = instance?.propertyKey;
if (ref == null || ref.kind == InstanceKind.kNull) {
return null;
}
return ref;
}),
);
return refs.where((e) => e != null).toList().cast();
}

找到了需要分析的对象,接下来就是获取这个对象的引用路径 RetainingPath

1
2
3
4
5
6
7
8
9
10
11
12
Future<RetainingPath?> _getRetainPath(VmService vmService, String isolateId, InstanceRef ref) async {
try {
return await vmService.getRetainingPath(isolateId!, ref.id!, 500);
} on SentinelException catch (e) {
// 有可能已经被回收了 ~
if (e.sentinel.kind == SentinelKind.kCollected || e.sentinel.kind == SentinelKind.kExpired) {
return null;
}
// 其它错误
rethrow;
}
}

能通过InstanceRef 找到 RetainingPath 对象,意味着它还没有被 GC 回收,从 GC Root 根节点到目标对象还有引用路径!亦即,这个在逻辑上应该要被回收的对象所属内存泄漏了!

RetainingPath 对象里的 elements,就是引用路径中的各个节点 RetainingObject。接下来的任务就是分析各个节点,找到内存泄漏点。

分析 RetainingObject

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
void _analyzeRetainingObject(RetainingObject ele) {
// 引用路径其中一个节点
var ref = ele.value!;
String name = ref.name!;
if (ref is InstanceRef) {
// 函数引用,如匿名函数 <anonymous closure>
if (ref.kind == InstanceKind.kClosure) {
List<String?> chain = [ref.closureFunction!.name];
var owner = ref.closureFunction!.owner;
while (owner is FuncRef) {
chain.add(owner.name);
owner = owner.owner;
}
if (owner != null) {
chain.add(owner.name);
}
name = chain.reversed.join('.');
}
} else if (ref is FieldRef) {
if (ref.isStatic == true) {
// 这是个全局静态field
name = '${ref.name} (static)';
}
} else if (ref is ContextRef) {
// 匿名函数的 context
name = '<Closure Context>';
}
// 引用路径节点的父节点 field
final String parentField = ele.parentField ?? '';
print('+ $name $parentField');
}

不过,需要注意一个小细节,找到 Expando 里跟踪对象的引用路径,并不意味着它一定泄漏了,也有可能只是 GC 任务还没来得及执行而已。
所以分析引用路径前最好先强制触发一下GC:

1
2
3
4
5
6
7
8
void triggerGC(VmService vmService, String isolateId) async {
var allocation = await vmService.getAllocationProfile(isolateId, gc: true);
var usage = allocation.memoryUsage!;
print('Memory Usage:\n'
' externalUsage: ${usage.externalUsage! >> 20}MB\n'
' heapCapacity: ${usage.heapCapacity! >> 20}MB\n'
' heapUsage: ${usage.heapUsage! >> 20}MB');
}

结合起来就是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
void main() async {
var wsUri = ...
var vmService = await vmServiceConnectUri(wsUri)
var vm = await vmService!.getVM();
var isolate = vm.isolates?.firstWhereOrNull((i) => i.name == "main");
assert(isolate != null);
var isolateId = isolate!.id!;

var watcher = await _findDeadObjectWatcher(vmService!, isolateId);
assert(watcher != null);
var expando = _findExpando(vmService!, isolateId, watcher!);
assert(expando != null);
var objs = await _findWatchingObjects(vmService!, isolateId, expando!);
await triggerGC(vmService!, isolateId);
for (var obj in objs) {
var path = await _getRetainPath(vmService!, isolateId, obj);
if (path == null) {
print('${obj.id} has been collected!');
} else {
print('${obj.id} leaks!');
print('GC ROOT: ${path.gcRootType}');
path.elements?.forEach(_analyzeRetainingObject);
}
}
await vmService.dispose();
}

这样,通过一个简单的“该死的”对象跟踪类 DeadObjectWatcher,再通过一个简单的工具与目标 Flutter 程序的Dart VM Service 通信,获取Expando 所跟踪的“该死的”对象,即可无需大海老针似地 dump 所有内存,直接分析指定目标对象是否会内存泄漏。

那么如何自动化地定位内存泄漏问题呢?下一篇文章再细说。

PS. 注意一下本文的 _findWatchingObjects 在 Flutter web 环境不可用,至于原因留给你自己发现咯~