Flutter内存泄露检查工具实践

智云健康

本文作者:陶涛,未经授权禁止转载。

背景

随着Flutter技术在团队内部的不断普及,一些性能和内存泄露问题开始暴露出来,我们智云Flutter基建团队开始了Flutter性能监测工具和Flutter内存泄露检查工具的开发。

官方也有一篇方案介绍:flutter.cn/community/t…

本文主要介绍一下基于上面的方案的Flutter内存泄露检查工具技术方案的具体实现。

Dart中内存泄露检查

Flutter中采用的是Dart语言开发,在Dart中内存回收采用的是垃圾回收机制,由于垃圾回收的机制问题,如果开发者使用Dart语言开发不规范,很容易出现的一个问题就是内存泄露。

在基于垃圾回收机制的语言中,弱引用是检查对象是否泄露的一个好方式,我们可以借鉴一起其他采用垃圾回收机制的语言是怎么内存泄露检查的(比如说Java):

  • Android中的LeakCanary

  • IOS中的MLeaksFinder

Android中的LeakCanary

这里简单介绍下Android中LeakCanary的检测原理:

  • Application中调用LeakCanary.install(this)进行注册
  • install方法中会注册一个Application.ActivityLifecycleCallbacks,在回调的onActivityDestroyed(Activity activity)方法中调用refWatcher.watch(Activity activity)
  • refWatcher.watch(Activity activity)中创建一个 Activity的弱引用对象,检测当前Activity是否被GC回收。
  • 判断ReferenceQueue中是否有这个弱引用对象,如果有则说明当前这个Activity已经被GC回收了。
  • 如果没有则主动调用一次系统的GC,等待100ms后再查看一次。
  • 如果ReferenceQueue中依旧没有这个弱引用对象,说明当前Activity没有被系统GC回收。
  • 没有被系统回收就调用 Debug.dumpHprofData(dumpHproFilePath)方法生成内存使用快照文件。
  • 结合dump Heap的hpof文件,通过Haha开源库分析泄露的位置,展示泄露应用路径。

Dart中的弱引用

那么如果我们要在Dart中检测内存泄露,是否也可以按照LeakCanary中方式实现呢?答案是可以的。

因为Dart 语言中也有着弱引用,它叫 Expando<T> ,看下它的 API:

class Expando<T> {
  external T operator [](Object object);
  external void operator []=(Object object, T value);
}
复制代码

Expando使用中通过expando[key]=valueexpando会以弱引用的方式持有 key

继续探索,在Expando 的具体实现类expando_path.dart中,我们可以看到这个 key 对象是放到了 _data 数组内,然后用一个 _WeakProperty 来包裹的,而在_WeakProperty中可以获取到key对象,源码如下:

@path
class Expando<T> {
  // ...
  T operator [](Objet object) {
    var mask = _size - 1;
    var idx = object._identityHashCode & mask;
    // sdk 是把 key 放到了一个 _data 数组内,这个 wp 是个 _WeakProperty
    var wp = _data[idx];

    // ... 省略部分代码
    return wp.value;
    // ... 省略部分代码
  }
}
复制代码
@pragma("vm:entry-point")
class _WeakProperty {
  get key => _getKey();
  // ... 省略部分代码
  _getKey() native "WeakProperty_getKey";
  // ... 省略部分代码
}
复制代码

Dart VM Service

基于上面的分析,我们只要用Expando弱引用需要检测的对象,触发GC,然后遍历Expando_data数组中的对象的propertyKey属性(_WeakProperty),如果不为null,则有可能内存泄露了,如果为null,则没有内存泄露。

但是,在Flutter中为了优化打包体积,关闭了反射,我们不能像Java中那样通过反射来遍历对象的私有属性和变量。不过,好在Dart为我们提供了一个自带的扩展服务:Dart VM Service,通过这个VM Service,我们可以获取App的IsolateObjectIdObjRef(引用类型)和Obj(对象实例类型)等等。

在Dart VM Service中,我们内存泄露检测需要用到如下知识:

IsolateId

