Flutter 上的內存泄漏監控

一、前言

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

二、Dart 中的弱引用

在具備垃圾回收的語言中,弱引用是檢測對象是否泄漏的一個好方式。咱們只需弱引用觀測對象,等待下次 Full GC,若是 gc 以後對象爲 null,說明被回收了,若是不爲 null 就多是泄漏了github

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

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

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

那麼問題來了,這個 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 平臺websocket

咱們能夠發現這個 key 對象是放到了 _data 數組內,用了一個 _WeakProperty 來包裹,那麼這個 _WeakProperty 就是關鍵類了,看下它實現,代..碼在 weak_property.dartmarkdown

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

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

這個類有咱們想要的 key,能夠用於判斷對象是否還在!app

怎麼獲取這種私有屬性和變量呢?flutter 中的 dart 是不支持反射的(爲了優化打包 size,關閉了反射),有沒有其它辦法來獲取到這種私有屬性呢?socket

答案確定是 「有」,爲了解決上述問題,我這邊介紹一個 dart 自帶的服務,Dart VM Service

三、Dart vm_service

Dart VM Service (後面簡稱 vm_service)是 dart 虛擬機內部提供的一套 web 服務,數據傳輸協議是 JSON-RPC 2.0。不過咱們並不須要要本身去實現數據請求解析,官方已經寫好了一個可用的 dart sdk 給咱們用 vm_service

ObjRef, Obj 和 id 的做用

先介紹 vm_service 中的核心內容:ObjRef、Obj、id

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

基本全部的 api 返回的數據都是 ObjRef,當 ObjRef 裏面的信息知足不了你的時候,再調用 getObject(,,,)來獲取 Obj。

關於 id: Obj 和 ObjRef 都含有 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 參數中的 isoateId 和 objectId 是啥,根據我前面介紹的 id 相關內容,它是對象在 vm_serive 中的標識符。也就是咱們只有經過 vm_service 才能夠獲取到這兩個參數。

IsolateId 的獲取

Isolate(隔離區)是 dart 裏面的一個很是重要的概念,基本上一個 isolate 至關於一個線程,可是和咱們日常接觸的線程不一樣的是:不一樣 isolate 之間的內存不共享。

由於有了上述特性,咱們在查找對象的時候也要帶上 isolateId。經過 vm_service 的 getVM() api 能夠獲取到虛擬機對象數據,再經過 isolates 字段能夠獲取到當前虛擬機全部的 isolate。

那麼怎麼篩選出咱們想要的 isolate 呢?這裏簡單起見只篩選主 isolate,這部分的篩選能夠查看 dev_tools 的源碼: service_manager.dart#_initSelectedIsolate 函數。

ObjectId 的獲取

咱們要獲取的 objectId 就是 expando 在 vm_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,能夠用來執行某個常規函數(getter、setter、構造函數、私有函數屬於很是規函數),其中若是 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 字段(注意 _data 是 ObjRef 類型),用一樣的辦法把 _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 root 的引用鏈信息,是否是感受很簡單?不過光這樣可不行,由於它有如下幾個坑點:

Expando 持有問題

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

  • 由於該 api 返回的引用鏈只有一條,返回的引用鏈會通過 expando,致使沒法獲取真正的泄漏節點信息
  • 在 arm 設備上會出現 native crash,具體錯誤出如今 utf8 字符解碼上

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

id 過時問題

Instance 類型的 id 和 Class、Library、Isolate 這種 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 組件節點(例如:widget、element、state、renderObject)。

也就是說引用鏈通過了 flutter 的組件樹,玩過 flutter 的應該都知道 flutter 組件樹的層次是很是深的。既然引用鏈長的緣由是由於包含了組件樹,並且組件樹基本都是成塊出現的,那咱們只要把引用鏈中的節點根據類型來分類、聚合,就能夠大幅縮短泄漏路徑了。

分類

根據 flutter 的組件類型,將節點分爲如下幾種類型:

  • element:對應 Element 節點
  • widget:對應 Widget 節點
  • renderObject:對應 RenderObject節點
  • state:對應 State<T extends StatefulWdget> 節點
  • collection:對應集合類型節點,例如:List、Map、Set
  • other:對應其它節點

聚合

節點的分類作好了以後,就能夠把相同類型的節點聚合一下。這裏提下個人聚合方式

把 collection 類型的節點當作了鏈接節點,相鄰的相同節點合併到一個集合內,若是兩個相同類型的集合中間是經過 collection 節點相連的,就繼續把這兩個集合合併成一個集合,遞歸進行

經過 分類-聚合 的處理後,原先 300+ 的鏈路長度,能夠縮短爲 100+。

繼續排查 1.9.1 framework 的泄漏問題,路徑雖然縮短了,能夠找到問題大體出如今 FocusManager 節點上!可是具體問題仍是難以定位,主要有如下兩點:

  • 引用鏈節點缺乏代碼位置:由於 RetainingObject 數據中只有 parentField、parentIndex 和 parentKey 三個字段來表示當前對象引用下一個對象的信息,經過該信息找代碼位置效率低下
  • 沒法知道當前 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 版本以後修復了

修復完上述兩個泄漏以後,再次測試,Route 和 Widget 均可以回收了,至此 1.9.1 framework 排查完畢。


本文做者: 戚耿鑫

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

聯繫方式:qigengxin@kuaishou.com

相關文章
相關標籤/搜索