一、前言

Flutter 所使用的 Dart 语言具有垃圾回收机制,有垃圾回收就避免不了会内存泄漏。

在 Android 平台上有个内存泄漏检测工具 LeakCanary

它可以方便地在 debug 环境下检测当前页面是否泄漏。

本文将会带你实现一个 Flutter 可用的 LeakCanary,

并讲述我是怎么用该工具检测出了 1.9.1 Framework 上的两个泄漏。

二、Dart 中的弱引用

在具有垃圾回收的语言中,弱引用是检测对象是否泄漏的一个好方式。

我们只需弱引用观测对象,等待下次 Full GC

如果 GC 之后对象为 null,说明被回收了,

如果不为 null 就可能是泄漏了。

Dart 语言中也有着弱引用,它叫 Expando<T>

看下它的 API:

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

你可能会好奇上述代码弱引用体现在哪里呢?

其实是在 expando[key]=value 这个赋值语句上。

Expando 会以弱引用的方式持有 key,这里就是弱引用的地方。

那么问题来了,这个 Expando 弱引用持有的是 key

但是本身又没有提供 getKey() 这样的 API,

我们就无从下手去得知 key 这个对象是否被回收了。

为了解决这个问题,我们来看下 Expando 的具体实现,

具体的代码在 expando_path.dart

@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;
// ... 省略部分代码
}
}

注意: 此 patch 代码不适用于 Web 平台

我们可以发现这个 key 对象是放到了 _data 数组内,

用了一个 _WeakProperty 来包裹,那么这个 _WeakProperty 就是关键类了,

看下它实现,代码在 weak_property.dart

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

这个类有我们想要的 key,可以用于判断对象是否还在。

怎么获取这种私有属性和变量呢?

Flutter 中的 Dart 是不支持反射的(为了优化打包体积,关闭了反射),

有没有其他办法来获取到这种私有属性呢?

答案肯定是 “有”,为了解决上述问题,

我来向大家介绍一个 Dart 自带的服务——

Dart VM Service

三、Dart vm_service

Dart VM Service (后面简称 vm_service

是 Dart 虚拟机内部提供的一套 Web 服务,数据传输协议是 JSON-RPC 2.0。

不过我们并不需要要自己去实现数据请求解析,

官方已经写好了一个可用的 Dart SDK 给我们用:vm_service

ObjRef, Objid 的作用

先介绍 vm_service 中的核心内容:ObjRefObjid

vm_service 返回的数据主要分为两大类,

ObjRef(引用类型) 和 Obj(对象实例类型)。

其中 Obj 完整的包含了 ObjRef 的数据,

并在其基础上增加了额外信息

ObjRef 只包含了一些基本信息,例如:idname 等)。

基本所有的 API 返回的数据都是 ObjRef

ObjRef 里面的信息满足不了你的时候,

再调用 getObject(,,,)来获取 Obj

关于 id ObjObjRef 都含有 id

这个 id 是对象实例在 vm_service 里面的一个标识符,

vm_service 几乎所有的 API 都需要通过 id 来操作,

比如:getInstance(isolateId, classId, ...)

getIsolate(isolateId)

getObject(isolateId, objectId, ...)

如何使用 vm_service 服务

vm_service 在启动的时候会在本地开启一个 WebSocket 服务,

服务 URI 可以在对应的平台中获得:

  • Android 在 FlutterJNI.getObservatoryUri() 中;
  • iOS 在 FlutterEngine.observatoryUrl 中。

有了 URI 之后我们就可以使用 vm_service 的服务了,

官方有一个帮我们写好的 SDK: vm_service

直接使用内部的 vmServiceConnectUri 就可以获得一个可用的 VmService 对象。

vmServiceConnectUri 的参数需要是一个 ws:// 协议的 URI,默认获取的是 http 协议,需要借助 convertToWebSocketUrl方法转化下

四、泄漏检测实现

有了 vm_service 之后,我们就可以用它来弥补 Expando 的不足了。

按照之前的分析,我们要获 Expando 的私有字段 _data

这里可以使用 getObject(isolateId, objectId) API,

它的返回值是 Instance

内部的 fields 字段保存了当前对象的所有属性。

这样我们就可以遍历属性获取到 _data,来达到反射的效果。

