[實戰] Flutter 上的內存泄漏監控


一、前言

Flutter 所使用的 Dart 語言具有垃圾回收機制,有垃圾回收就避免不了會內存泄漏。
在 Android 平台上有個內存泄漏檢測工具 LeakCanary
它可以方便地在 debug 環境下檢測當前頁面是否泄漏。
本文將會帶你實現一個 Flutter 可用的 LeakCanary,
並講述我是怎么用該工具檢測出了 1.9.1 Framework 上的兩個泄漏。

二、Dart 中的弱引用

在具有垃圾回收的語言中,弱引用是檢測對象是否泄漏的一個好方式。
我們只需弱引用觀測對象,等待下次 Full GC
如果 GC 之后對象為 null,說明被回收了,
如果不為 null 就可能是泄漏了。

Dart 語言中也有着弱引用,它叫 Expando<T>
看下它的 API:

class Expando<T> {
  external T operator [](Object object "");
  external void operator []=(Object object, T value);
}

你可能會好奇上述代碼弱引用體現在哪里呢?
其實是在 expando[key]=value 這個賦值語句上。
Expando 會以弱引用的方式持有 key,這里就是弱引用的地方。

那么問題來了,這個 Expando 弱引用持有的是 key
但是本身又沒有提供 getKey() 這樣的 API,
我們就無從下手去得知 key 這個對象是否被回收了。

為了解決這個問題,我們來看下 Expando 的具體實現,
具體的代碼在 expando_path.dart

@path
class Expando<T> {
  // ...
  T operator [](Objet object "") {
    var mask = _size - 1;
    var idx = object._identityHashCode & mask;
    // sdk 是把 key 放到了一個 _data 數組內,這個 wp 是個 _WeakProperty
    var wp = _data[idx];

    // ... 省略部分代碼
    return wp.value;
    // ... 省略部分代碼
  }
}

注意: 此 patch 代碼不適用於 Web 平台

我們可以發現這個 key 對象是放到了 _data 數組內,
用了一個 _WeakProperty 來包裹,那么這個 _WeakProperty 就是關鍵類了,
看下它實現,代碼在 weak_property.dart

@pragma("vm:entry-point")
class _WeakProperty {

  get key => _getKey();
  // ... 省略部分代碼
  _getKey() native "WeakProperty_getKey";
  // ... 省略部分代碼
}

這個類有我們想要的 key,可以用於判斷對象是否還在。

怎么獲取這種私有屬性和變量呢?
Flutter 中的 Dart 是不支持反射的(為了優化打包體積,關閉了反射),
有沒有其他辦法來獲取到這種私有屬性呢?

答案肯定是 “有”,為了解決上述問題,
我來向大家介紹一個 Dart 自帶的服務——
Dart VM Service

三、Dart vm_service

Dart VM Service (后面簡稱 vm_service
是 Dart 虛擬機內部提供的一套 Web 服務,數據傳輸協議是 JSON-RPC 2.0。
不過我們並不需要要自己去實現數據請求解析,
官方已經寫好了一個可用的 Dart SDK 給我們用:vm_service

ObjRef, Objid 的作用

先介紹 vm_service 中的核心內容:ObjRefObjid

vm_service 返回的數據主要分為兩大類,
ObjRef(引用類型) 和 Obj(對象實例類型)。
其中 Obj 完整的包含了 ObjRef 的數據,
並在其基礎上增加了額外信息
ObjRef 只包含了一些基本信息,例如:idname 等)。

基本所有的 API 返回的數據都是 ObjRef
ObjRef 里面的信息滿足不了你的時候,
再調用 getObject(,,,)來獲取 Obj

關於 id ObjObjRef 都含有 id
這個 id 是對象實例在 vm_service 里面的一個標識符,
vm_service 幾乎所有的 API 都需要通過 id 來操作,
比如:getInstance(isolateId, classId, ...)
getIsolate(isolateId)
getObject(isolateId, objectId, ...)

如何使用 vm_service 服務

vm_service 在啟動的時候會在本地開啟一個 WebSocket 服務,
服務 URI 可以在對應的平台中獲得:

  • Android 在 FlutterJNI.getObservatoryUri() 中;
  • iOS 在 FlutterEngine.observatoryUrl 中。

