這可能是,Flutter 中最“強悍”的記憶體洩漏檢測方案......

阿里巴巴移動技術發表於2021-11-18

作者:吳志偉

近兩年來,無論是創新型應用還是老牌旗艦型應用,都在或多或少地使用 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 引擎的接入方式,反哺社群,敬請期待。

相關文章