Flutter 所使用的 Dart 語言具備垃圾回收機制,有垃圾回收就避免不了會內存泄漏。
在 Android 平臺上有個內存泄漏檢測工具 LeakCanary,
它能夠方便地在 debug 環境下檢測當前頁面是否泄漏。
本文將會帶你實現一個 Flutter 可用的 LeakCanary,
並講述我是怎麼用該工具檢測出了 1.9.1 Framework 上的兩個泄漏。git
在具備垃圾回收的語言中,弱引用是檢測對象是否泄漏的一個好方式。
咱們只需弱引用觀測對象,等待下次 Full GC,
若是 GC 以後對象爲 null
,說明被回收了,
若是不爲 null
就多是泄漏了。github
Dart 語言中也有着弱引用,它叫 Expando<T>
,
看下它的 API:api
<!-- skip -->數組
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
這個對象是否被回收了。app
爲了解決這個問題,咱們來看下 Expando
的具體實現,
具體的代碼在 expando_path.dart:async
<!-- skip -->ide
@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:函數
<!-- skip -->工具
@pragma("vm:entry-point") class _WeakProperty { get key => _getKey(); // ... 省略部分代碼 _getKey() native "WeakProperty_getKey"; // ... 省略部分代碼 }
這個類有咱們想要的 key
,能夠用於判斷對象是否還在。
怎麼獲取這種私有屬性和變量呢?
Flutter 中的 Dart 是不支持反射的(爲了優化打包體積,關閉了反射),
有沒有其餘辦法來獲取到這種私有屬性呢?
答案確定是 「有」,爲了解決上述問題,
我來向你們介紹一個 Dart 自帶的服務——
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 能夠在對應的平臺中得到:
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
才能夠獲取到這兩個參數。
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
了,代碼以下:
<!-- skip -->
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 Roots 的引用鏈信息,
是否是感受很簡單?不過光這樣可不行,
由於它有如下幾個坑點:
若是在執行 getRetainingPath
的時候,
泄漏對象被 expando
持有的話會產生如下兩個問題
expando
,致使沒法獲取真正的泄漏節點信息;此問題很好解決,注意下在前面泄漏檢測完以後,
釋放掉 expando
就行。
id
過時問題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 的 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
;節點的分類作好了以後,就能夠把相同類型的節點聚合一下。
這裏提下個人聚合方式:
把 collection
類型的節點當作了鏈接節點,
相鄰的相同節點合併到一個集合內,
若是兩個相同類型的集合中間是經過 collection
節點相連的,
就繼續把這兩個集合合併成一個集合,遞歸進行。
經過 分類-聚合 的處理後,原先 300+ 的鏈路長度,能夠縮短爲 100+。
繼續排查 1.9.1 Framework 的泄漏問題,
路徑雖然縮短了,能夠找到問題大體出如今 FocusManager
節點上!
可是具體問題仍是難以定位,主要有如下兩點:
RetainingObject
數據中只有 parentField
、parentIndex
和 parentKey
三個字段來表示當前對象引用下一個對象的信息,經過該信息找代碼位置效率低下;Text
的文本信息,element
所在的 widget 是啥,state 的生命週期狀態,當前組件屬於哪一個頁面,等等。介於上述兩個痛點,還須要對泄漏節點的信息作擴展處理:
parentField
就行,經過 vm_serive
解析 class
,取內部的 field
,找到對應的 script
等信息。此方法能夠獲取到源碼;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
「Flutter 中文社區教程」由社區的開發者投稿,內容同步發佈到 flutter.cn 網站以及 「Flutter 社區」的各個社交平臺。本項目內部測試中,籌備完成後會開放投稿。
在 flutter.cn 閱讀本文:https://flutter.cn/community/...