有了 URI 之后我們就可以使用 vm_service 的服務了,
官方有一個幫我們寫好的 SDK: vm_service
直接使用內部的 vmServiceConnectUri 就可以獲得一個可用的 VmService 對象。

vmServiceConnectUri 的參數需要是一個 ws:// 協議的 URI,默認獲取的是 http 協議,需要借助 convertToWebSocketUrl方法轉化下

四、泄漏檢測實現

有了 vm_service 之后,我們就可以用它來彌補 Expando 的不足了。
按照之前的分析,我們要獲 Expando 的私有字段 _data
這里可以使用 getObject(isolateId, objectId) API,
它的返回值是 Instance
內部的 fields 字段保存了當前對象的所有屬性。
這樣我們就可以遍歷屬性獲取到 _data,來達到反射的效果。

現在的問題是 API 參數中的 isoateIdobjectId 是什么呢?
根據我前面介紹的 id 相關內容,它是對象在 vm_serive 中的標識符。
也就是我們只有通過 vm_service 才可以獲取到這兩個參數。

IsolateId 的獲取

Isolate(隔離區)是 Dart 里面的一個非常重要的概念,
基本上一個 isolate 相當於一個線程,
但是和我們平常接觸的線程不同的是:不同 isolate 之間的內存不共享。

因為有了上述特性,我們在查找對象的時候也要帶上 isolateId
通過 vm_servicegetVM() API 可以獲取到虛擬機對象數據,
再通過 isolates 字段可以獲取到當前虛擬機所有的 isolate

那么怎么篩選出我們想要的 isolate 呢?
這里簡單起見只篩選主 isolate
這部分的篩選可以查看 dev_tools
的源碼: service_manager.dart#_initSelectedIsolate 函數。

ObjectId 的獲取

我們要獲取的 objectId 就是 expandovm_service 中的 id
這里可以把問題擴展下:

如何獲取指定對象在 vm_service 中的 id

這個問題比較麻煩,vm_service 中沒有實例對象和 id 轉換的 API,
有個 getInstance(isolateId, classId, limit) 的 API,
可以獲取某個 classId 的所有子類實例,
先不說如何獲取到想要的 classId
此 API 的性能和 limit 都讓人擔憂。

沒有好辦法了嗎?其實我們可以
借助 Library 的 頂級函數(直接寫在當前文件,不在類中,例如 main 函數)
來實現該功能。

簡單說明下 Library 是什么東西,Dart 中的分包管理是根據 Library 來的,同一個 Library 內的類名不能重復,一般情況下一個 .dart 文件就是一個 Library,當然也有例外,比如:part of 和 export。

vm_service 有個 invoke(isolateId, targetId, selector, argumentIds) API,
可以用來執行某個常規函數
gettersetter、構造函數、私有函數屬於非常規函數),
其中如果 targetId 是 Library 的 id,那么 invoke
執行的就是 Library 的頂級函數。

有了 invoke Library 頂級函數的路徑,
就可以用它實現對象轉 id 了,代碼如下:

int _key = 0;
/// 頂級函數,必須常規方法,生成 key 用
String generateNewKey() {
  return "${++_key}";
}

Map<String, dynamic> _objCache = Map();
/// 頂級函數,根據 key 返回指定對象
dynamic keyToObj(String key) {
  return _objCache[key];
}

/// 對象轉 id
String obj2Id(VMService service, dynamic obj) async {

  // 找到 isolateId。這里的方法就是前面講的 isolateId 獲取方法
  String isolateId = findMainIsolateId();
  // 找到當前 Library。這里可以遍歷 isolate 的 libraries 字段
  // 根據 uri 篩選出當前 Library 即可,具體不展開了
  String libraryId = findLibraryId();

  // 用 vm service 執行 generateNewKey 函數
  InstanceRef keyRef = await service.invoke(
    isolateId,
    libraryId,
    "generateNewKey",
    // 無參數,所以是空數組
    []
  );
  // 獲取 keyRef 的 String 值
  // 這是唯一一個能把 ObjRef 類型轉為數值的 api
  String key = keyRef.valueAsString;

  _objCache[key] = obj;
  try {
    // 調用 keyToObj 頂級函數,傳入 key,獲取 obj
    InstanceRef valueRef = await service.invoke(
      isolateId,
      libraryId,
      "keyToObj",
      // 這里注意,vm_service 需要的是 id,不是值
      [keyRef.id]
    )
    // 這里的 id 就是 obj 對應的 id
    return valueRef.id;
  } finally {
    _objCache.remove(key);
  }
  return null;
}

