When an APP grows with business, useless features pile up, the team of developers grows, and the code expands in a big way, despite a series of process restraints such as manual review, code specification, static lint check and so on, it cannot stop the collapsing universe from evolving into an unfathomable black hole. Among them, there is no greater headache than “memory leak”.

What is a memory leak? It’s so common yet so insignificant that you and I can easily write a piece of code to make an object invisible to the garbage collector (GC).

For example, the following paragraph.

 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: ...
    );
  }
}

Can you find the object that might be leaking while reading through this code?

Yes, context may leak for a short time. But, thanks to Dart’s aggressive and efficient GC strategy, this leak is a negligible and minor part of the memory leak problem. (Extended reading: Flutter: Don’t Fear the Garbage Collector d69b3ff1ca30))

Another example is this piece of code.

 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;
      });
    }
  }

As you can easily see from your extensive Flutter development experience, the return object StreamSubscription of stream.listen is not properly canceled, causing _WidgetAState and its Element (context) to probably leak and may not be recovered by GC until the Provider<AuthStateBloc> of the ancestor node is unloaded.

So back to the title, is there a tool that can programmatically find memory leaks?

The first question to address is how to find memory leaks?

Is it possible to track an object code-logically discarded without affecting the reference count and see if it is eventually collected by the garbage collector?

If you’ve written Java, you’ll immediately think of WeakReference. There is a similar one in Dart, except it implements Java’s WeakHashMap : Expando. It is similar to a normal Map in use, but has a feature that it does not increase the reference count of the key property, i.e. it does not prevent the key object from being GC’d.

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

Implement a DeadObjectWatcher with Expando to keep track of objects that theoretically need to be reclaimed by 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 is a single instance used as a globally surviving object to keep track of the target object, e.g.

 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();
  }
}

If you are using flutter_bloc, then you can register a global BlocObserver that automatically tracks bloc objects, as I did :

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);
  }
}

The next question is how to analyze the objects in Expando?

As a senior Flutter developer, you must be using Dart DevTools every day to analyze memory, Timeline, etc. Actually, this tool essentially communicates with the underlying Dart VM service via ** Dart VM Service Protocol** to communicate with Flutter’s underlying Dart VM service. So it is certainly possible to write a simple memory leak analysis tool through this protocol.

Dart has provided us with a toolkit that implements Dart VM Service Protocol: vm_service.

ps. Dart DevTools actually uses an extended implementation of the Dart VM Service Protocol: Dart Development Service Protocol dds/dds_protocol.md)

Connect to the Observatory Uri exposed by the Dart VM Service via the vm_service utility, and find the 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;

It is important to find this isolateId because the object of the static DeadObjectWatcher lives in this 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;
  }
}

Then find the Expando object in the DeadObjectWatcher.

 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;
  }
}

By reading the code dart-sdk/lib/_internal/vm/lib/expando_patch.dart, you can see that the tracked objects are wrapped in _WeakProperty and exist in a list called _data.

Then find this _data to find the object reference being tracked.

 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();
}

Having found the object to be analyzed, the next step is to get the reference path of this object 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;
  }
}

The fact that the RetainingPath object is found by InstanceRef means that it has not been reclaimed by GC and there is still a reference path from the GC Root node to the target object! That is, the object that is logically supposed to be reclaimed belongs to a memory leak!

The elements in the RetainingPath object are the nodes in the reference path RetainingObject. The next task is to analyze each node and find the memory leak.

Analyzing 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');
}

However, one small detail to note is that finding the reference path of an object tracked in Expando doesn’t mean it is necessarily leaking, it could just be that the GC task hasn’t had time to execute yet. So it is better to force the GC to be triggered before analyzing the reference path:

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');
}

The combination is.

 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();
}

Thus, with a simple object tracking class DeadObjectWatcher, and a simple tool to communicate with the target Flutter application’s Dart VM Service to get the “damn” objects tracked by Expando, you can directly analyze whether a given target object is leaking memory without dumping all the memory like a needle in a haystack.

So how do you automate a memory leak to locate it? More on that in the next article.

PS. Note that _findWatchingObjects in this article is not available in the Flutter web environment, so you can find out why. s