Isolate(隔离区)是 Dart 里面的一个非常重要的概念, 基本上一个 isolate 相当于一个线程,但是和我们平常接触的线程不同的是:不同 isolate 之间的内存不共享。所以当我们查找对象的时候需要IsolateId,通过 vm_servicegetVM() API 可以获取到虚拟机对象数据,再通过 isolates 字段可以获取到当前虚拟机所有的 isolate。目前我们只遍历了主Isolate

Future<Isolate> _findMainIsolateId(VmService vmService) async {
  if (vmService == null) {
    return null;
  }
  VM vm = await vmService.getVM();
  List<IsolateRef> isolates = vm.isolates;
  if (isolates.isEmpty) {
    return null;
  }
  final IsolateRef ref = isolates.firstWhere((IsolateRef ref) {
    debugPrint('ref:${ref.name}');
    return ref.name.contains('main');
  }, orElse: () => null);
  if (ref == null) {
    return null;
  }
  Isolate isolate = await vmService.getIsolate(ref.id);
  return isolate;
}
复制代码

ObjectId

对象实例在VM Service中唯一标示,然后通过ObjectId我们可以获取实例对象的所有信息。

LibraryId、InstanceRef

InstanceRef 是一个 Instance的引用类型,它包含了实例对象的基本信息,例如:idname 等。

在VM Service中没有实例对象和id转换的API,我们可以借助Library的顶级函数来实现该功能,在VM Service中有个invoke(isolateId, targetId, selector, argumentIds)API,可以用来执行某个常规函数,其中如果 targetId 是 Library 的 id,那么 invoke 执行的就是 Library 的顶级函数,通过这种方式我们能获取对象的Response,而这个Response其实就是我们需要的InstanceRef,强转一下就行了。

Future<InstanceRef> _getLeakInstanceRef(
    VmService vmService, String isolateId, String libraryId) async {
  var leakInstanceRef = await vmService.invoke(
    isolateId,
    libraryId,
    "getLeakExpandoRef",
    [],
  );
  return leakInstanceRef as InstanceRef;
}
复制代码
Expando<Widget> leakExpandoRef = Expando("leakExpandoRef");

Expando<Widget> getLeakExpandoRef() {
  return leakExpandoRef;
}
复制代码

Obj

Obj 完整的包含了 ObjRef 的数据,并在其基础上增加了额外信息(ObjRef 只包含了一些基本信息,例如:idname 等)。

Instance继承Obj,包含实例对象所有信息。

有了ObjectId,通过VM Service,我们可以获取到对应ObjectIdObj,而这个Obj其实是我们需要的Instance

Obj _data = await vmService.getObject(isolateId, _dataRef.id);
Instance _dataInstance = _data as Instance;
复制代码

Flutter中内存泄露方案的实现

经过上面的分析以及借鉴Android LeakCanary和IOSMLeaksFinder的设计思想, 我们Flutter中内存泄露方案如下:

init初始化

新建Isolate,用来异步执行连接VM Service、对象遍历以及泄露分析

在Flutter中debug和profile模式运行的App,会在本地启动一个WebSocket服务,通过服务URI,我们调用vm_service中的vmServiceConnectUri就可以获得一个可用的VmService对象。

watch监听检测对象

watch的对象添加到Expando中,弱引用需要监听的对象。

void watch(dynamic object) async {
  if (leakExpandoRef == null) {
    leakExpandoRef = Expando("leakExpandoRef");
  }
  leakExpandoRef[object] = object;
}
复制代码

check内存泄露检查逻辑

首先通过VmService对象遍历得到主IsolateId(我们这里先只检测主Isolate的内存泄露) 然后通过顶级函数获取Expando对象的引用类型InstanceRef,从而得到Expando对象的ObjectId

Expando<Widget> leakExpandoRef = Expando("leakExpandoRef");

Expando<Widget> getLeakExpandoRef() {
  return leakExpandoRef;
}
复制代码
Future<InstanceRef> _getLeakInstanceRef(
    VmService vmService, String isolateId, String libraryId) async {
  var leakInstanceRef = await vmService.invoke(
    isolateId,
    libraryId,
    "getLeakExpandoRef",
    [],
  );
  return leakInstanceRef as InstanceRef;
}
复制代码

通过遍历Expando 弱引用对象进行泄露分析,由于涉及公司内部源码,以下提供检查内存泄露伪代码(需要交流的小伙伴可以联系我们)

