5

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

 2 years ago
source link: https://my.oschina.net/alimobile/blog/5311691
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

这可能是,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.dart
mixin 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.dart
class BuildOwner {
  ...
  void finalizeTree() {
    try {
      // _inactiveElements 中记录不再使用的 Element
      lockState(() {
        _inactiveElements._unmountAll(); // this unregisters the GlobalKeys
      });
    } catch() {
    }
  }
  ...
}

// flutter_sdk/packages/flutter/lib/src/widgets/framework.dart
class _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.dart
abstract class State<T extends StatefulWidget> with Diagnosticable {
  // 强引用对应的 Widget 泄漏
  T _widget;
  // unmount 时候,_element = null, 不会导致泄漏
  StatefulElement _element;
  ...
}

// flutter_sdk/packages/flutter/lib/src/widgets/framework.dart
class 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: '',
});
// 检测之前放入的对象是否发生了泄漏,会进行 FullGc
external 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.dart
class _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.dart
class 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.dart
abstract 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 引擎的接入方式,反哺社区,敬请期待。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK