Flutter 上的記憶體洩漏監控

快手技術團隊發表於2020-06-16

1、前言

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

2、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 是不支援反射的(為了優化打包 size,關閉了反射),有沒有其它辦法來獲取到這種私有屬性呢?

答案肯定是 “有”,為了解決上述問題,我這邊介紹一個 dart 自帶的服務,Dart VM Service

3、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方法轉化下

4、洩漏檢測實現

有了 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 了,下面就開始獲取分析洩漏路徑。

5、獲取洩漏路徑

關於洩漏路徑的獲取,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。

6、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 節點中發現了問題:

Flutter 上的記憶體洩漏監控

洩漏路徑中有兩個 _InkResponseState 節點所屬的 route 資訊不同,表明這兩個節點在兩個不同的頁面中。頂部 _InkResponseState 的描述資訊顯示 lifecycle not mounted,說明元件已經銷燬了,但是還是被 FocusManager 引用著!問題出現在這,來看下這部分程式碼

Flutter 上的記憶體洩漏監控

程式碼中可以明顯的看到 addListener 時候對 StatefulWidget 的生命週期理解錯誤。didChangeDependencies 是會多次呼叫的,dispose 只會呼叫一次,所以這裡就會出現 listener 移除不乾淨的情況。

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

Flutter 上的記憶體洩漏監控

當開啟一個新頁面的時候,該頁面的 Route(也就是程式碼中的 nextRoute)會被前一個頁面的 animation 所持有,如果頁面跳轉都是 TransitionRoute,那麼所有的 Route 都會洩漏 !

好訊息是以上洩漏都在 1.12 版本之後修復了

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


本文作者: 戚耿鑫

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

聯絡方式:qigengxin@kuaishou.com

相關文章