概述
據了解,很多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 被異步代碼長期持有導緻。