现在的问题是 API 参数中的 isoateIdobjectId 是什么呢?

根据我前面介绍的 id 相关内容,它是对象在 vm_serive 中的标识符。

也就是我们只有通过 vm_service 才可以获取到这两个参数。

IsolateId 的获取

Isolate(隔离区)是 Dart 里面的一个非常重要的概念,

基本上一个 isolate 相当于一个线程,

但是和我们平常接触的线程不同的是:不同 isolate 之间的内存不共享。

因为有了上述特性,我们在查找对象的时候也要带上 isolateId

通过 vm_servicegetVM() API 可以获取到虚拟机对象数据,

再通过 isolates 字段可以获取到当前虚拟机所有的 isolate

那么怎么筛选出我们想要的 isolate 呢?

这里简单起见只筛选主 isolate

这部分的筛选可以查看 dev_tools

的源码: service_manager.dart#_initSelectedIsolate 函数。

ObjectId 的获取

我们要获取的 objectId 就是 expandovm_service 中的 id

这里可以把问题扩展下:

如何获取指定对象在 vm_service 中的 id

这个问题比较麻烦,vm_service 中没有实例对象和 id 转换的 API,

有个 getInstance(isolateId, classId, limit) 的 API,

可以获取某个 classId 的所有子类实例,

先不说如何获取到想要的 classId

此 API 的性能和 limit 都让人担忧。

没有好办法了吗?其实我们可以

借助 Library 的 顶级函数(直接写在当前文件,不在类中,例如 main 函数)

来实现该功能。

简单说明下 Library 是什么东西,Dart 中的分包管理是根据 Library 来的,同一个 Library 内的类名不能重复,一般情况下一个 .dart 文件就是一个 Library,当然也有例外,比如:part of 和 export。

vm_service 有个 invoke(isolateId, targetId, selector, argumentIds) API,

可以用来执行某个常规函数

gettersetter、构造函数、私有函数属于非常规函数),

其中如果 targetId 是 Library 的 id,那么 invoke

执行的就是 Library 的顶级函数。

有了 invoke Library 顶级函数的路径,

就可以用它实现对象转 id 了,代码如下:

int _key = 0;
/// 顶级函数,必须常规方法,生成 key 用
String generateNewKey() {
return "${++_key}";
} Map<String, dynamic> _objCache = Map();
/// 顶级函数,根据 key 返回指定对象
dynamic keyToObj(String key) {
return _objCache[key];
} /// 对象转 id
String obj2Id(VMService service, dynamic obj) async { // 找到 isolateId。这里的方法就是前面讲的 isolateId 获取方法
String isolateId = findMainIsolateId();
// 找到当前 Library。这里可以遍历 isolate 的 libraries 字段
// 根据 uri 筛选出当前 Library 即可,具体不展开了
String libraryId = findLibraryId(); // 用 vm service 执行 generateNewKey 函数
InstanceRef keyRef = await service.invoke(
isolateId,
libraryId,
"generateNewKey",
// 无参数,所以是空数组
[]
);
// 获取 keyRef 的 String 值
// 这是唯一一个能把 ObjRef 类型转为数值的 api
String key = keyRef.valueAsString; _objCache[key] = obj;
try {
// 调用 keyToObj 顶级函数,传入 key,获取 obj
InstanceRef valueRef = await service.invoke(
isolateId,
libraryId,
"keyToObj",
// 这里注意,vm_service 需要的是 id,不是值
[keyRef.id]
)
// 这里的 id 就是 obj 对应的 id
return valueRef.id;
} finally {
_objCache.remove(key);
}
return null;
}

对象泄漏判断

现在我们已经可以获取到 expando 实例在

vm_service 中的 id 了,接下来就简单了。

先通过 vm_service 获取到 Instance

遍历里面的 fields 属性,找到 _data 字段

(注意 _dataObjRef 类型),

用同样的办法把 _data 字段转成 Instance 类型

_data 是个数组,Obj 里面有数组的 child 信息)。

遍历 _data 字段,如果都是 null

表明我们观测的 key 对象已经被释放了。

如果 item 不为 null

再次把 item 转为 Instance 对象,

取它的 propertyKey

(因为 item 是 _WeakProperty 类型,

Instance 里面特地为 _WeakProperty 开了这个字段)。

