写点什么

这可能是,Flutter 中最“强悍”的内存泄漏检测方案......

  • 2021 年 11 月 17 日
  • 本文字数:6886 字

    阅读完需:约 23 分钟

这可能是,Flutter 中最“强悍”的内存泄漏检测方案......


作者:吴志伟


近两年来,无论是创新型应用还是老牌旗舰型应用,都在或多或少地使用 Flutter 技术。然而,目前 Flutter 业务团队反馈最普遍的问题是,Flutter 内存占用过高


Flutter 内存占用过高原因比较复杂,需另开一个主题才能说清楚。简单总结下我们调研的结论:Dart Heap 内存管理以及 Flutter Widget 设计综合导致业务内存较高,其最核心的问题引擎设计使开发者容易踩中内存泄漏。开发过程中,内存泄漏常见且难以定位,总结主要 2 点原因:


  • Flutter 渲染三棵树的设计,以及 Dart 各种异步编程的特点,导致对象引用关系比较绕,分析困难

  • Dart “闭包”,“实例方法”可赋值传递,导致所在的类被方法上下文持有,不经意就会发生泄漏。典型例如注册一个 listener 没有反注册,导致 listener 所在的类对象泄漏


开发者享受了 Flutter 开发的便利性,却不知不觉中承受了内存泄漏的苦果。因此,我们迫切需要一套高效的内存泄漏检测工具来摆脱这种困境。


盘点我了解到的几种内存泄漏检测方案:


  1. 监控 State 是否泄漏:针对 State 的泄漏检测。但 State 是 Flutter 内存泄漏中占比最大的对象吗?StatelessWidget 的对象也是可以引用很大内存的

  2. 监控 Layer 个数:对比 正在使用,内存中的 Layer 个数来判定是否存在内存泄漏。方案对内存泄漏判定是否准确?Layer 对象离业务 Widget 太远,溯源太困难

  3. Expando 弱引用泄漏判定:判定特定对象是否泄漏并返回引用链 。但我们不知道 Flutter 中最应该监控的对象是哪个,哪个对象泄漏是主要问题?

  4. 基于 Heap Snapshot 内存泄漏检测:对比不同两个时间点的 Dart 虚拟机 Heap 对象的增长,以“class 内存增量”,“对象内存个数” 2 个指标检测发生泄漏的可疑对象。这是个通用的解决方案,但要做到高效定位到泄漏对象(Image, Layer)才比较有价值。目前“确定检测对象”和“检测时机”这 2 个问题都不好解决,所以还需要人工逐一排查确认,效率不高。


总之,我们觉得方案 1,2 逻辑上不够完备,方案 3,4 效率有待提高。


更好的方案是?


参考 Android,LeakCanary 能够准确、高效检测 Activity 内存泄漏,解决内存泄漏的主要问题。那我们能不能在 Flutter 中也实现一套这样的工具呢?这应该是一套更好的方案。


在回答这个问题之前,先思考下为什么 LeakCanary 要挑选 Activity 作为内存泄漏监控的对象,并且能够解决主要的内存泄漏问题?


我们总结其至少满足了下面 3 个条件:


  1. 泄漏对象引用的内存足够大:Activity 对象引用的内存是非常大,是内存泄漏的主要问题

  2. 能够完备定义内存泄漏:Activity 具有明确的生命周期和确切回收时机,泄漏定义完备,可实现自动化,提高效率

  3. 泄漏的风险高:Activity 基类为 Context,作为参数传递,使用非常频繁,存在较高的泄漏风险


3 个条件反映了监控对象的必要性,监控工具的可操作性。


顺着这个思路,如果我们能够在 Flutter 中找到满足上面 3 个条件的对象,将其监控起来,那就可以做一套 Flutter 的 LeakCanary 工具,用来解决 Flutter 中内存泄漏的主要问题。


从实际项目中回顾近期解决的内存泄漏问题,内存飙升体现在 Image, Picture 对象,如下图所示。



虽然 Image, Picture 内存占用高,是泄漏内存的主要贡献者,但它们不能作为我们监控的目标,因为它们明显不符合上面列出的 3 个条件:


  1. 内存占用大,是其对象个数多,累加起来的,并不是由某一个 Image 引用而导致

  2. 无法定义什么时候是泄漏的,没有明确的生命周期

  3. 并不会作为一个常用的参数传递,使用地方都比较固定,例如 RawImage Widget


深入 Flutter 渲染分析,总结到 Image, Picture 泄漏的根本原因是 BuildContext 发生泄漏。而 BuildContext 恰恰满足上面列的 3 个条件(后面详述),似乎是我们要找的那个对象,实现一套监控 BuildContex 泄漏的方案似乎不错。


