背景
目前西瓜視頻作者側 Flutter 業務場景已經覆寫了 40多個頁面 (包括視頻播放場景),用戶側核心場景包括我的 Tab 也已經是 Flutter,在開發程序中,暴露了一些問題,debug 除錯難、離開了 IDE 后猶如抓瞎、PM 設計 QA 驗收程序中拿不到有用的資訊,在市面上找了一圈,也沒有類似 iOS Flex 這樣強大的除錯工具,例如視圖大小、層級的展示,實體物件屬性的實時修改,網路請求抓取,log 日志列印,檔案查看等,所以西瓜視頻 Flutter 基礎團隊決定開發 UME,
介紹
UME (讀音:油米~) 是一個 Flutter 除錯工具包,內部集成了豐富的除錯小工具,設計UI、網路、監控、性能、logger 等,無論是研發、PM、還是 QA 均能使用,
目前已實作的功能
接下來會詳細介紹一些核心功能的使用效果以及核心實作:
模塊詳解
Widget 資訊
可以查看當前選中 widget 的大小、名稱,檔案路徑以及代碼所在行數,有了這工具,即使你不負責這個功能模塊的開發,你也能迅速找到當前代碼,
那如何能獲取到選中當前 widget 的資訊呢,大小通過RenderObject 就能拿到,那 widget 的代碼位置呢?通過WidgetInspectorService 中的 getSelectedSummaryWidget 便可以獲取到一個json字串,我們來看下它的結構:
{
"description":"Text",
"type":"_ElementDiagnosticableTreeNode",
"style":"dense",
"hasChildren":true,
"allowWrap":false,
"locationId":0,
"creationLocation":{
"file":"file:///Users/.../example/lib/home/widgets/category_card.dart",
"line":69,
"column":15,
"parameterLocations":[
{
"file":null,
"line":70,
"column":24,
"name":"data"
},
...
]
},
"createdByLocalProject":true,
"children":[
{
"description":"RichText",
"type":"_ElementDiagnosticableTreeNode",
"style":"dense",
"allowWrap":false,
"locationId":1,
"creationLocation":{
"file":"file://../packages/flutter/lib/src/widgets/text.dart",
"line":425,
"column":21,
"parameterLocations":[
{
"file":null,
"line":426,
"column":7,
"name":"textAlign"
},
...
]
},
"children":[],
"widgetRuntimeType":"RichText",
"stateful":false
}
],
"widgetRuntimeType":"Text",
"stateful":false
}
由于資料太多了,省略了一部分, 然后根據對應的key即可找到需要的部分,
Widget層級
可以查看當前選中 widget 的樹層級,以及它 renderObject 的詳細 build 鏈,
這個獲取到選中 widget 的一個 build 鏈還是比較簡單的,通過 InspectorSelection 獲取到當前 currentElement ,然后 使用 debugGetDiagnosticChain 方法就可以獲取到整個build 鏈了,
RenderObject 的資訊也很好得到,通過currentElement 拿到 當前的RenderObject,然后使用 toString方法就可以拿到了,
ShowCode
可以查看到當前頁面的頁面代碼,
主要實作涉及到以下幾個關鍵點:
獲取到當前頁面 widget 所屬的檔案名,
根據 dart 腳本的檔案名來找到并讀取腳本,
獲取檔案名主要利用WidgetInspectorService實作,
而讀取腳本主要使用VMService實作,
獲取當前頁面widget檔案名
我們通過遍歷獲得當前頁面的
renderObject串列,按照大小篩選出我們想要的目標 widget,Widget 資訊中講解到過,我們可以通過
WidgetInspectorService中getSelectedSummaryWidget方法獲取到 json 字串,提取 "creationLocation" 的值即是當前 widget 的在開發程序中的檔案地址,
我們截取出來地址字串的最后一部分就是當前頁面代碼所在的檔案名了,
找到并讀取腳本
VMService中的getScripts方法可以獲取當前執行緒下的所有庫檔案的 ID和檔案名,我們通過比對檔案名可以獲得目標庫檔案 id,
通過
VMService的getObject方法可以獲取到當前id對應的物件,我們傳入剛付訓取的庫檔案id即可獲得這個庫物件,讀取物件的source屬性,里面就是我們的原始碼了,
記憶體泄露
LeakDetector 用于檢測 flutter 記憶體泄漏,總體的實作思想和 Android 平臺的LeakCannary工具類似,利用Expando來弱參考持有待檢測物件,并且使用 VMService 拿到泄漏物件的參考鏈,最終將泄漏資訊本地存盤并且展示出來,
Dart VM Service 是 Dart 提供的一套 web 服務,資料傳輸協議是 JSON-RPC 2.0,通過它提供的介面我們能獲取到 Dart 虛擬機內部的一些重要資訊,下面介紹下整個程序:
獲取 VMService 服務
獲取 ObservatoryUri
通過
Service.``getInfo``()獲取ServiceProtocolInfo,從中取出serverUri,通過
vm_service中的util工具方法convertToWebSocketUrl()將上面的http格式的uri格式轉為ws://格式,獲取VmService服務物件,
vm_service_io檔案中有個vmServiceConnectUri()方法,傳入一個observatoryUri就可以獲取一個VmService物件,
獲取 isolateId
通過 VmService 的 getVM 方法拿到 VM 物件,VM 物件中存盤著所有的IsolateRef,
通過
Service.getIsolateID(Isolate.current)拿到,只有 debug 下有效,release 下會回傳null,
獲取 libraryId
通過第2步拿到 isolateId 之后,然后呼叫 VmService 的
getIsolate拿到對應的 Isolate 物件,遍歷 Isolate 的 libraries 欄位,這是一個 LibraryRef 的 List,然后拿當前 Library 的 uri 去List中匹配LibraryRef的 uri ,就可以獲取 LibraryRef 的 id ,
拿著 isolateId 和 LibraryRef 的 id,呼叫 VmService 的 getObject 方法就可以獲取 Library,取其 id 欄位就是我們要找的libraryId(其實LibraryRef的 id 應該就是了,實際可以測驗),
獲取 objectId
由于getInstance(isolateId, classId, limit)方法存在性能和limit限制的問題,我們轉而利用invoke(isolateId, targetId, selector, argumentIds, disableBreakpoints)方法,借助 Library 頂層函式就可以獲取 libraryId 也就是 invoke 方法中的 targetId,最后我們只需要將目標物件暫存一下再通過 invoke 方法取出來就可以拿到該物件的 InstanceRef 了,進而拿到其 id 欄位就是我們要找的 objectId 了,
泄漏判斷
通過 getObject(isolateId, objectId) 方法拿到 Expando 的物件的 Obj 實體,它的真實型別其實是一個 Instance,
遍歷 Instance 的 fields 欄位找到 _data(_data的型別是 ObjRef,可以拿到它對應的 Instance 實體)欄位(怎么找_data?可以通過 BoundField 的 FieldRef 欄位,然后匹配 FieldRef 的 name 為 ‘_data’),在
expando_path.dart中我們可以看到 Expando 的具體實作,_data 欄位是一個 List,遍歷 _data 欄位,如果都為 null,表明我們觀察的 key 物件都釋放了;如果元素不為 null,則將該該元素轉為 Instance 物件(其實就是一個 WeakProperty),取其 propertyKey 欄位就是我們實際的沒被回收的物件了,
獲取參考路徑
VmService 有一個
getRetainingPath方法可以直接拿到一個物件的參考鏈,但是只會拿一條,需要注意在前面使用 Expando 檢測完記憶體泄漏之后,就釋放 Expando 對原始物件的參考,
Instance 的 id 會過期,VmService 對它的快取最大是8192,所以不要保存 id而要保存物件,
觸發GC
VmService 有一個
getAllocationProfile(isolateId, gc=true)方法,通過它來觸發 dart vm 進行 gc,這個也是 Dev Tools 工具上觸發 gc 按鈕最終呼叫的方法,據測驗觸發的都是 FULL GC,
觸發時機
Route 檢測
借助 framework 提供的
NavigatorObserver機制,可以很輕松的監聽到頁面的進出堆疊,在 didPop、didRemove、didReplace 方法中觸發對route的泄漏檢測,
Widget/State 檢測
一般的頁內 Widget/State 不檢測,而只檢測真正頁面對應的 Widget 和State,framework 并沒有提供一個全域監聽頁面銷毀的機制,這里我們借助
hook_annotation(這個后面會解釋)來hook兩個點:RouteRootState 的 initState 方法,記錄要檢測的頁面物件;State 的 dispose 方法,如果是我們已記錄的頁面,則觸發檢測流程,
記憶體查看
Memory 可用于查看當前Dart VM 物件所占用情況,
通過 Future<MemoryUsage> getMemoryUsage 就能獲取到當前 isolate 所占用的資訊,來看下 MemoryUsage 的結構, 每個屬性都有詳細的解釋,這里就不再贅述了,
/// The amount of non-Dart memory that is retained by Dart objects. For
/// example, memory associated with Dart objects through APIs such as
/// Dart_NewWeakPersistentHandle and Dart_NewExternalTypedData. This usage is
/// only as accurate as the values supplied to these APIs from the VM embedder
/// or native extensions. This external memory applies GC pressure, but is
/// separate from heapUsage and heapCapacity.
int externalUsage;
/// The total capacity of the heap in bytes. This is the amount of memory used
/// by the Dart heap from the perspective of the operating system.
int heapCapacity;
/// The current heap memory usage in bytes. Heap usage is always less than or
/// equal to the heap capacity.
int heapUsage;
那如何獲取到每個類物件的記憶體資訊呢?
通過 getAllocationProfile 獲取分配物件的資訊,通過members屬性來獲取到每個 class 所占用的堆資訊,
對齊標尺
對齊標尺用來測量當前 widget 所在螢屏的一個坐標位置,開啟吸附開關后可以自動吸附最近 widget,
標尺顯示當前坐標還是非常簡單的,通過手勢移動的坐標,來改變Positioned的位置即可,并通過螢屏的大小來計算出當前的距離,下面會著重講一下自動吸附的實作,
要吸附最近的 widget ,就必須找到當前位置的所在的 widget ,然后并畫出當前 widget 的一個大小范圍,最后設定標尺的位置即可,那么如何找到當前坐標的 widget 呢?
通過globalKey我們可以獲取到當前頁面的一個RenderObject,然后通過它的debugDescribeChildren 獲取到它的所有子節點,然后通過describeApproximatePaintClip獲取到當前物件坐標系中的Rect,之后在根據一些坐標轉換,判斷是不是在當前坐標范圍,最后根據RenderObject 的大小做一個排序,這樣我們就能知道最小的那個一定是當前坐標位置中最近的 widget 了,得到最近的 widget 之后,我們只需要將標尺的中心位置設定成離 widget 最近的四個角即可,
顏色吸管
可以查看到當前頁面任何像素的顏色,方便除錯 UI,
這個功能首先分為兩步,1、背景放大 2、獲取當前像素的顏色值,
如何放大圖片
在Flutter中,要想給圖片加一些效果,我們可以用到 BackdropFilter, 其實就是加上一層濾鏡效果,發現引數其實并不多,通過 ImageFilter就能添加具體的濾鏡,想要做一個放大的效果,我們可以使用 ImageFilter.matrix ,它能夠放大背景圖片, filterQuality 引數可以用來設定放大效果的質量,那如何放大對應的位置以及放大的倍數呢?
通過Matrix4便可以設定,通過我們手勢移動的位置,加上 scale 就能計算出它的矩陣引數,并賦值給ImageFilter.matrix就能得到放大效果,
如何獲取圖片像素及顏色值
在Flutter中想要截圖的話就必須借助RepaintBoundary了,配合globalKey我們就能獲取當螢屏的當前截圖了,
RenderRepaintBoundary boundary = rootKey.currentContext.findRenderObject();
Image image = await boundary.toImage();
ByteData byteData = await image.toByteData(format: ui.ImageByteFormat.png"/>獲取到截圖后,我們就需要通過移動的位置來獲取到圖片的當前像素值了,可以通過Image的 getPixelSafe 來獲取到 用 Uint32 編碼過的像素顏色值了(#AABBGGRR),最后我們只需要把abgr轉換成 argb 就好了,
int abgrToArgb(int argbColor) {
int r = (argbColor >> 16) & 0xFF;
int b = argbColor & 0xFF;
return (argbColor & 0xFF00FF00) | (b << 16) | r;
}
網路除錯
在除錯 Flutter 網路的時候,要 mock 資料或者查看請求非常麻煩,需要連代理,使用抓包工具才可以進行這些操作,想要簡單的在手機上就能完成這些操作,所以網路除錯模塊目前支持的功能:
支持所有網路請求抓取
資料支持結構化展示,長按可以復制到剪貼板
收藏請求,單獨展示;清空非收藏串列
請求過濾與搜索(支持部分匹配、正則匹配)
請求匯出 curl
持久化與匯出 HAR
mock 回應內容
完整har檔案映射
修改單個欄位
結構化資訊長按復制



看到這,你可能會問這是怎么攔截到所有的網路請求的呢?
這里通過 Dart 在編譯時的插樁從而達到對特定 API 的 Hook 效果(其實就是替換掉某個方法的實作從而添加自己的實作),由于篇幅問題,這里暫時不展開講 Hook 的具體流程,之后也會有另外的文章來詳細說這個,
Flutter 中的所有網路請求走的都是 package:http/src/base_client.dart 中 BaseClient 類中的_sendUnstreamed, 因此,我們只需要 hook _sendUnstreamed 方法便可以攔截到所有的網路請求,
Logger
會展示使用 debugprint 函式列印的日志,特別是播放器的一些日志,在沒有 IDE 的情況下,查看日志還是很方便的,

攔截 print 有兩種方式:
Dart 中有一個runZoned方法,可以給執行物件指定一個 Zone,Zone 表示一個代碼執行的環境范圍,Zone 類似一個代碼執行沙箱,不同沙箱的之間是隔離的,沙箱可以捕獲、攔截或修改一些代碼行為,如 Zone 中可以捕獲日志輸出、Timer 創建、微任務調度的行為,同時 Zone 也可以捕獲所有未處理的例外,runZoned(...)方法定義:
R runZoned<R>(R body(), {
Map zoneValues,
ZoneSpecification zoneSpecification,
Function one rror
})
zoneValues: Zone 的私有資料,可以通過實體zone[key]獲取
zoneSpecification:Zone 的一些配置,可以自定義一些代碼行為,比如攔截日志輸出行為等,
這樣所有呼叫 print 方法輸出日志的行為都會被攔截,
runZoned(() => runApp(MyApp()), zoneSpecification: new ZoneSpecification(
print: (Zone self, ZoneDelegate parent, Zone zone, String line) {
print(line);
}));
通 hook 的方式
由于在 hook 的 print 方法里可能會呼叫 print 來列印日志造成死回圈,這里我們只 hook debugPrint 方法,對 package:flutter/src/foundation/print.dart 中 debugPrintThrottled 進行 hook 即可,
Channel Monitor
可以查看到所有的 channel 呼叫,包括方法名,時間,引數,回傳結果,


hook package:flutter/src/services/platform_channel.dart 中 MethodChannel 類的invokeMethod方法即可,
目前存在的問題
目前只是完成了初步的版本,很多功能還需要繼續完善以及更多的新功能;接下來會從一些細節上繼續深入;現在網路除錯、channel 監控、Logger這些功能依賴于Hook方案,后續 Hook方案也會考慮開源,
總結
以上介紹了一些 UME 的核心功能以及實作,還有很多豐富的功能由于篇幅問題在這里就不繼續展開了,之后還會有更多有趣的東西出現,未來會考慮開源一些核心功能,
加入我們
我們是負責西瓜視頻客戶端 Flutter 基礎技術研發團隊,我們在 Flutter 工程,研發工具等方向深耕,支撐業務快速迭代的同時,提高 Flutter 開發調式打包效率,
如果你對技術充滿熱情,歡迎加入西瓜視頻 Flutter 基礎技術團隊或者西瓜基礎業務團隊,目前我們在上海、北京、杭州、均有招聘需求,內推可以聯系郵箱: tech@bytedance.com ;郵件標題: 姓名 - 作業年限 - 西瓜 - iOS/Android ,
更多分享
一例 Go 編譯器代碼優化 bug 定位和修復決議
位元組跳動破局聯邦學習:開源Fedlearner框架,廣告投放增效209%
抖音品質建設 - iOS啟動優化《原理篇》
iOS 性能優化實踐:頭條抖音如何實作 OOM 崩潰率下降50%+

歡迎關注「 位元組跳動技術團隊 」
簡歷投遞聯系郵箱「 tech@bytedance.com 」
點擊閱讀原文,快來加入我們吧!
轉載請註明出處,本文鏈接:https://www.uj5u.com/qita/232621.html
標籤:其他
