一、前言
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
, 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 Roots 的引用鏈信息,
是不是感覺很簡單?不過光這樣可不行,
因為它有以下幾個坑點:
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 的 widget tree,
熟悉 Flutter 的開發者應該都知道,
Flutter 的 widget tree 的層次是非常深的。
既然引用鏈長的原因是因為包含了 widget tree,
而且 widget tree 基本都是成塊出現的,
那我們只要把引用鏈中的節點根據類型來分類、聚合,
就可以大幅縮短泄漏路徑了。
分類
根據 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
「Flutter 中文社區教程」由社區的開發者投稿,內容同步發布到 flutter.cn 網站以及 「Flutter 社區」的各個社交平台。本項目內部測試中,籌備完成后會開放投稿。
在 flutter.cn 閱讀本文:https://flutter.cn/community/tutorials/memory-leak-monitoring-on-flutter