概述
据了解,很多Flutter业务上线后都出现内存占用较高的问题,首当其冲的是 Image 内存占用过多。
Image 图片内存过高,可能由于 Flutter ImageCache 对内存缺房控制力导致,也有可能是被业务代码强引用,泄漏导致。如果 Image 被业务强引用,则调整 ImageCache 容量,增加 gc 次数都没有效果。
面对这种“强引用”的泄漏,需要定位到引用 Image 的业务代码才能解决。本文主要描述怎么利用 Observatory 工具,定位强引用Image的业务代码。
分析
class ImageTestWidget extends StatefulWidget {
@override
State<StatefulWidget> createState() {
return _ImageTestWidgetState();
}
}
class _ImageTestWidgetState extends State<ImageTestWidget> {
@override
Widget build(BuildContext context) {
return Center(
child: Image(
image: NetworkImage("https://avatars2.githubusercontent.com/u/20411648?s=460&v=4"),
width: 200.0,
)
);
}
}
以一个简单的例子来说明,怎么通过 Image,反查引用链,定位到是 ImageTestWidget 对其引用。这里的 ImagetTestWidget 就是我们的目标,从这里就能看出 Image 被哪个页面持有/泄漏。
思路是从 Image 开始反向遍历 Widget tree,定位到 ImageTestWidget。期望的引用链如下:
Image 引用链
Observatory 会将 instance 到 gc Root 的引用链显示在 Retained path 下。如图中红圈1所示,Image 的 Retained path 只显示 image 对象被 weak persistent handle(参考 Flutter内存分析 描述,这是一种 GC Root,gc时会被遍历,如果 dart对象 被回收,其关联的 external 内存会得到释放)直接持有,但根据这个信息显然是无法定位到我们的目标 ImageTestWidget。
为什么 Observatory 显示 Image 的引用链与上面预期的不一样?
原来这里的 Image 与我们代码中的 Image 并不是同一个类。
part of dart.ui;
class ImageInfo {
/// Creates an [ImageInfo] object for the given [image] and [scale].
///
/// Both the image and the scale must not be null.
///
/// The tag may be used to identify the source of this image.
const ImageInfo({ @required this.image, this.scale = 1.0, this.debugLabel })
: assert(image != null),
assert(scale != null);
/// The raw image pixels.
///
/// This is the object to pass to the [Canvas.drawImage],
/// [Canvas.drawImageRect], or [Canvas.drawImageNine] methods when painting
/// the image.
final ui.Image image;
...
}
// Observatory 中显示的 Image 对象
class Image extends NativeFieldWrapperClass2 {
// This class is created by the engine, and should not be instantiated
// or extended directly.
//
// To obtain an [Image] object, use [instantiateImageCodec].
@pragma('vm:entry-point')
Image._();
...
/// Returns an error message on failure, null on success.
String? _toByteData(int format, _Callback<Uint8List?> callback) native 'Image_toByteData';
/// Release the resources used by this object. The object is no longer usable
/// after this method is called.
void dispose() native 'Image_dispose';
...
}
代码中直接用 Image.network() 的 Iamge:
// 业务代码中 Image 对象
class Image extends StatefulWidget {
...
Image.network();
...
}
class _ImageState extends State<Image> with WidgetsBindingObserver {
@override
Widget build(BuildContext context) {
...
Widget result = RawImage(
image: _imageInfo?.image,
...
);
...
}
}
从上面代码片段可知,Observatory 中显示的 image 是 ui.Image。分析图片解码过程可知,这个是engine中解码后的 CanvasImage(真正占用内存的地方) 在 dart 层的表示,并通过 weak persistent handle 关联起来。这个 ui.Image会至少被3个对象持有,上图圈2中:
- ImageInfo : 解码完成后保存 ui.Image
- RawImage : 持有 ui.Image 的 widget
- RenderImage : 持有 ui.Image 的 renderObject
结合 _ImageState 的 build() 方法,可知 RawIamge 才是我们用 Image.network() 生成在 Widget tree 上的对象,所以我们应该从 RawImage 开始查找。
RawImage 引用链
通过 Inbounce references 查看反向引用。可见引用 RawImage 有2个分支(实验场景简单,实际场景会有更多引用路径),哪个分支的路径能够到达 ImageTestWidget 呢?
这里先简单梳理下 Flutter Widget tree,Element tree 的关系:
从主要类关系看到,Widget 与 Element 具有一一对应关系。
两棵树生成的过程:
- runApp() 时候 attachRootWidget(Widget rootWidget),root Widget 为 RenderObjectToWidgetAdapter,root Element 为 RenderObjectToWidgetElement,其 child 对应 RenderObjectToWidgetAdapter.child,即我们的app代码
- mount 时候会递归将 child / children 对应的 element 构建出来,并挂载到 Element tree
- 最后想成一棵 Element tree。
这里需要注意到是,Widget 家族是没有 child 这个成员,而 Element 家族有, 真正具有引用关系的那棵树是 Element tree,Widget tree 概念上成立,但对象间并无直接引用但关系。
业务代码通常不会直接去写 Element,Element是根据我们定义的 Widget 生成的,总之 , 反向引用链 查找的方法是:
- 找到第一个 Widget 对应的 Element
- 从这个 Element 开始反向遍历 Element 树,通过 _parent, _child 属性可遍历一条完整的引用支路
- Element 中 _widget, _state 可帮我们确定某个节点是否是我们目标到 Widget,例如例子中的 ImageTestWidget
下面回答上面 “哪个才是正确的引用路径” 的问题。
RawImage 对应的 element 是 LeafRenderObjectElement,其 _widget 引用了 RawImage。接着从 LeafRenderObjectElement 反向遍历 Element tree,跟踪 _child 属性引用自己的分支展开即可定位到 ImageTestWidget。如下展示了从 ui.Image 到 ImageTestWidget的完整引用链:
BuildContext 泄漏
通过上面的操作已经可以定位到强引用 Image 的业务代码 Widget,也可以定位其归属的页面。根据页面是否关闭,则可确定是否泄漏。如果是泄漏,那是什么导致了业务 Widget 没有被系统回收呢?
Dart_NotifyIdle() 的逻辑可看出,Flutter gc机制是可以应付频繁创建/销毁 Widget 的这种操作的 ,它会尽量在每一帧绘制完都去执行 gc 操作,回收掉销毁的 Widget。
每一帧画完后,在 finalizeTree() 中会对 inactive elements 进行 unmount 操作
@override
void drawFrame() {
...
assert(!debugBuildingDirtyElements);
try {
if (renderViewElement != null)
buildOwner.buildScope(renderViewElement);
super.drawFrame();
// 回收 elements
buildOwner.finalizeTree();
} finally {
assert(() {
debugBuildingDirtyElements = false;
return true;
}());
}
...
}
elements 被 unmount 之后,脱离 element 树,连同被它引用的 widget, state 都会后面的被 gc 回收。
总之,Widget 是在 Element 回收后,关联被回收的。同样的,如果 Element 没有被回收,那被它关联的 Widget,State 都不会被回收。Element 泄漏的概率比较高,因为 Element 继承了 BuildContext,业务代码会经常传递这个参数。特别是 异步执行 的代码的场景(Feature, async/await,methodChannel),这些代码可能会长期持有传入的BuildContext,导致 element 以及关联的 widget, state 发生泄漏。
追踪具体泄漏 BuildContext 的代码,可以继续从这个 Element 反向引用继续查找,特别留意 Clouse, Completer 之类对其的引用。
总结
本文主要提炼了一种基于 Observatory 工具,从 Image 反向引用定位到业务 Widget 的方法,用于解决 Image 强引用的问题。而造成 Image 强引用的根本原因很大可能是 BuildContext 被异步代码长期持有导致。