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
節點中發現了問題:
洩漏路徑中有兩個 _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