Future<List<Instance>> _check(
    VmService vmService, String isolateId, String leakRefId) async {
  List<Instance> leakInstances = [];
  // expando对象,从中获取到_data的成员
  for (BoundField field in instance.fields) {
    if(field.decl.name == "_data") {
      // 遍历_data数组,找到propertyKey
      for (var elementRef in _dataInstance.elements) {
        // 检查propertyKey对象是否为null,判断是否泄露
        Instance elementInstance = elementObj as Instance;
        InstanceRef propertyKey = elementInstance.propertyKey;
        if (propertyKey == null) {//无泄漏
            return leakInstances;
          } else {//有泄漏
          Instance leakInstance = leakObj as Instance;
          leakInstances.add(leakInstance);
        }
      }
    }
  }
  return leakInstances;
}
复制代码

通过getRetainingPath获取泄漏路径

Future<List<String>> _getPath(
    VmService service, String objectId, String isolateId) async {
  List<String> res = [];
  RetainingPath path =
      await service.getRetainingPath(isolateId, objectId, 1000);///limit设置为1000
  if (path.elements == null || path.elements.length == 0) {
    return res;
  }
  for (RetainingObject object in path.elements) {
    String name = _getObjName(object.value);
    if (object.value is InstanceRef && object.parentField != null) {
      name = "$name.${object.parentField}";
    }
    res.add(name);
    print('path name:$name');
  }
  return res;
}
复制代码

showLeakInfo泄露提示及展示泄露路径

_showFlutterNotifications(String name) {
    return showDialog<bool>(
      context: navigatorKey.currentState.overlay.context,
      builder: (context) {
        return AlertDialog(
          title: Text("内存泄漏!"),
          content: Text("检测到$name发送内存泄漏"),
          actions: <Widget>[
            FlatButton(
              child: Text("取消"),
              onPressed: () => Navigator.of(context).pop(), // 关闭对话框
            ),
            FlatButton(
              child: Text("去看看"),
              onPressed: () {
                Navigator.pushReplacement(
                  context,
                  MaterialPageRoute<void>(
                      builder: (context) => DisplayScreen()),
                );
              },
            ),
          ],
        );
      },
    );
  }
}
复制代码

落地项目