请记住这 3 个条件,后面我们在说明的时候会经常用到。

为什么监控 BuildContext

BuildContext 引用的内存有哪些呢?


BuildContext 是 Element 的基类,直接引用 Widget,RenderObject,其类之间的关系也是它们形成的 Element Tree, Widget Tree, RenderObject Tree 的关系。类关系如下图所示。



着重说下 Element Tree:


  • 三棵树的构建是通过 Element 的 mount / unmount 方法构建

  • 父子 Element 相互强引用, 所以 Element 泄漏会导致整棵 Element Tree 泄漏,连同强引用住对应的 Widget Tree, RenderObject Tree 一起泄漏,相当可观

  • Element 中强引用到 Widget, RenderObject 的 field 不会主动置为 null,所以三棵树的释放依赖 Element 被 GC 回收


Widget Tree 表示被引用的 Widget,例如引用 Image 的 RawImage Widget。RenderObject Tree 会生成 Layer Tree,并且会强引用 ui.EngineLayer(c++ 分配内存),所以 Layer 相关的渲染内存会被这棵树持有。综合上述,BuildContext 引用住了 Flutter 中的 3 棵树。因此:


  1. BuildContext 引用的内存占用大,满足条件 1

  2. BuildContext 在业务代码中使用频繁,作为参数传递等,泄漏风险高,满足条件 3

怎么监控 BuildContext

BuildContext 的泄漏是否可以完备定义?


从 Element 的生命周期看:



重点需要确定什么时候 Element 会被 Element Tree 丢弃,并且不会再使用,会被随后来的 GC 回收掉。


finalizeTree 处理代码如下:


// flutter_sdk/packages/flutter/lib/src/rendering/binding.dartmixin WidgetsBinding on BindingBase, ServicesBinding, SchedulerBinding, GestureBinding, RendererBinding, SemanticsBinding {  @override  void drawFrame() {    ...    try {      if (renderViewElement != null)        buildOwner.buildScope(renderViewElement);      super.drawFrame();      // 每一帧最后回收从 Element 树中移除的 Element      buildOwner.finalizeTree();    } finally {        }  }}  // flutter_sdk/packages/flutter/lib/src/widgets/framework.dartclass BuildOwner {  ...  void finalizeTree() {    try {      // _inactiveElements 中记录不再使用的 Element      lockState(() {        _inactiveElements._unmountAll(); // this unregisters the GlobalKeys      });    } catch() {    }  }  ...}
// flutter_sdk/packages/flutter/lib/src/widgets/framework.dartclass _InactiveElements { ... void _unmountAll() { _locked = true; // 将 Element 拷贝到临时变量 elements 中 final List<Element> elements = _elements.toList()..sort(Element._sort); // 清空 _elements,当前方法执行完,elements 也会被回收,则全部 Element 正常情况下都会被 GC 回收。 _elements.clear(); try { elements.reversed.forEach(_unmount); } finally { assert(_elements.isEmpty); _locked = false; } } ...}
复制代码


finalize 阶段 _inactiveElements 中保存了被 Element Tree 丢弃,并且不会再使用的 Element;在执行完 unmount 方法后,即等待被 GC 回收。


因此 Element 泄漏可定义为:执行完 umount,并且 GC 后,仍存在这些 Element 的引用,则说明 Element 发生内存泄漏。满足条件 2。

内存泄漏检测工具

工具描述

我们对内存泄漏工具有 2 点要求:


  1. 准确。包括核心对象泄漏检测:image, layer,state,能够解决 Flutter 90% 以上对内存泄漏问题

  2. 高效。业务无感,自动化检测,优化引用链,快速定位到泄漏源

准确

从上文描述,BuildContext 毫无疑问是最有可能导致大内存泄漏的对象,是作为监控对象的最佳对象。为了提高准确度,我们也把最常用的 State 对象监控起来。


为什么要添加 State 对象的监控呢?


因为业务逻辑控制实现在 State 中,业务中实现的“闭包或者方法”传递很容易导致 State 泄漏。例子如下。


