作者:吳志偉
近兩年來,無論是創新型應用還是老牌旗艦型應用,都在或多或少地使用 Flutter 技術。然而,目前 Flutter 業務團隊反饋最普遍的問題是,Flutter 記憶體佔用過高。
Flutter 記憶體佔用過高原因比較複雜,需另開一個主題才能說清楚。簡單總結下我們調研的結論:Dart Heap 記憶體管理以及 Flutter Widget 設計綜合導致業務記憶體較高,其最核心的問題引擎設計使開發者容易踩中記憶體洩漏。開發過程中,記憶體洩漏常見且難以定位,總結主要 2 點原因:
- Flutter 渲染三棵樹的設計,以及 Dart 各種非同步程式設計的特點,導致物件引用關係比較繞,分析困難
- Dart “閉包”,“例項方法”可賦值傳遞,導致所在的類被方法上下文持有,不經意就會發生洩漏。典型例如註冊一個 listener 沒有反註冊,導致 listener 所在的類物件洩漏
開發者享受了 Flutter 開發的便利性,卻不知不覺中承受了記憶體洩漏的苦果。因此,我們迫切需要一套高效的記憶體洩漏檢測工具來擺脫這種困境。
盤點我瞭解到的幾種記憶體洩漏檢測方案:
- 監控 State 是否洩漏:針對 State 的洩漏檢測。但 State 是 Flutter 記憶體洩漏中佔比最大的物件嗎?StatelessWidget 的物件也是可以引用很大記憶體的
- 監控 Layer 個數:對比 正在使用,記憶體中的 Layer 個數來判定是否存在記憶體洩漏。方案對記憶體洩漏判定是否準確?Layer 物件離業務 Widget 太遠,溯源太困難
- Expando 弱引用洩漏判定:判定特定物件是否洩漏並返回引用鏈 。但我們不知道Flutter 中最應該監控的物件是哪個,哪個物件洩漏是主要問題?
- 基於 Heap Snapshot 記憶體洩漏檢測:對比不同兩個時間點的 Dart 虛擬機器 Heap 物件的增長,以“class記憶體增量”,“物件記憶體個數” 2 個指標檢測發生洩漏的可疑物件。這是個通用的解決方案,但要做到高效定位到洩漏物件(Image, Layer)才比較有價值。目前“確定檢測物件”和“檢測時機”這 2 個問題都不好解決,所以還需要人工逐一排查確認,效率不高。
總之,我們覺得方案 1,2 邏輯上不夠完備,方案 3,4 效率有待提高。
更好的方案是?
參考 Android,LeakCanary 能夠準確、高效檢測 Activity 記憶體洩漏,解決記憶體洩漏的主要問題。那我們能不能在 Flutter 中也實現一套這樣的工具呢?這應該是一套更好的方案。
在回答這個問題之前,先思考下為什麼 LeakCanary 要挑選 Activity 作為記憶體洩漏監控的物件,並且能夠解決主要的記憶體洩漏問題?
我們總結其至少滿足了下面 3 個條件:
- 洩漏物件引用的記憶體足夠大:Activity 物件引用的記憶體是非常大,是記憶體洩漏的主要問題
- 能夠完備定義記憶體洩漏:Activity 具有明確的生命週期和確切回收時機,洩漏定義完備,可實現自動化,提高效率
- 洩漏的風險高:Activity 基類為 Context,作為引數傳遞,使用非常頻繁,存在較高的洩漏風險
3 個條件反映了監控物件的必要性,監控工具的可操作性。
順著這個思路,如果我們能夠在 Flutter 中找到滿足上面 3 個條件的物件,將其監控起來,那就可以做一套 Flutter 的 LeakCanary 工具,用來解決 Flutter 中記憶體洩漏的主要問題。
從實際專案中回顧近期解決的記憶體洩漏問題,記憶體飆升體現在 Image, Picture 物件,如下圖所示。
雖然 Image, Picture 記憶體佔用高,是洩漏記憶體的主要貢獻者,但它們不能作為我們監控的目標,因為它們明顯不符合上面列出的 3 個條件:
- 記憶體佔用大,是其物件個數多,累加起來的,並不是由某一個 Image 引用而導致
- 無法定義什麼時候是洩漏的,沒有明確的生命週期
- 並不會作為一個常用的引數傳遞,使用地方都比較固定,例如 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 棵樹。因此:
- BuildContext 引用的記憶體佔用大,滿足條件 1
- 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 點要求:
- 準確。包括核心物件洩漏檢測:image, layer,state,能夠解決 Flutter 90% 以上對記憶體洩漏問題
- 高效。業務無感,自動化檢測,優化引用鏈,快速定位到洩漏源
準確
從上文描述,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 物件弱引用”判定物件洩漏的方式:
- 將 finalizeTree 階段的 inactiveElements 放到 weak Reference map 中
- Full GC 後檢測 weak Reference map ,如果其中仍持有未釋放的 Element,則判定為發生洩漏
- 將洩漏的 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 為單位的自動化記憶體洩漏,根據使用場景,提供三種記憶體洩漏檢測工具。
- Hummer 引擎深度定製的 DevTools 資源皮膚展示,可以自動/手動觸發記憶體洩漏檢測
- 獨立 APP 端記憶體洩漏展示,在 Page 發生洩漏時候,彈出洩漏物件詳情
- 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 引擎的接入方式,反哺社群,敬請期待。