强制 GC

文章开头说到,如果要判断对象是否泄漏,

需要在 Full GC 之后判断弱引用是否还在。

有没有办法手动触发 GC 呢?

答案是有的,vm_service 虽然没有强制 GC 的 API,

但是 Dev Tools 的内存图标右上角有个 GC 的按钮,

我们仿照着它来操作就行!

Dev Tools 是调用了 vm_service

getAllocationProfile(isolateId, gc: true) API

来实现手动 GC 的。

至于这个 API 触发的是不是 FULL GC,

并没有说明,我测试触发的都是 FULL GC,

如果要确定在 FULL GC 之后检测泄漏,

可以监听 gc 事件流,

vm_service 提供了该功能。

至此为止,我们已经可以实现泄漏的监控,

而且可以获取到泄漏目标在 vm_serive 中的 id 了,

下面就开始获取分析泄漏路径。

五、获取泄漏路径

关于泄漏路径的获取,

vm_service 提供了一个 API 叫 getRetainingPath(isolateId, objectId, limit)

直接使用此 API 就可以获取到泄漏对象到 GC Roots 的引用链信息,

是不是感觉很简单?不过光这样可不行,

因为它有以下几个坑点:

Expando 持有问题

如果在执行 getRetainingPath 的时候,

泄漏对象被 expando 持有的话会产生以下两个问题

  • 因为该 API 返回的引用链只有一条,

    返回的引用链会经过 expando,导致无法获取真正的泄漏节点信息;
  • 在 ARM 设备上会出现 native crash,

    具体错误出现在 utf8 字符解码上。

此问题很好解决,注意下在前面泄漏检测完之后,

释放掉 expando 就行。

id 过期问题

Instance 类型的 idClassLibraryIsolate

这种 id 不一样,是会过期的。

vm_service 中对于此类临时 id 的缓存容量默认大小是 8192

是一个循环队列。

因为此问题的存在,我们在检测到泄漏的时候,

不能只保存泄漏对象的 id,需要保存原对象,

而且不能强引用持有对象。

所以这里我们还是需要使用 expando

来保存我们检测到的泄漏对象,

等到需要分析泄漏路径的时候,

再把对象专为 id

六、1.9.1 Framework 上的内存泄漏

完成了泄漏检测和路径获取之后,

得到了一个简陋的 leakcanary 工具。

当我在 1.9.1 版本的 Framework 下测试此工具的时候发现,

我观测一个页面它就泄漏一个页面!!!

通过 dev_tools dump 出来的对象来看,的确泄漏了!

也就是 1.9.1 Framework 里面存在着泄漏,

而且此泄漏会泄漏整个页面。

接下来开始排查泄漏原因,这里就碰到一个问题:

泄漏路径太长:getRetainingPath 返回的链路长度有 300+,

排查了一下午也没有找到问题根源。

结论:直接根据 vm_service 返回的数据是很难分析问题来源的,

需要对泄漏路径的信息二次处理下。

如何缩短引用链

首先看下泄漏路径为什么会这么长,

通过观测返回的链路后发现,

绝大部分的节点都是 Flutter UI 组件节点

(例如:widgetelementstaterenderObject)。

也就是说引用链经过了 Flutter 的 widget tree,

熟悉 Flutter 的开发者应该都知道,

Flutter 的 widget tree 的层次是非常深的。

既然引用链长的原因是因为包含了 widget tree,

而且 widget tree 基本都是成块出现的,

那我们只要把引用链中的节点根据类型来分类、聚合,

就可以大幅缩短泄漏路径了。

分类

根据 Flutter 的组件类型,将节点分为以下几种类型:

  • element:对应 Element 节点;
  • widget:对应 Widget 节点;
  • renderObject:对应 RenderObject节点;
  • state:对应 State<T extends StatefulWdget> 节点;
  • collection:对应集合类型节点,例如:ListMapSet
  • other:对应其他节点。

聚合

节点的分类做好了之后,就可以把相同类型的节点聚合一下。

这里提下我的聚合方式:

collection 类型的节点看成了连接节点,

相邻的相同节点合并到一个集合内,

如果两个相同类型的集合中间是通过 collection 节点相连的,

就继续把这两个集合合并成一个集合,递归进行。