對象泄漏判斷

現在我們已經可以獲取到 expando 實例在
vm_service 中的 id 了,接下來就簡單了。

先通過 vm_service 獲取到 Instance
遍歷里面的 fields 屬性,找到 _data 字段
(注意 _dataObjRef 類型),
用同樣的辦法把 _data 字段轉成 Instance 類型
_data 是個數組,Obj 里面有數組的 child 信息)。

遍歷 _data 字段,如果都是 null
表明我們觀測的 key 對象已經被釋放了。
如果 item 不為 null
再次把 item 轉為 Instance 對象,
取它的 propertyKey
(因為 item 是 _WeakProperty 類型,
Instance 里面特地為 _WeakProperty 開了這個字段)。

強制 GC

文章開頭說到,如果要判斷對象是否泄漏,
需要在 Full GC 之后判斷弱引用是否還在。
有沒有辦法手動觸發 GC 呢?

答案是有的,vm_service 雖然沒有強制 GC 的 API,
但是 Dev Tools 的內存圖標右上角有個 GC 的按鈕,
我們仿照着它來操作就行!
Dev Tools 是調用了 vm_service
getAllocationProfile(isolateId, gc: true) API
來實現手動 GC 的。

至於這個 API 觸發的是不是 FULL GC,
並沒有說明,我測試觸發的都是 FULL GC,
如果要確定在 FULL GC 之后檢測泄漏,
可以監聽 gc 事件流,
vm_service 提供了該功能。

至此為止,我們已經可以實現泄漏的監控,
而且可以獲取到泄漏目標在 vm_serive 中的 id 了,
下面就開始獲取分析泄漏路徑。

五、獲取泄漏路徑

關於泄漏路徑的獲取,
vm_service 提供了一個 API 叫 getRetainingPath(isolateId, objectId, limit)
直接使用此 API 就可以獲取到泄漏對象到 GC Roots 的引用鏈信息,
是不是感覺很簡單?不過光這樣可不行,
因為它有以下幾個坑點:

Expando 持有問題

如果在執行 getRetainingPath 的時候,
泄漏對象被 expando 持有的話會產生以下兩個問題

  • 因為該 API 返回的引用鏈只有一條,
    返回的引用鏈會經過 expando,導致無法獲取真正的泄漏節點信息;
  • 在 ARM 設備上會出現 native crash,
    具體錯誤出現在 utf8 字符解碼上。

此問題很好解決,注意下在前面泄漏檢測完之后,
釋放掉 expando 就行。

id 過期問題

Instance 類型的 idClassLibraryIsolate
這種 id 不一樣,是會過期的。
vm_service 中對於此類臨時 id 的緩存容量默認大小是 8192
是一個循環隊列。

因為此問題的存在,我們在檢測到泄漏的時候,
不能只保存泄漏對象的 id,需要保存原對象,
而且不能強引用持有對象。
所以這里我們還是需要使用 expando
來保存我們檢測到的泄漏對象,
等到需要分析泄漏路徑的時候,
再把對象專為 id

六、1.9.1 Framework 上的內存泄漏

完成了泄漏檢測和路徑獲取之后,
得到了一個簡陋的 leakcanary 工具。
當我在 1.9.1 版本的 Framework 下測試此工具的時候發現,
我觀測一個頁面它就泄漏一個頁面!!!

通過 dev_tools dump 出來的對象來看,的確泄漏了!

也就是 1.9.1 Framework 里面存在着泄漏,
而且此泄漏會泄漏整個頁面。

接下來開始排查泄漏原因,這里就碰到一個問題:
泄漏路徑太長:getRetainingPath 返回的鏈路長度有 300+,
排查了一下午也沒有找到問題根源。

結論:直接根據 vm_service 返回的數據是很難分析問題來源的,
需要對泄漏路徑的信息二次處理下。

如何縮短引用鏈

首先看下泄漏路徑為什么會這么長,
通過觀測返回的鏈路后發現,
絕大部分的節點都是 Flutter UI 組件節點
(例如:widgetelementstaterenderObject)。

