Flutter 所使用的 dart 語言具備垃圾回收機制,有垃圾回收就避免不了會內存泄漏。在 Android 平臺上有個內存泄漏檢測工具 LeakCanary ,它能夠方便的在 debug 環境下檢測當前頁面是否泄漏。本文將會帶你實現一個 flutter 可用的 LeakCanary,並講述我是怎麼用該工具檢測出了 1.9.1 framework 上的兩個泄漏。git
在具備垃圾回收的語言中,弱引用是檢測對象是否泄漏的一個好方式。咱們只需弱引用觀測對象,等待下次 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.dart:markdown
@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 (後面簡稱 vm_service)是 dart 虛擬機內部提供的一套 web 服務,數據傳輸協議是 JSON-RPC 2.0。不過咱們並不須要要本身去實現數據請求解析,官方已經寫好了一個可用的 dart sdk 給咱們用 vm_service。
先介紹 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 在啓動的時候會在本地開啓一個 websocket 服務,服務 uri 能夠在對應的平臺中得到:
FlutterJNI.getObservatoryUri()
中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 才能夠獲取到這兩個參數。
Isolate(隔離區)是 dart 裏面的一個很是重要的概念,基本上一個 isolate 至關於一個線程,可是和咱們日常接觸的線程不一樣的是:不一樣 isolate 之間的內存不共享。
由於有了上述特性,咱們在查找對象的時候也要帶上 isolateId。經過 vm_service 的 getVM()
api 能夠獲取到虛擬機對象數據,再經過 isolates
字段能夠獲取到當前虛擬機全部的 isolate。
那麼怎麼篩選出咱們想要的 isolate 呢?這裏簡單起見只篩選主 isolate,這部分的篩選能夠查看 dev_tools 的源碼: service_manager.dart#_initSelectedIsolate 函數。
咱們要獲取的 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 開了這個字段)。
文章開頭說到,若是要判斷對象是否泄漏,須要在 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 的引用鏈信息,是否是感受很簡單?不過光這樣可不行,由於它有如下幾個坑點:
若是在執行 getRetainingPath
的時候,泄漏對象被 expando 持有的話會產生如下兩個問題
此問題很好解決,注意下在前面泄漏檢測完以後,釋放掉 expando 就行。
Instance 類型的 id 和 Class、Library、Isolate 這種 id 不同,是會過時的。vm_service 中對於此類臨時 id 的緩存容量默認大小是 8192,是一個循環隊列。
由於此問題的存在,咱們在檢測到泄漏的時候,不能只保存泄漏對象的 id,須要保存原對象,並且不能強引用持有對象。因此這裏咱們仍是須要使用 expando 來保存咱們檢測到的泄漏對象,等到須要分析泄漏路徑的時候,再把對象專爲 id。
完成了泄漏檢測和路徑獲取以後,獲得了一個簡陋的 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
節點Widget
節點RenderObject
節點State<T extends StatefulWdget>
節點節點的分類作好了以後,就能夠把相同類型的節點聚合一下。這裏提下個人聚合方式
把 collection 類型的節點當作了鏈接節點,相鄰的相同節點合併到一個集合內,若是兩個相同類型的集合中間是經過 collection 節點相連的,就繼續把這兩個集合合併成一個集合,遞歸進行
經過 分類-聚合 的處理後,原先 300+ 的鏈路長度,能夠縮短爲 100+。
繼續排查 1.9.1 framework 的泄漏問題,路徑雖然縮短了,能夠找到問題大體出如今 FocusManager
節點上!可是具體問題仍是難以定位,主要有如下兩點:
RetainingObject
數據中只有 parentField、parentIndex 和 parentKey 三個字段來表示當前對象引用下一個對象的信息,經過該信息找代碼位置效率低下介於上述兩個痛點,還須要對泄漏節點的信息作擴展處理:
Diagnosticable
,也就是隻要是 Diagnosticable
類型的節點均可獲取到很是詳細的信息(dev_tools 調試時候,組件樹信息就是經過 Diagnosticable.debugFillProperties
方法獲取的)。除了這個還須要擴展當前組件所在 route 的信息,這個很重要,判斷組件所在頁面用經過上述的種種優化後,我獲得了下面這個工具,在兩個 _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