通过 分类-聚合 的处理后,原先 300+ 的链路长度,可以缩短为 100+。

继续排查 1.9.1 Framework 的泄漏问题,

路径虽然缩短了,可以找到问题大致出现在 FocusManager 节点上!

但是具体问题还是难以定位,主要有以下两点:

  • 引用链节点缺少代码位置:因为 RetainingObject 数据中只有 parentFieldparentIndexparentKey 三个字段来表示当前对象引用下一个对象的信息,通过该信息找代码位置效率低下;
  • 无法知道当前 Flutter 组件节点的信息:比如 Text 的文本信息,element 所在的 widget 是啥,state 的生命周期状态,当前组件属于哪个页面,等等。

介于上述两个痛点,还需要对泄漏节点的信息做扩展处理:

  • 代码位置:节点的引用代码位置其实只需要解析 parentField 就行,通过 vm_serive 解析 class,取内部的 field,找到对应的 script 等信息。此方法可以获取到源码;
  • 组件节点信息:Flutter 的 UI 组件都是继承自 Diagnosticable,也就是只要是 Diagnosticable 类型的节点都可获取到非常详细的信息(dev_tools 调试时候,组件树信息就是通过 Diagnosticable.debugFillProperties 方法获取的)。除了这个还需要扩展当前组件所在 route 的信息,这个很重要,判断组件所在页面用。

排查 1.9.1 Framework 泄漏根源

通过上述的种种优化后,我得到了下面这个工具,

在两个 _InkResponseState 节点中发现了问题:

泄漏路径中有两个 _InkResponseState 节点所属的 route 信息不同,

表明这两个节点在两个不同的页面中。

顶部 _InkResponseState 的描述信息显示 lifecycle not mounted

说明组件已经销毁了,但是还是被 FocusManager 引用着!

问题出现在这,来看下这部分代码

代码中可以明显的看到 addListener 时候

StatefulWidget 的生命周期理解错误。

didChangeDependencies 是会多次调用的,

dispose 只会调用一次,

所以这里就会出现 listener 移除不干净的情况。

修复了上述泄漏之后,发现还有一处泄漏。

排查后发现泄漏源在 TransitionRoute 中:

当打开一个新页面的时候,