也就是說引用鏈經過了 Flutter 的 widget tree,
熟悉 Flutter 的開發者應該都知道,
Flutter 的 widget tree 的層次是非常深的。
既然引用鏈長的原因是因為包含了 widget tree,
而且 widget tree 基本都是成塊出現的,
那我們只要把引用鏈中的節點根據類型來分類、聚合,
就可以大幅縮短泄漏路徑了。

分類

根據 Flutter 的組件類型,將節點分為以下幾種類型:

  • element:對應 Element 節點;
  • widget:對應 Widget 節點;
  • renderObject:對應 RenderObject節點;
  • state:對應 State<T extends StatefulWdget> 節點;
  • collection:對應集合類型節點,例如:ListMapSet
  • other:對應其他節點。

聚合

節點的分類做好了之后,就可以把相同類型的節點聚合一下。
這里提下我的聚合方式:

collection 類型的節點看成了連接節點,
相鄰的相同節點合並到一個集合內,
如果兩個相同類型的集合中間是通過 collection 節點相連的,
就繼續把這兩個集合合並成一個集合,遞歸進行。

通過 分類-聚合 的處理后,原先 300+ 的鏈路長度,可以縮短為 100+。

繼續排查 1.9.1 Framework 的泄漏問題,
路徑雖然縮短了,可以找到問題大致出現在 FocusManager 節點上!
但是具體問題還是難以定位,主要有以下兩點:

  • 引用鏈節點缺少代碼位置:因為 RetainingObject 數據中只有 parentFieldparentIndexparentKey 三個字段來表示當前對象引用下一個對象的信息,通過該信息找代碼位置效率低下;
  • 無法知道當前 Flutter 組件節點的信息:比如 Text 的文本信息,element 所在的 widget 是啥,state 的生命周期狀態,當前組件屬於哪個頁面,等等。

介於上述兩個痛點,還需要對泄漏節點的信息做擴展處理:

  • 代碼位置:節點的引用代碼位置其實只需要解析 parentField 就行,通過 vm_serive 解析 class,取內部的 field,找到對應的 script 等信息。此方法可以獲取到源碼;
  • 組件節點信息:Flutter 的 UI 組件都是繼承自 Diagnosticable,也就是只要是 Diagnosticable 類型的節點都可獲取到非常詳細的信息(dev_tools 調試時候,組件樹信息就是通過 Diagnosticable.debugFillProperties 方法獲取的)。除了這個還需要擴展當前組件所在 route 的信息,這個很重要,判斷組件所在頁面用。

排查 1.9.1 Framework 泄漏根源

通過上述的種種優化后,我得到了下面這個工具,
在兩個 _InkResponseState 節點中發現了問題:

泄漏路徑中有兩個 _InkResponseState 節點所屬的 route 信息不同,
表明這兩個節點在兩個不同的頁面中。
頂部 _InkResponseState 的描述信息顯示 lifecycle not mounted
說明組件已經銷毀了,但是還是被 FocusManager 引用着!
問題出現在這,來看下這部分代碼

代碼中可以明顯的看到 addListener 時候
StatefulWidget 的生命周期理解錯誤。
didChangeDependencies 是會多次調用的,
dispose 只會調用一次,
所以這里就會出現 listener 移除不干凈的情況。

修復了上述泄漏之后,發現還有一處泄漏。
排查后發現泄漏源在 TransitionRoute 中:

當打開一個新頁面的時候,
該頁面的 Route(也就是代碼中的 nextRoute
會被前一個頁面的 animation 所持有,
如果頁面跳轉都是 TransitionRoute
那么所有的 Route 都會泄漏!

好消息是以上泄漏都在 1.12 版本之后修復了。

修復完上述兩個泄漏之后,
再次測試,RouteWidget 都可以回收了,
至此 1.9.1 Framework 排查完畢。


本文作者: 戚耿鑫

現就職於快手應用研發平台組 Flutter 團隊,負責 APM 方向開發研究。從 2018 年開始接觸 Flutter,在 Flutter 混合棧、工程化落地、UI 組件等方面有大量經驗。

聯系方式:qigengxin@kuaishou.com


「Flutter 中文社區教程」由社區的開發者投稿,內容同步發布到 flutter.cn 網站以及 「Flutter 社區」的各個社交平台。本項目內部測試中,籌備完成后會開放投稿。

在 flutter.cn 閱讀本文:https://flutter.cn/community/tutorials/memory-leak-monitoring-on-flutter


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM