UME – 豐富的Flutter調(diào)試工具(flutter uri)
背景
目前西瓜視頻作者側(cè) Flutter 業(yè)務(wù)場(chǎng)景已經(jīng)覆蓋了 80% (包括視頻播放場(chǎng)景),用戶側(cè)核心場(chǎng)景包括我的 Tab 也已經(jīng)是 Flutter,在開發(fā)過程中,暴露了一些問題,debug 調(diào)試難、離開了 IDE 后猶如抓瞎、PM 設(shè)計(jì) QA 驗(yàn)收過程中拿不到有用的信息,在市面上找了一圈,也沒有類似 iOS Flex 這樣強(qiáng)大的調(diào)試工具,例如視圖大小、層級(jí)的展示,實(shí)例對(duì)象屬性的實(shí)時(shí)修改,網(wǎng)絡(luò)請(qǐng)求抓取,log 日志打印,文件查看等,因此西瓜視頻 Flutter 基礎(chǔ)團(tuán)隊(duì)決定開發(fā) UME 以解決上述問題。
介紹
UME (讀音:油米~) 是一個(gè) Flutter 調(diào)試工具包,內(nèi)部集成了豐富的調(diào)試小工具,設(shè)計(jì) UI、網(wǎng)絡(luò)、監(jiān)控、性能、logger 等,無論是研發(fā)、PM、還是 QA 均能使用。
目前已實(shí)現(xiàn)的功能
- 首頁功能支持拖拽排序展示當(dāng)前使用功能
- Widget 信息展示widget 名稱widget 大小widget 文件路徑widget 代碼所在行
- Widget 層級(jí)widget 構(gòu)建鏈支持 widget 搜索widget 信息展示renderObject 詳細(xì)信息展示
- 網(wǎng)絡(luò)調(diào)試支持所有基于 http 包的等網(wǎng)絡(luò)請(qǐng)求抓取數(shù)據(jù)支持結(jié)構(gòu)化展示,長(zhǎng)按可以復(fù)制到剪貼板收藏請(qǐng)求,單獨(dú)展示;清空非收藏列表請(qǐng)求過濾與搜索(支持部分匹配、正則匹配)請(qǐng)求導(dǎo)出 curl持久化與導(dǎo)出 HARmock 響應(yīng)內(nèi)容完整 har 文件映射修改單個(gè)字段結(jié)構(gòu)化信息長(zhǎng)按復(fù)制
- 內(nèi)存泄露支持自動(dòng)檢測(cè)由 route 打開的頁面Widget、State、Route 對(duì)象的內(nèi)存泄漏檢測(cè)
- 內(nèi)存查看Dart vm 信息展示當(dāng)前 Dart 內(nèi)存使用情況具體類信息所占用的空間跟數(shù)量類屬性和方法展示
- CPU 模塊CPU 詳細(xì)信息展示CPU 是使用率(iOS)App 內(nèi)存和磁盤使用情況
- 性能看板GPU 和 UI FPS 信息展示
- 顏色吸管顏色值獲取十六進(jìn)制
- 對(duì)齊標(biāo)尺widget 屏幕坐標(biāo)展示可自動(dòng)吸附最近 widget
- 二維碼二維碼生成
- Logger展示 debugPrint 函數(shù)輸出日志支持搜索
- Device Info手機(jī)硬件信息展示
- HTTP Server目前 8080 端口用于上傳文件,用戶可上傳 HAR 文件,用于 Mock 請(qǐng)求8888 端口作為數(shù)據(jù)端口,目前用途是校驗(yàn)服務(wù) host、接收用戶上傳的文件未來計(jì)劃開放更多數(shù)據(jù) api,允許通過該服務(wù)讀取 UME 中的數(shù)據(jù)
- Channel Monitorchannel 調(diào)用方法名稱請(qǐng)求參數(shù)返回起始時(shí)間返回結(jié)果支持搜索
接下來會(huì)詳細(xì)介紹一些核心功能的使用效果以及核心實(shí)現(xiàn)
模塊詳解
Widget 信息
可以查看當(dāng)前選中 widget 的大小、名稱,文件路徑以及代碼所在行數(shù),有了這工具,即使你不負(fù)責(zé)這個(gè)功能模塊的開發(fā),你也能迅速找到當(dāng)前代碼。
那如何能獲取到選中當(dāng)前 widget 的信息呢,大小通過RenderObject 就能拿到,那 widget 的代碼位置呢? 通過WidgetInspectorService 中的 getSelectedSummaryWidget 便可以獲取到一個(gè) json 字符串,我們來看下它的結(jié)構(gòu):
{ "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}
由于數(shù)據(jù)太多了,省略了一部分, 然后根據(jù)對(duì)應(yīng)的 key 即可找到需要的部分。
Widget 層級(jí)
可以查看當(dāng)前選中 widget 的樹層級(jí),以及它 renderObject 的詳細(xì) build 鏈。
這個(gè)獲取到選中 widget 的一個(gè) build 鏈還是比較簡(jiǎn)單的,通過 InspectorSelection 獲取到當(dāng)前 currentElement ,然后 使用 debugGetDiagnosticChain 方法就可以獲取到整個(gè) build 鏈了。
RenderObject 的信息也很好得到,通過currentElement 拿到 當(dāng)前的RenderObject,然后使用 toString方法就可以拿到了。
ShowCode
可以查看到當(dāng)前頁面的頁面代碼。
主要實(shí)現(xiàn)涉及到以下幾個(gè)關(guān)鍵點(diǎn):
- 獲取到當(dāng)前頁面 widget 所屬的文件名
- 根據(jù) dart 腳本的文件名來找到并讀取腳本
獲取文件名主要利用WidgetInspectorService實(shí)現(xiàn)。
而讀取腳本主要使用VMService實(shí)現(xiàn)。
獲取當(dāng)前頁面 widget 文件名
- 我們通過遍歷獲得當(dāng)前頁面的renderObject列表,按照大小篩選出我們想要的目標(biāo) widget。
- Widget 信息中講解到過,我們可以通過WidgetInspectorService 中getSelectedSummaryWidget 方法獲取到 json 字符串。
- 提取"creationLocation"的值即是當(dāng)前 widget 的在開發(fā)過程中的文件地址。
- 我們截取出來地址字符串的最后一部分就是當(dāng)前頁面代碼所在的文件名了。
找到并讀取腳本
- VMService中的getScripts方法可以獲取當(dāng)前線程下的所有庫文件的 ID 和文件名。
- 我們通過比對(duì)文件名可以獲得目標(biāo)庫文件 id。
- 通過VMService的getObject方法可以獲取到當(dāng)前 id 對(duì)應(yīng)的對(duì)象,我們傳入剛剛獲取的庫文件 id 即可獲得這個(gè)庫對(duì)象,讀取對(duì)象的source屬性,里面就是我們的源碼了。
內(nèi)存泄露
LeakDetector 用于檢測(cè) flutter 內(nèi)存泄漏,總體的實(shí)現(xiàn)思想和 Android 平臺(tái)的LeakCannary工具類似。利用Expando來弱引用持有待檢測(cè)對(duì)象,并且使用 VMService 拿到泄漏對(duì)象的引用鏈,最終將泄漏信息本地存儲(chǔ)并且展示出來。
Dart VM Service Dart 提供的一套 web 服務(wù),數(shù)據(jù)傳輸協(xié)議是 JSON-RPC 2.0。通過它提供的接口我們能獲取到 Dart 虛擬機(jī)內(nèi)部的一些重要信息。下面介紹下整個(gè)過程:
- 獲取 VMService 服務(wù)
- 獲取 ObservatoryUri通過Service.“getInfo“()獲取ServiceProtocolInfo,從中取出serverUri通過vm_service中的 util 工具方法convertToWebSocketUrl()將上面的 http 格式的 uri 格式轉(zhuǎn)為 ws://格式獲取 VmService 服務(wù)對(duì)象, vm_service_io文件中有個(gè)vmServiceConnectUri()方法,傳入一個(gè)observatoryUri就可以獲取一個(gè) VmService 對(duì)象
- 獲取 isolateId
- 通過 VmService 的 getVM 方法拿到 VM 對(duì)象,VM 對(duì)象中存儲(chǔ)著所有的 IsolateRef通過Service.getIsolateID(Isolate.current)拿到,只有 debug 下有效,release 下會(huì)返回 null
- 獲取 libraryId
- 通過第 2 步拿到 isolateId 之后,然后調(diào)用 VmService 的getIsolate拿到對(duì)應(yīng)的 Isolate 對(duì)象。
- 遍歷 Isolate 的 libraries 字段,這是一個(gè) LibraryRef 的 List,然后拿當(dāng)前 Library 的 uri 去 List 中匹配 LibraryRef 的 uri,就可以獲取 LibraryRef 的 id。
- 拿著 isolateId 和 LibraryRef 的 Id,調(diào)用 VmService 的 getObject 方法就可以獲取 Library,取其 id 字段就是我們要找的 libraryId(其實(shí) LibraryRef 的 id 應(yīng)該就是了,實(shí)際可以測(cè)試)。
- 獲取 objectId
由于getInstance(isolateId, classId, limit)方法存在性能和 limit 限制的問題,我們轉(zhuǎn)而利用invoke(isolateId, targetId, selector, argumentIds, disableBreakpoints)方法,借助 Library 頂層函數(shù)就可以獲取 libraryId 也就是 invoke 方法中的 targetId,最后我們只需要將目標(biāo)對(duì)象暫存一下再通過 invoke 方法取出來就可以拿到該對(duì)象的 InstanceRef 了,進(jìn)而拿到其 id 字段就是我們要找的 objectId 了。
- 泄漏判斷
- 通過 getObject(isolateId, objectId)方法拿到 Expando 的對(duì)象的 Obj 實(shí)例,它的真實(shí)類型其實(shí)是一個(gè) Instance。
- 遍歷 Instance 的 fields 字段找到_data(_data 的類型是 ObjRef,可以拿到它對(duì)應(yīng)的 Instance 實(shí)例)字段(怎么找_data?可以通過 BoundField 的 FieldRef 字段,然后匹配 FieldRef 的 name 為‘_data’),在expando_path.dart中我們可以看到 Expando 的具體實(shí)現(xiàn),_data 字段是一個(gè) List。
- 遍歷_data 字段,如果都為 null,表明我們觀察的 key 對(duì)象都釋放了;如果元素不為 null,則將該該元素轉(zhuǎn)為 Instance 對(duì)象(其實(shí)就是一個(gè) WeakProperty),取其 propertyKey 字段就是我們實(shí)際的沒被回收的對(duì)象了。
- 獲取引用路徑
- VmService 有一個(gè)getRetainingPath方法可以直接拿到一個(gè)對(duì)象的引用鏈,但是只會(huì)拿一條。
- 需要注意在前面使用 Expando 檢測(cè)完內(nèi)存泄漏之后,就釋放 Expando 對(duì)原始對(duì)象的引用。
- Instance 的 id 會(huì)過期,VmService 對(duì)它的緩存最大是 8192,所以不要保存 id 而要保存對(duì)象。
- 觸發(fā) GC
- VmService 有一個(gè)getAllocationProfile(isolateId, gc=true)方法,通過它來觸發(fā) dart vm 進(jìn)行 gc,這個(gè)也是 Dev Tools 工具上觸發(fā) gc 按鈕最終調(diào)用的方法。據(jù)測(cè)試觸發(fā)的都是 FULL GC。
- 觸發(fā)時(shí)機(jī)
- Route 檢測(cè)借助 framework 提供的NavigatorObserver機(jī)制,可以很輕松的監(jiān)聽到頁面的進(jìn)出棧,在 didPop、didRemove、didReplace 方法中觸發(fā)對(duì) route 的泄漏檢測(cè)。
- Widget/State 檢測(cè)一般的頁內(nèi) Widget/State 不檢測(cè),而只檢測(cè)真正頁面對(duì)應(yīng)的 Widget 和 State,framework 并沒有提供一個(gè)全局監(jiān)聽頁面銷毀的機(jī)制。這里我們借助hook_annotation(這個(gè)后面會(huì)解釋)來 hook 兩個(gè)點(diǎn):RouteRootState 的 initState 方法,記錄要檢測(cè)的頁面對(duì)象;State 的 dispose 方法,如果是我們已記錄的頁面,則觸發(fā)檢測(cè)流程。
內(nèi)存查看
Memory 可用于查看當(dāng)前 Dart VM 對(duì)象所占用情況。
需要拿到 vm 內(nèi)存的話就必須得依賴 Dart VM,上文說到,通過 vm_service 就可通過它提供的接口拿到。
通過 Future<MemoryUsage> getMemoryUsage 就能獲取到當(dāng)前 isolate 所占用的信息,來看下 MemoryUsage 的結(jié)構(gòu), 每個(gè)屬性都有詳細(xì)的解釋,這里就不再贅述了。
/// 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;
那如何獲取到每個(gè)類對(duì)象的內(nèi)存信息呢?
通過 getAllocationProfile 獲取分配對(duì)象的信息,通過members屬性來獲取到每個(gè) class 所占用的堆信息。
對(duì)齊標(biāo)尺
對(duì)齊標(biāo)尺用來測(cè)量當(dāng)前 widget 所在屏幕的一個(gè)坐標(biāo)位置,開啟吸附開關(guān)后可以自動(dòng)吸附最近 widget。
標(biāo)尺顯示當(dāng)前坐標(biāo)還是非常簡(jiǎn)單的,通過手勢(shì)移動(dòng)的坐標(biāo),來改變Positioned的位置即可,并通過屏幕的大小來計(jì)算出當(dāng)前的距離,下面會(huì)著重講一下自動(dòng)吸附的實(shí)現(xiàn)。
要吸附最近的 widget,就必須找到當(dāng)前位置的所在的 widget,然后并畫出當(dāng)前 widget 的一個(gè)大小范圍,最后設(shè)置標(biāo)尺的位置即可,那么如何找到當(dāng)前坐標(biāo)的 widget 呢?
通過 globalKey 我們可以獲取到當(dāng)前頁面的一個(gè)RenderObject,然后通過它的debugDescribeChildren 獲取到它的所有子節(jié)點(diǎn),然后通過describeApproximatePaintClip獲取到當(dāng)前對(duì)象坐標(biāo)系中的Rect,之后在根據(jù)一些坐標(biāo)轉(zhuǎn)換,判斷是不是在當(dāng)前坐標(biāo)范圍,最后根據(jù)RenderObject 的大小做一個(gè)排序,這樣我們就能知道最小的那個(gè)一定是當(dāng)前坐標(biāo)位置中最近的 widget 了,得到最近的 widget 之后,我們只需要將標(biāo)尺的中心位置設(shè)置成離 widget 最近的四個(gè)角即可。
顏色吸管
可以查看到當(dāng)前頁面任何像素的顏色,方便調(diào)試 UI。
這個(gè)功能首先分為兩步,1、背景放大 2、獲取當(dāng)前像素的顏色值
如何放大圖片
在 Flutter 中,要想給圖片加一些效果,我們可以用到 BackdropFilter, 其實(shí)就是加上一層濾鏡效果,發(fā)現(xiàn)參數(shù)其實(shí)并不多,通過 ImageFilter就能添加具體的濾鏡,想要做一個(gè)放大的效果,我們可以使用 ImageFilter.matrix ,它能夠放大背景圖片, filterQuality 參數(shù)可以用來設(shè)置放大效果的質(zhì)量,那如何放大對(duì)應(yīng)的位置以及放大的倍數(shù)呢?
通過Matrix4便可以設(shè)置,通過我們手勢(shì)移動(dòng)的位置,加上 scale 就能計(jì)算出它的矩陣參數(shù),并賦值給ImageFilter.matrix就能得到放大效果。
如何獲取圖片像素及顏色值
在 Flutter 中想要截圖的話就必須借助RepaintBoundary了,配合globalKey我們就能獲取當(dāng)屏幕的當(dāng)前截圖了。
RenderRepaintBoundary boundary = rootKey.currentContext.findRenderObject();Image image = await boundary.toImage();ByteData byteData = await image.toByteData(format: ui.ImageByteFormat.png);Uint8List pngBytes = byteData.buffer.asUint8List();snapshot = img.decodeImage(pngBytes);
獲取到截圖后,我們就需要通過移動(dòng)的位置來獲取到圖片的當(dāng)前像素值了,可以通過Image的 getPixelSafe 來獲取到 用 Uint32 編碼過的像素顏色值了(#AABBGGRR),最后我們只需要把abgr轉(zhuǎn)換成 argb 就好了。
int abgrToArgb(int argbColor) { int r = (argbColor >> 16) & 0xFF; int b = argbColor & 0xFF; return (argbColor & 0xFF00FF00) | (b << 16) | r;}
網(wǎng)絡(luò)調(diào)試
在調(diào)試 Flutter 網(wǎng)絡(luò)的時(shí)候,要 mock 數(shù)據(jù)或者查看請(qǐng)求非常麻煩,需要連代理,使用抓包工具才可以進(jìn)行這些操作,想要簡(jiǎn)單的在手機(jī)上就能完成這些操作,所以網(wǎng)絡(luò)調(diào)試模塊目前支持的功能:
- 支持所有網(wǎng)絡(luò)請(qǐng)求抓取
- 數(shù)據(jù)支持結(jié)構(gòu)化展示,長(zhǎng)按可以復(fù)制到剪貼板
- 收藏請(qǐng)求,單獨(dú)展示;清空非收藏列表
- 請(qǐng)求過濾與搜索(支持部分匹配、正則匹配)
- 請(qǐng)求導(dǎo)出 curl
- 持久化與導(dǎo)出 HAR
- mock 響應(yīng)內(nèi)容完整 har 文件映射修改單個(gè)字段
- 結(jié)構(gòu)化信息長(zhǎng)按復(fù)制
?
?
看到這,你可能會(huì)問這是怎么攔截到所有的網(wǎng)絡(luò)請(qǐng)求的呢?
這里通過 Dart 在編譯時(shí)的插樁從而達(dá)到對(duì)特定 API 的 hook 效果(其實(shí)就是替換掉某個(gè)方法的實(shí)現(xiàn)從而添加自己的實(shí)現(xiàn)),由于篇幅問題,這里暫時(shí)不展開講 Hook 的具體流程~ 之后也會(huì)有另外的文章來詳細(xì)說這個(gè)。
Flutter 中的所有網(wǎng)絡(luò)請(qǐng)求走的都是 package:http/src/base_client.dart 中 BaseClient 類中的_sendUnstreamed, 因此,我們只需要 hook _sendUnstreamed 方法便可以攔截到所有的網(wǎng)絡(luò)請(qǐng)求。
Logger
會(huì)展示使用 debugprint 函數(shù)打印的日志,特別是播放器的一些日志,在沒有 IDE 的情況下,查看日志還是很方便的。 ?
攔截 print 有兩種方式:
- Dart 中有一個(gè)runZoned方法,可以給執(zhí)行對(duì)象指定一個(gè) Zone,Zone 表示一個(gè)代碼執(zhí)行的環(huán)境范圍,Zone 類似一個(gè)代碼執(zhí)行沙箱,不同沙箱的之間是隔離的,沙箱可以捕獲、攔截或修改一些代碼行為,如 Zone 中可以捕獲日志輸出、Timer 創(chuàng)建、微任務(wù)調(diào)度的行為,同時(shí) Zone 也可以捕獲所有未處理的異常。runZoned(…)方法定義:
R runZoned<R>(R body(), { Map zoneValues, ZoneSpecification zoneSpecification, Function onError}) zoneValues: Zone 的私有數(shù)據(jù),可以通過實(shí)例zone[key]獲取
zoneSpecification:Zone 的一些配置,可以自定義一些代碼行為,比如攔截日志輸出行為等。
這樣所有調(diào)用 print 方法輸出日志的行為都會(huì)被攔截。
runZoned(() => runApp(MyApp()), zoneSpecification: new ZoneSpecification( print: (Zone self, ZoneDelegate parent, Zone zone, String line) { print(line);}));
- 通 hook 的方式
由于在 hook 的 print 方法里可能會(huì)調(diào)用 print 來打印日志造成死循環(huán),這里我們只 hook debugPrint 方法,對(duì) package:flutter/src/foundation/print.dart 中 debugPrintThrottled 進(jìn)行 hook 即可。
Channel Monitor
可以查看到所有的 channel 調(diào)用,包括方法名,時(shí)間,參數(shù),返回結(jié)果。 ?
?
hook package:flutter/src/services/platform_channel.dart 中 MethodChannel 類的invokeMethod方法即可。
目前存在的問題
目前只是完成了初步的版本,很多功能還需要繼續(xù)完善以及更多的新功能;接下來會(huì)從一些細(xì)節(jié)上繼續(xù)深入;現(xiàn)在網(wǎng)絡(luò)調(diào)試、channel 監(jiān)控、Logger 這些功能依賴于 Hook 方案,后續(xù) hook 方案也會(huì)考慮開源。
總結(jié)
以上介紹了一些 UME 的核心功能以及實(shí)現(xiàn),還有很多豐富的功能由于篇幅問題在這里就不繼續(xù)展開了,之后還會(huì)有更多有趣的東西出現(xiàn),未來會(huì)考慮開源一些核心功能。
加入我們
我們是負(fù)責(zé)西瓜視頻客戶端 Flutter 基礎(chǔ)技術(shù)研發(fā)團(tuán)隊(duì)。我們?cè)?Flutter 工程,研發(fā)工具等方向深耕,支撐業(yè)務(wù)快速迭代的同時(shí),提高 Flutter 開發(fā)調(diào)式打包效率。
如果你對(duì)技術(shù)充滿熱情,歡迎加入西瓜視頻 Flutter 基礎(chǔ)技術(shù)團(tuán)隊(duì)或者西瓜基礎(chǔ)業(yè)務(wù)團(tuán)隊(duì)。目前我們?cè)谏虾?、北京、杭州、均有招聘需求,?nèi)推可以聯(lián)系郵箱:tech@bytedance.com ;郵件標(biāo)題:姓名 – 工作年限 – 西瓜 – iOS/Android。
更多分享
一例 Go 編譯器代碼優(yōu)化 bug 定位和修復(fù)解析
字節(jié)跳動(dòng)破局聯(lián)邦學(xué)習(xí):開源Fedlearner框架,廣告投放增效209%
抖音品質(zhì)建設(shè) – iOS啟動(dòng)優(yōu)化《原理篇》
iOS性能優(yōu)化實(shí)踐:頭條抖音如何實(shí)現(xiàn)OOM崩潰率下降50%
歡迎關(guān)注「 字節(jié)跳動(dòng)技術(shù)團(tuán)隊(duì) 」
簡(jiǎn)歷投遞聯(lián)系郵箱「 tech@bytedance.com 」