class MainApp extends StatefulWidget {  @override  State<StatefulWidget> createState() {    return _MainAppState();  }}
class _MainAppState extends State<MainApp> { @override void initState() { super.initState(); // 注册这个回调,这个回调如果没有被反注册或者被其他上下文持有,都会导致 _MainAppState 泄漏。 xxxxManager.addListerner(handleAction); }
@override Widget build(BuildContext context) { return MaterialApp( ); }
// 1个回调 void handleAction() { ... }}
复制代码


State 关联哪些内存会被泄漏?


结合以下代码看,泄漏肯定会导致关联的 Widget 泄漏,而 Widget 关联的内存如果是一张的 Image 或者 gif 的话,泄漏的内存也会很大。同时,State 中可能还以关联其他的一些强引用住的内存。


// flutter_sdk/packages/flutter/lib/src/widgets/framework.dartabstract class State<T extends StatefulWidget> with Diagnosticable {  // 强引用对应的 Widget 泄漏  T _widget;  // unmount 时候,_element = null, 不会导致泄漏  StatefulElement _element;  ...}
// flutter_sdk/packages/flutter/lib/src/widgets/framework.dartclass StatefulElement extends ComponentElement { ... @override void unmount() { ... _state.dispose(); _state._element = null; // 其他地方持有,则导致泄漏。unmount 后 State 仍被持有,可作为一个泄漏定义。 _state = null; } ...}
复制代码


所以,我们方案将关联大内存的 BuildContext,业务常操作的 State 一并监控起来,提高整套方案的准确度。

高效

怎么实现自动化高效的内存泄漏检测?


首先我们要怎么明确一个对象是否发生泄漏?以 BuildContext 为例,我们采取类似“Java 对象弱引用”判定对象泄漏的方式:


  1. 将 finalizeTree 阶段的 inactiveElements 放到 weak Reference map 中

  2. Full GC 后检测 weak Reference map ,如果其中仍持有未释放的 Element,则判定为发生泄漏

  3. 将泄漏的 Element 关联的 size,对应的 Widget,泄漏引用链信息输出


虽然 Dart 没有直接提供“弱引用”检测能力,但我们 Hummer 引擎从底层将“弱引用泄漏检测”功能完整实现了,这里简单介绍它判定泄漏的接口:


// 添加需要检测泄漏的对象,类似将对象放到若引用map中external void leakAdd(Object suspect, {    String tag: '',});// 检测之前放入的对象是否发生了泄漏,会进行 FullGcexternal void leakCheck({    Object? callback,    String tag: '',    bool clear: true,});external void leakClear({    String tag: '',});external String leakCount();external List<String> leakTags();
复制代码


因此,要实现自动化检测,我们只需要明确 leakAdd(),leakCheck() 调用的时机即可。

leakAdd 时机

BuildContext 的时机在 finalizeTree 的 unmount 流程中:


// flutter_sdk/packages/flutter/lib/src/widgets/framework.dartclass _InactiveElements {  ...  void _unmount(Element element) {        element.visitChildren((Element child) {      assert(child._parent == element);      _unmount(child);    });
// BuildContext 泄漏 leakAdd() 时机 if (!kReleaseMode && debugMemoryLeakCheckEnabled && null != debugLeakAddCallback) { debugLeakAddCallback(_state); }
element.unmount(); ... } ...}
复制代码


State 的时机在对应的 StatefulElement 的 unmount 流程中:


// flutter_sdk/packages/flutter/lib/src/widgets/framework.dartclass StatefulElement extends ComponentElement {  @override  void unmount() {    _state.dispose();    _state._element = null;
// State 泄漏 leakAdd() 时机 if (!kReleaseMode && debugMemoryLeakCheckEnabled && null != debugLeakAddCallback) { debugLeakAddCallback(_state); }
_state = null; }}
复制代码

leakCheck 时机

leakCheck 本质上是一个检测是否存在泄漏的时机点,我们认为 Page 退出是个合适的时机,以业务 Page 为单位进行内存泄漏检测。示例代码如下:


// flutter_sdk/packages/flutter/lib/src/widgets/navigator.dartabstract class Route<T> {  _navigator = null;  // BuilContext, State leakCheck时机  if (!kReleaseMode && debugMemoryLeakCheckEnabled && null != debugLeakCheckCallback) {    debugLeakCheckCallback();  }} 
复制代码

工具实现

以 Page 为单位的自动化内存泄漏,根据使用场景,提供三种内存泄漏检测工具。


  1. Hummer 引擎深度定制的 DevTools 资源面板展示,可以自动/手动触发内存泄漏检测

  2. 独立 APP 端内存泄漏展示,在 Page 发生泄漏时候,弹出泄漏对象详情

  3. Hummer 引擎海鸥实验室自动化检测,自动化将内存泄漏详情以报告给出


工具 1、2 提供开发过程的内存泄漏检测能力,工具 3 可作为 APP 常规健康测试,自动化测试并输出检测报告结果。

异常检测实例