无侵入方式接入方式

  1. ZyMemoryLeakPlugin.init(navigatorKey);
    复制代码
  2. class AppNavigatorObserver extends NavigatorObserver {
      @override
      void didPop(Route route, Route previousRoute) {
        super.didPop(route, previousRoute);
        Future.delayed(Duration(milliseconds: 400)).then((value) => 			   	ZyMemoryLeakPlugin.check());
      }
    }
    复制代码
  3. Route generateRoute(RouteSettings settings) {
      final name = settings.name.isEmpty ? homeRoute : settings.name;
      final args = settings.arguments;
      if (_routes.containsKey(name)) {
        return MaterialPageRoute(settings: settings, builder: (context) {
          Widget page = _routes[name](
            context,
            name,
            args,
          );
          ZyMemoryLeakPlugin.watch(page);
          return page;
        });
      }
    复制代码
    MaterialApp(
      title: 'Flutter Demo',
      navigatorKey: navigatorKey,
      navigatorObservers: [AppNavigatorObserver()],
      onGenerateRoute: generateRoute,
      home: MyHomePage(title: 'Flutter Demo Home Page'),
    )
    复制代码

基类接入方式:

class _HealthOrderDetailPageState extends CloudHealthBaseState<HealthOrderDetailPage> 
复制代码

具体项目接入,根据泄露提示,分析具体案例

打开app-进入首页-点击首页待处理任务-进入订单详情-点击返回键回到首页-弹窗内存泄露提示

2 3

泄露路径:

I/flutter ( 6702): path length:42,elements:42 I/flutter ( 6702): path name:HealthOrderDetailPageState

I/flutter ( 6702): path name: I/flutter ( 6702): path name:_Closure._context@0150898 I/flutter ( 6702): path name:GestureDetector.onTap I/flutter ( 6702): path name:SafeArea.child I/flutter ( 6702): path name:_BodyBuilder.body I/flutter ( 6702): path name:MediaQuery.child I/flutter ( 6702): path name:LayoutId.child I/flutter ( 6702): path name:_GrowableList I/flutter ( 6702): path name: I/flutter ( 6702): path name:_Closure._context@0150898 I/flutter ( 6702): path name:AnimatedBuilder.builder I/flutter ( 6702): path name:Material.child I/flutter ( 6702): path name:_MaterialState._widget@72042623 I/flutter ( 6702): path name:_RenderInkFeatures.vsync I/flutter ( 6702): path name:RenderPhysicalModel._child@934266271 I/flutter ( 6702): path name:RenderRepaintBoundary._child@934266271 I/flutter ( 6702): path name:SingleChildRenderObjectElement._renderObject@72042623 I/flutter ( 6702): path name:SingleChildRenderObjectElement._child@72042623 I/flutter ( 6702): path name:StatefulElement._child@72042623 I/flutter ( 6702): path name:SingleChildRenderObjectElement._child@72042623 I/flutter ( 6702): path name:StatefulElement._child@72042623 I/flutter ( 6702): path name:StatefulElement._child@72042623 I/flutter ( 6702): path name:SingleChildRenderObjectElement._child@72042623 I/flutter ( 6702): path name:_InheritedNotifierElement._child@72042623 I/flutter ( 6702): path name:SingleChildRenderObjectElement._child@72042623 I/flutter ( 6702): path name:StatefulElement._child@72042623 I/flutter ( 6702): path name:FocusScopeNode._context@1020042876 I/flutter ( 6702): path name:FocusScope.focusNode I/flutter ( 6702): path name:Actions.child I/flutter ( 6702): path name:PageStorage.child I/flutter ( 6702): path name:Offstage.child I/flutter ( 6702): path name:_ModalScopeStatus.child I/flutter ( 6702): path name:DiagnosticableTreeNode.value I/flutter ( 6702): path name:_GrowableList I/flutter ( 6702): path name:DiagnosticsProperty._value@717198569 I/flutter ( 6702): path name:_GrowableList I/flutter ( 6702): path name:DiagnosticPropertiesBuilder.properties I/flutter ( 6702): path name:_ElementDiagnosticableTreeNode._cachedBuilder@717198569 I/flutter ( 6702): path name:_List I/flutter ( 6702): path name:_CompactLinkedIdentityHashMap._data@3220832 I/flutter ( 6702): path name:_WidgetInspectorService._objectToId@1038171358 I/flutter ( 6702): ===ZyMemoryLeakPlugin.init===主线程接收到leakInfo

查看源码:

class HealthOrderDetailPage extends StatefulWidget {
  HealthOrderDetailPage(this.id, {Key key}) : super(key: key);

  final String id;

  @override
  _HealthOrderDetailPageState createState() => _HealthOrderDetailPageState();
}

class _HealthOrderDetailPageState extends CloudHealthBaseState<HealthOrderDetailPage> {
  HealthOrderDetailViewModel _orderDetailVM;
  HealthOrderStatusUpdateViewModel _orderStatusUpdateViewModel;
  FocusNode _commentFocus;

  TextEditingController _remarkEditingController;

  @override
  void initState() {
    super.initState();
    _orderDetailVM = HealthOrderDetailViewModel();
    _orderStatusUpdateViewModel = HealthOrderStatusUpdateViewModel();
    _commentFocus = FocusNode();
    _remarkEditingController = TextEditingController();
  }
  @override
  Widget build(BuildContext context) {
    ...
  }
}
复制代码

得出结论:页面dispose时_commentFocus没有dispose

修改代码,泄露不再提示:

@override
void dispose() {
  _commentFocus?.dispose();
  super.dispose();
}
复制代码

总结

到此,一个简易版的Flutter内存泄露工具就完成了。

后续还存在如下待优化点:

  • 手动GC,提高泄露分析的准确性
  • 需要优化内存泄露检测的发起时机
  • 泄露路径优化,更详细更精确

结尾

感谢你的阅读,日前智云健康大前端团队正在参加掘金人气团队评选活动。如果你觉得还不错的话,那就来 给我们投几票 吧!

今日总共可以投18票,网页6票,App6票,分享6票。感谢支持,2021我们还会创作更多的技术好文~~~

你的支持是是我们最大的动力~

分类:
前端
标签:
分类:
前端
标签:
收藏成功!
已添加到「」, 点击更改