该页面的 Route(也就是代码中的 nextRoute

会被前一个页面的 animation 所持有,

如果页面跳转都是 TransitionRoute

那么所有的 Route 都会泄漏!

好消息是以上泄漏都在 1.12 版本之后修复了。

修复完上述两个泄漏之后,

再次测试,RouteWidget 都可以回收了,

至此 1.9.1 Framework 排查完毕。


本文作者: 戚耿鑫

现就职于快手应用研发平台组 Flutter 团队,负责 APM 方向开发研究。从 2018 年开始接触 Flutter,在 Flutter 混合栈、工程化落地、UI 组件等方面有大量经验。

联系方式:qigengxin@kuaishou.com


「Flutter 中文社区教程」由社区的开发者投稿,内容同步发布到 flutter.cn 网站以及 「Flutter 社区」的各个社交平台。本项目内部测试中,筹备完成后会开放投稿。

在 flutter.cn 阅读本文:https://flutter.cn/community/tutorials/memory-leak-monitoring-on-flutter

[实战] Flutter 上的内存泄漏监控的更多相关文章

  1. 轻松排查线上Node内存泄漏问题

    I. 三种比较典型的内存泄漏 一. 闭包引用导致的泄漏 这段代码已经在很多讲解内存泄漏的地方引用了,非常经典,所以拿出来作为第一个例子,以下是泄漏代码: 'use strict'; const exp ...

  2. 关于iOS上使用WWW引起的内存泄漏的临时解决方案

    原地址:http://www.unity蛮牛.com/thread-16493-1-1.html 目前,在的4.3.3.和4.3.4版本中存在一个iOS平台上的内存泄漏问题,即当使用WWW来下载和加载 ...

  3. 内存溢出OOM与内存泄漏ML

    附, 微信团队原创分享:Android内存泄漏监控和优化技巧总结 一.如何避免OOM 异常 想要避免OOM 异常首先我们要知道什么情况下会导致OOM 异常. 1.图片过大导致OOM Android 中 ...

  4. 内存泄漏(Memory Leak)

    什么情况下会导致内存泄露(Memory Leak)? Android 的虚拟机是基于寄存器的Dalvik,它的最大堆大小一般是16M,有的机器为24M.因此我们所能利用 的内存空间是有限的.如果我们的 ...

  5. Day 18: 记filebeat内存泄漏问题分析及调优

    ELK 从发布5.0之后加入了beats套件之后,就改名叫做elastic stack了.beats是一组轻量级的软件,给我们提供了简便,快捷的方式来实时收集.丰富更多的数据用以支撑我们的分析.但由于 ...

  6. 为什么各大厂自研的内存泄漏检测框架都要参考 LeakCanary?因为它是真强啊!

    请点赞关注,你的支持对我意义重大. Hi,我是小彭.本文已收录到 GitHub · AndroidFamily 中.这里有 Android 进阶成长知识体系,有志同道合的朋友,关注公众号 [彭旭锐] ...

  7. 深入分析 ThreadLocal 内存泄漏问题

    前言 ThreadLocal 的作用是提供线程内的局部变量,这种变量在线程的生命周期内起作用,减少同一个线程内多个函数或者组件之间一些公共变量的传递的复杂度.但是如果滥用ThreadLocal,就可能 ...

  8. 一个跨平台的 C++ 内存泄漏检测器

    2004 年 3 月 01 日 内存泄漏对于C/C++程序员来说也可以算作是个永恒的话题了吧.在Windows下,MFC的一个很有用的功能就是能在程序运行结束时报告是否发生了内存泄漏.在Linux下, ...

  9. Android开发之漫漫长途 番外篇——内存泄漏分析与解决

    该文章是一个系列文章,是本人在Android开发的漫漫长途上的一点感想和记录,我会尽量按照先易后难的顺序进行编写该系列.该系列引用了<Android开发艺术探索>以及<深入理解And ...

随机推荐

  1. ExtJS定时和JS定时

    ExtJS定时 //定时刷新待办事宜状态 var task={ run:function(){ //执行的方法或方法体 }, interval:5*60*1000 //5分钟 } //定时启动 Ext ...

  2. shell日期格式化、加减运算

    #!/bin/bash echo i love you输出:i love you =======================================反引号的作用============== ...

  3. 值得注意的Java基础知识

    1)Java语言中默认(即缺省没写出)的访问权限,不同包中的子类不能访问. 中有4中访问修饰符:friendly(默认).private.public和protected. public :能被所有的 ...

  4. Python基础知识思维导图

    看不清的可以右键保存到本地,或者在新标签页中打开图片

  5. Shell编程案例:修改运维脚本输出效果

    1. 需求:每日运维检查脚本dailymonitor.sh显示对服务器测试结果,其中命令 zabbix_get -s 192.168.111.21 -p 10050 -k "net.tcp. ...

  6. DOM表单,下拉菜单和表格

    DOM访问表单控件的常用属性和方法如下: action 返回该表单的提交地址 elements 返回表单内全部表单控件所组成的数组,通过数组可以访问表单内的任何表单控件. length 返回表单内表单 ...

  7. Java中数组二分法查找

    算法:当数组的数据量很大适宜采用该方法.采用二分法查找时,数据需是有序不重复的,如果是无序的也可通过选择排序.冒泡排序等数组排序方法进行排序之后,就可以使用二分法查找. 基本思想:假设数据是按升序排序 ...

  8. java实现整数翻转

    ** 整数翻转** 以下程序把一个整数翻转(8765变为:5678),请补充缺少的代码. int n = 8765; int m = 0; while(n>0) { m = __________ ...

  9. Linux目录处理命令mkdir详解

    mkdir(英文原意:make directories),基本作用是创建新的目录,命令的路径及权限: 可以看到,这个命令的路径是/usr/bin/mkdir,所以它的执行权限是所有用户 mkdir 创 ...

  10. 痞子衡嵌入式:降低刷新率是定位LCD花屏显示问题的第一大法(i.MXRT1170, 1280x480 LVDS)

    大家好,我是痞子衡,是正经搞技术的痞子.今天痞子衡给大家分享的是i.MXRT1170上LCD花屏显示问题的分析解决经验. 痞子衡最近这段时间在参与一个基于i.MXRT1170的大项目(先保个密),需要 ...