在 Demo 中模拟 StatelessWidget, StatefulWidget 被 BuildContext 持有导致的泄漏。泄漏的原因是被静态持有,Timer 异常持有。


// 验证 StatelessWidget 泄漏class StatelessImageWidget extends StatelessWidget {  @override  Widget build(BuildContext context) {    // 模拟静态持有 BuildContext 导致泄漏    MyApp.sBuildContext.add(context);
return Center( child: Image( image: NetworkImage("https://avatars2.githubusercontent.com/u/20411648?s=460&v=4"), width: 200.0, ) ); }}
class StatefulImageWidget extends StatefulWidget { @override State<StatefulWidget> createState() { return _StatefulImageWidgetState(); }}
// 验证 StatefulWidget 泄漏class _StatefulImageWidgetState extends State<StatefulImageWidget> { @override Widget build(BuildContext context) { if (context is ComponentElement) { print("sBuildContext add :" + context.widget.toString()); }
// 模拟被 Timer 异步持有 BuildContext 导致泄漏,延时 1h 用于说明问题 Timer(Duration(seconds: 60 * 60), () { print("zw context:" + context.toString()); });
return Center( child: Image( image: NetworkImage("https://avatars2.githubusercontent.com/u/20411648?s=460&v=4"), width: 200.0, ) ); }}
复制代码


分别进入 2 个 Widget 页面退出,检测泄漏结果。


工具 1 - DevTools 资源面板展示:


StatefulElement 泄漏检测,可见 StatefulImageWidget 被 Timer 异步持有导致泄漏。



StatelessElement 泄漏检测,可见 StatelessImageWidget 被静态持有导致导致泄漏。



工具 2 - 独立 app 端泄漏展示:


聚合页展示所有泄漏对象,详情页展示了泄漏的对象以及对象引用链。



根据工具给出的泄漏链,都能够快速地找到泄漏源。

业务实战

UC 某个内容型业务,特点是多图文、视频内容,内存消耗相当大。之前我们基于 Flutter 原生 Observatory 工具解决了一些 State, BuildContext 泄漏问题(耗时漫长,相当痛苦)。为了验证工具的实用价值,我们将内存泄漏问题还原去验证。结果发现:之前苦苦排查的问题,瞬间就能检测出来,效率大大提高,与 Observatory 工具去排查对比,简直是云泥之别。基于新的工具,我们陆续发现了许多之前没有排查出来的内存泄漏问题。


这个例子中泄漏的 StatefulElent 对应的是一个重量级页面,Element Tree 非常深,关联泄漏的内存很可观。我们解决这个问题后,业务由于 OOM 导致的崩溃率下降显著。



我们的另一款纯 Flutter APP 的开发同学反馈,知道部分场景下内存会增加,存在泄漏,但没有有效的手段进行检测和解决。接入我们的工具进行检测,结果检测出多处不同场景下的内存泄漏问题。


业务同学对此非常认可,这也给了我们做这套工具很大的鼓舞,因为可以快速解决实际的问题,赋能业务。


总结展望

从 Flutter 内存泄漏的实际出发,总结了内存消耗的大头主要是 Image, Layer 以及探索一套高效内存泄漏检测方案的必要性。通过借鉴 Android 的 leak-canary,我们总结了寻找泄漏监控对象的三个条件;通过对 Flutter 渲染三棵树的分析,确定 BuildContext 作为监控对象。为了提高检测工具的准确性,我们又增加 State 的监控并分析了必要性。最终探索出一套高效的内存泄漏检工具的方案,其优势在于:


  • 更准确:包括核心泄漏对象 widget,Layer,State;直接监控泄漏的根源;完备定义内存泄漏

  • 更高效:自动化检测泄漏对象,更加短和直接的引用链

  • 业务无感知:减轻开发负担


这是业界首创的一套逻辑完备,实用价值高,高效自动化的内存泄漏检测工具,可谓最强 Flutter 内存泄漏检测工具方案。


该方案可以覆盖我们当前遇到所有的内存泄漏问题,大大提升内存泄漏检测效率,为我们业务 Flutter 化保驾护航。目前方案实现基于 Hummer 引擎,运行在 debug,profile 模式下,后续会探索线上 release 模式检测,覆盖本地无法复现的场景。


我们有计划提供针对非 Hummer 引擎的接入方式,反哺社区,敬请期待。

发布于: 1 小时前阅读数: 6
用户头像

还未添加个人签名 2018.07.07 加入

阿里巴巴移动&终端技术官方账号。

评论

发布
暂无评论
这可能是,Flutter 中最“强悍”的内存泄漏检测方案......