Flutter 全埋點的實現(xiàn)
一、前言
目前,F(xiàn)lutter App(以下簡稱 App)的全量日志的模塊埋點功能采用業(yè)務層手動埋點的方式實現(xiàn),這種方式不僅增加了研發(fā)成本,同時也限制了后續(xù)的擴展和維護。因此,可以基于 Dart AOP 實現(xiàn) Flutter 全埋點功能來補齊全量日志。該方式不依賴于業(yè)務層,可以在端上自動采集并上報數(shù)據(jù),并通過一定規(guī)則篩選出所需數(shù)據(jù),用于分析和模擬用戶行為,幫助排查線上疑難問題。這種方法不僅能夠提高我們的效率,而且能夠加快問題的排查速度,從而提高 App 的穩(wěn)定性。
二、實現(xiàn)原理
隨著 App 的不斷迭代,項目復雜度也不斷提升。在該過程中,為了準確找出問題并排查,我們需要使用一些技術手段來輔助。在 Flutter 方面,Hook 能力是 App 缺少的基礎能力之一。因此,實現(xiàn)一套通用的 Dart AOP 基礎工具變得尤為重要。我們可以在關鍵的代碼調(diào)用點注入自定義邏輯,以實現(xiàn)數(shù)據(jù)收集、性能監(jiān)控等功能,這種切面編程的技術被稱為 AOP(Aspect-Oriented Programming),它可以幫助我們更好地管理和組織代碼,提高代碼的可維護性和復用性。
前端編譯
要想實現(xiàn) Flutter 側(cè) Hook 能力,首先要簡單了解一下前端編譯。
圖片
CFE(Common Front-End):通用前端編譯器,當執(zhí)行 Dart 代碼時,通過詞法分析(Scanner)和語法分析(parser)構建一顆 AST(Component)樹,再經(jīng)過一系列的 Transformer 優(yōu)化(TFA、Desugaring、Tree Shaking)后,將優(yōu)化后的 AST 樹二進制寫入到 Dill 文件中;
TFA(Type Flow Analysis):全局類型流分析和相關轉(zhuǎn)換,比如簡化參數(shù)傳遞等;
Desugaring:語法脫糖,比如將 Async/Await 轉(zhuǎn)換成基于 Future 實現(xiàn);
Tree Shaking:樹搖,從 Kernel 產(chǎn)物中摘除未使用的 Classes、Procedures、Fields等;
AST (Abstract Syntax Tree):抽象語法樹,是一種用于表示源代碼結(jié)構的樹形結(jié)構,每個節(jié)點代表一個語法單元,例如表達式、函數(shù)、變量等。它在編譯器和解釋器中扮演著非常重要的角色,是代碼優(yōu)化、代碼轉(zhuǎn)換和運行的基礎。通過構建 AST,我們可以對代碼的結(jié)構和語義進行全面的分析和處理,同時也為開發(fā)人員提供了一種理解代碼表達方式和程序執(zhí)行方式的框架,簡單看下 Component 結(jié)構。Dart 2.18.6 AST 源碼點這里。
圖片
frontend_server.dart 前端編譯關鍵偽代碼如下:
Future<bool> compile() {
// 1.kernelForProgram(source)源碼編譯為AST樹
// 詞法分析、語法分析、構建AST Outline
summaryComponent = await kernelTarget.buildOutlines(...);
// 構建完整AST樹
component = await kernelTarget.buildComponent(...);
// 2.運行優(yōu)化transformer:TFA、Desugaring、Tree Shaking
result = await runGlobalTransformations(component);
// 3. 序列化為二進制
await writeDillFile(result);
}
- 執(zhí)行 Dart 代碼時,先進行詞法分析和語法分析來構建 AST Outline,接著第二次會構建完整 AST;
- 運行語法糖脫糖、Tree-shaking 和 TFA 等來進行優(yōu)化;
- 將優(yōu)化后的 AST 二進制寫入 Dill 文件中。
Dart AOP
設計思路
通過對前端編譯流程的簡單梳理,我們已經(jīng)知道要想實現(xiàn)編譯期的 Dart 切面能力,需要在 Transfromer 優(yōu)化之前注入 AOP 能力,因為 Transfromer 優(yōu)化中會發(fā)生 Tree Shaking,如果在此之后才注入可能會因為沒有用到而被樹搖搖掉。設計流程如下:
圖片
- Dart 編譯成 Kernel 前注入自定義 AopTransformer,通過 AopTransformer 提取自定義注解信息,遍歷 AST 節(jié)點,對注解中聲明的節(jié)點進行修改;
- 編譯 host_release,生成新的 frontend_server.dart.snapshot 來替換 App 對應 SDK 的原前端編譯器快照;
- 針對原方法新建一個帶有切面注解信息的 Hook 方法,當程序執(zhí)行到原方法時,其實執(zhí)行的是對應的樁方法。
注意:AOP 之前,B 方法調(diào)用 A 方法:B -> A。
圖片
支持的 Hook 方式有兩種:
圖片
閑魚有一套開源的面向 Dart 的 AOP 框架 AspectD,不直接使用它的原因如下:
- AspectD 支持的 SDK 版本過低且對外不再維護,當 Flutter SDK 升級到 3.3.10 后,AST 中的部分 API 發(fā)生了較大變更,其中代碼生成相關邏輯需要進行較大的調(diào)整來適配新 API,無法直接使用;
- AspectD 沒有支持空安全(Null Safety)這個很重要的語法特性;
- 缺少調(diào)用方的作用域能力:實際開發(fā)中可能存在這樣一種場景,插件 A 和 插件 B 都有打印功能,只想 Hook 插件 B 的打印的話,目前缺少這個能力;
- 方法調(diào)用替換會生成重復的樁方法:不同的調(diào)用方執(zhí)行同一個原始方法的調(diào)用替換(Call)時,生成了多個重復的樁方法,應只保留一個樁方法即可;
- AspectD 使用 Flutter_tools 調(diào)用工具鏈較為繁瑣,可以直接編譯并替換前端編譯器快照,化繁為簡。
方案描述可能比較抽象,可以參考以下 Demo 來加深理解。
分別使用 @Call 和 @Execute 注解對 hello() 方法執(zhí)行切面操作:
圖片
打印日志信息:
圖片
圖片
偽代碼如下:
圖片
圖片
技術難點
調(diào)用方的作用域能
App 中,插件 A 和插件 B 里都有打印功能,但若只想對插件 B 的打印進行 hook,那就必須可精細化的控制 hook 范圍。根據(jù)上面的原理分析,@Execute 修改了原方法,插樁后只有一個變更點,保證了所有方法都能被 hook 到,所以無法支持調(diào)用方的作用域能力,無法精準控制 hook 范圍;而 @Call 不會修改原方法,只是替換了方法調(diào)用點,即將原方法調(diào)用替換為 hook 方法調(diào)用,所以插樁 N 次就會生成 N 個變更點。因此,在方法調(diào)用替換前首先判斷當前 class 的 uri,通過正則匹配定義的 scope,如果滿足,才可以進行插樁。
可選參數(shù)的默認值
在經(jīng)過 AOP 之后,B 方法調(diào)用 A 方法時會經(jīng)過一層代理,也就是我們的 Hook 方法,然后才會調(diào)用到 A 方法,這個過程中就存在了對原方法參數(shù)的傳遞。
為了能夠把參數(shù)傳遞給原方法,在調(diào)用點進行替換時,會構造一個 PointCut 對象,將位置參數(shù)放入到 PointCut 對象的 List 屬性中,將命名參數(shù)放入到 PointCut 對象的 Map 屬性中,然后將 PointCut 對象作為參數(shù)傳遞給 Hook 方法。在替換方法調(diào)用時,還會為 PointCut 生成一個 Stub 樁方法,而這個 Stub 方法則是調(diào)用原來的 A 方法,即通過 A 方法參數(shù)列表定義,在 Stub 方法中分別取出 PointCut 對象的 List 屬性和 Map 屬性中存儲的實參,來拼接成 A 方法調(diào)用所需的 Arguments,然后在 Stub 方法中生成 A 方法調(diào)用的 Invocation。
所以,最終方法調(diào)用的實參都會存儲到 PointCut 對象的 List 屬性與 Map 屬性中,然后在 Stub 方法中取出并回調(diào)原方法。這種方式本身沒有問題,但是當參數(shù)是可選參數(shù)時就會出現(xiàn)問題。假如 A 方法中的參數(shù) a 是可選參數(shù),默認值是 "hello world",B 方法在調(diào)用 A 方法時并沒有為可選參數(shù) a 傳值,理論上可選參數(shù) a 的值是默認值 "hello world",但是 Stub 方法生成 Invocation 時,是通過 A 方法的參數(shù)列表定義去拼接參數(shù)的,這里會存在一定變數(shù)。
由于 B 方法沒有傳入可選參數(shù) a,當 PointCut 對象構造時,Map 屬性中并沒有存入可選參數(shù) a,所以,Stub 方法在拼接參數(shù)時,從 Map 屬性中獲取的可選參數(shù) a 的值將是 null,這個 null 值是作為 Arguments 中的一員,這樣最終的 A 方法調(diào)用將會使用 null 值,而不是默認值 "hello world"。
為了解決這個問題,需要在 Stub 方法中生成 A 方法調(diào)用所需的 Arguments 時,對 PointCut 對象的 Map 屬性中的參數(shù)進行判斷。通過 A 方法參數(shù)列表定義從 Map 屬性中提取實參時,先判斷對應參數(shù)是否為可選參數(shù),如果是可選參數(shù),通過 Map 的 containsKey() 方法來判斷 Map 屬性中是否存在該可選參數(shù)。假如這個參數(shù)是可選參數(shù),而且 Map 屬性中也不存在該參數(shù),那么我們接下來該怎么辦呢?其實,我們在遍歷 A 方法的參數(shù)列表定義時,可以獲取到對應參數(shù)的變量聲明,通過這個變量聲明可以獲取到對應初始值的表達式。假如 Map 屬性中不包含對應的可選參數(shù),我們可以使用對應可選參數(shù)的初始值表達式拼接到 Arguments 中,這樣就保證了 Arguments 是固定的,也保證了可選參數(shù)在沒有傳值的情況下依舊可以使用到默認值。
總結(jié):判斷 Map 屬性中是否存在可選參數(shù)時,我們需要先構造出 Map 對象的 containsKey() 的 Invocation,然后再構建條件表達式(ConditionalExpression),將 containsKey() 的 Invocation 作為條件值,條件表達式兩個分支分別放入 Map 取值的表達式與可選參數(shù)初始值的表達式。
圖片
重復的樁方法
方法調(diào)用替換時,不同調(diào)用方執(zhí)行同一個原方法的調(diào)用替換時,都會生成一個 Stub 方法,以便 pointCut.proceed() 能夠通過 Stub 方法來回調(diào)原方法。
假如,一個方法有 N 個調(diào)用點,那么我們就要為每個調(diào)用點都生成一個 Stub 方法,這顯然不合理,因為都是對同一個方法的調(diào)用,且方法調(diào)用所需的 Arguments 都是通過 PointCut 對象的 List 屬性與 Map 屬性中取出來拼接的,所以眾多的方法調(diào)用其實都可以復用一個 Stub 方法來完成原方法的回調(diào)。
圖片
三、全埋點
用戶操作路徑
當用戶觸發(fā)點擊事件時,我們可以通過命中點擊的最小 Widget 來回溯出該 Widget 在樹中的層次結(jié)構;通過獲取到的層次結(jié)構,我們可以去除中間無效和冗余的組件路徑,并按照一定的拼接規(guī)則來獲取用戶的操作路徑。簡言之,當用戶點擊某個 Widget 時,我們可以追蹤到它在 Widget 樹中的位置,并根據(jù)這個位置信息剔除無效和重復的組件路徑,從而得到有效的用戶操作路徑。這種操作路徑的獲取方法可以幫助我們了解用戶在 App 中的具體操作流程,從而更好地理解和分析用戶行為,更準確更及時的定位問題。
路徑追蹤
關鍵字段的拼接規(guī)則如下:
- 用戶操作路徑:控件類:Dart文件名:行數(shù):列數(shù);
- 組件路徑 ID (從根節(jié)點到子節(jié)點):Widget 名字[位置]/ ... / Widget 名字[位置]。
源碼分析
BuildContext 定義了一些如獲取 State、Widget、RenderObject、父子 Element 等重要的接口;Element 實現(xiàn)了 BuildContext 中的關鍵方法,比如實現(xiàn)了 visitAncestorElements (訪問祖先元素)方法等,且通過 Element.Widget 獲取與之對應的 Widget,根據(jù)此 Widget 可獲取到具體路徑;RenderObjectElement 繼承 Element,在 mount() 方法中初始化 _renderObject 對象;在 mount() 和 update() 方法中,通過斷言將當前 Element 傳入到 renderObject 的 debugCreator 屬性中保存。因此,可以通過 debugCreator 屬性獲取到對應的 Element,再通過 Element 獲取到對應的 Widget。由于 debugCreator 屬性賦值定義在斷言中,只在Debug 模式時能獲取到 Widget,因此需要分別 Hook mount() 和 update() 方法來支持 Release 和 Profile 模式時獲取對應 Widget 信息的能力。
圖片
關鍵實現(xiàn)
- Release 和 Profile 模式創(chuàng)建 DebugCreator
圖片
- 組件路徑優(yōu)化
Widget_Inspctor 在 Debug 模式的編譯期間,通過一個特定的 Transform,讓最底層 Widget 實現(xiàn)了抽象類 xxHasCreationLocation,在 Widget 所有子類的構造方法中新增一個 xxLocation 類型的命名參數(shù),同時會修改對應的構造方法調(diào)用點即傳入 xxLocation 對象,最終可通過 Widget 對象獲取到 Widget 構造時所在文件路徑和代碼行數(shù)?;诖?,可以在非 Debug 模式復用此邏輯(為了保留 Debug 模式時本身支持的 Dev-Tools 能力,Debug 模式不做修改)
修改源碼 track_widget_constructor_locations.dart
圖片
當前 Element 是否添加到 Path 中,用于去除中間無效冗余的組件路徑:
圖片
事件與手勢
理解手勢
PointerEvent(指針事件)表示用戶交互的原始觸摸數(shù)據(jù),例如 PointerDownEvent、PointerCancelEvent、PointerUpEvent 等;當手指觸摸屏幕的時候,發(fā)生觸摸事件,F(xiàn)lutter 會確定觸發(fā)的位置上有哪些組件,并將觸摸事件交給最內(nèi)層的組件去響應,事件會從最內(nèi)層的組件開始,沿著組件樹向根節(jié)點向上一級級冒泡分發(fā)。
處理 PointerEvent 是從 GestureBinding 的 handlePointerEvent() 方法開始:
圖片
- 創(chuàng)建 HitTestResult 對象:PointerEvent 為 PointerDownEvent、PointerSignalEvent、PointerHoverEvent、PointerPanZoomStartEvent 時創(chuàng)建 HitTestResult 對象,該對象內(nèi)部有一個 _path 字段,表示 HitTestEntry 集合。
- 命中測試,調(diào)用 RendererBinding 的 hitTest() 方法:調(diào)用 hitTest() 方法進行命中測試,該方法將自身作為參數(shù)創(chuàng)建 HitTestEntry 對象,然后將 HitTestEntry 對象添加到 HitTestResult 的 _path 中,HitTestEntry 中只有 HitTestTarget 屬性字段。即創(chuàng)建的 HitTestEntry 添加到 HitTestResult 的 _path 中,被當做事件分發(fā)冒泡排序中的一個路徑節(jié)點。
圖片
- 調(diào)用 RenderView 的 hitTest() 方法(從根節(jié)點 RenderView 開始命中測試);
- 調(diào)用父類的 hitTest() 方法,即 GestureBinding 的 hitTest() 方法。
- 事件分發(fā):經(jīng)過一系列的 hitTest 后,調(diào)用到 GestureBinding 的 dispatchEvent() 方法。
圖片
dispatchEvent() 方法遍歷 _path 中的每個 HitTestEntry,取出其 target 進行事件分發(fā),而 HitTestTarget 除了幾個Binding,其具體都是由 RenderObject 實現(xiàn)的,所以也就是對每個 RenderObject 節(jié)點進行事件分發(fā),也就是我們說的“事件冒泡”,冒泡的第一個節(jié)點是最小 child 節(jié)點(最內(nèi)部的組件),最后一個是 GestureBinding。
所以,handlePointerEvent() 方法主要就是不斷通過 hitTest() 方法計算出所需的 HitTestResult,然后再通過 dispatchEvent() 對事件進行分發(fā)。
關鍵實現(xiàn)
通過分析手勢事件,選擇以下兩個切入點:
- 獲取到點擊的控件:通過攔截 GestureBinding 的 dispatchEvent() 方法,獲取到傳給該方法的 PointerEvent 和 HitTestResult 參數(shù);
- 攔截點擊事件:攔截 GestureRecognizer 中的 invokeCallback() 方法,可以通過傳遞的參數(shù),得到是不是點擊狀態(tài)(判斷 eventName == "onTap")。
圖片
業(yè)務信息
即使我們獲取了用戶的操作路徑信息,如果缺少關鍵業(yè)務代碼,也無法快速排查問題。因此,在全埋點中,我們需要上報與業(yè)務流程相關的日志。為了避免對業(yè)務層代碼的侵入,我們可以通過 Hook 來獲取業(yè)務內(nèi)容,并將其上傳到全量日志。那么,如何獲取業(yè)務信息呢?
設計思路
以下敘述均以新版 Bloc 為例。
在 App 中,存在多種設計模式。以新版 Bloc 為例,與業(yè)務相關的信息保存在一個 State 類中。我們可以通過獲取當前 State 對象中的所有信息來還原模擬用戶操作。然而,F(xiàn)lutter 缺少動態(tài)能力,無法通過反射機制動態(tài)獲取 State 對象的所有信息。因此,我們可以為每個 State 對象生成 toString() 方法,以獲取對象中的所有信息(方法返回的是 Map 對象轉(zhuǎn)成的字符串)。然而,手動編寫大量的 toString() 代碼不僅侵入了業(yè)務層代碼,而且效率極低。為了解決這些問題,我們可以嘗試在編譯期提前生成 State 對象的 toString() 方法,以更高效地獲取業(yè)務流程信息。當 Hook 方法被調(diào)用時,我們可以通過調(diào)用 toString() 方法獲取到 State 對象所有信息并上報。
如何判斷當前的類是否為需要的 State 類呢?
- 自定義 CreateToStringMethodVisitor 繼承 Transformer,重寫訪問實例調(diào)用(visitInstanceInvocation)方法;
- 遍歷 AST,獲取當前實例調(diào)用 methodInvocation 的接口目標引用(interfaceTargetReference)的節(jié)點 node;
- 判斷該節(jié)點如果為 Procedure,獲取到它的 Class 和 Library,從而獲得 importUri、clsName、methodName;
- 由于 State 沒有明顯的繼承關系,無法直接判斷出一個類是否為 State,所以從 Emit 方法調(diào)用點出發(fā),通過 Emit 方法調(diào)用點傳入的參數(shù)來獲取 State 對應的類,這么可分別對比 ImportUri、clsName、methodName 和新版 Bloc 的 Emit() 方法所在的類、Import 名字 和 Call() 方法所在的類、Import 名字,完全匹配則說明找到了 State 類的實力調(diào)用遍歷實例調(diào)用的位置參數(shù)列表中的表達式,根據(jù)表達式不同的類型獲取到對應的 state 的 Class;
- 遍歷 stateClass 的 Procedures,如果沒有 toStringProcedure,為當前 StateClass 生成 toStringProcedure 并插入到 Procedures 中。
如何生成 toStringProcedure 呢?
- 初始化一個空數(shù)組,里面存放的是映射文字條目(MapLiteralEntry)。
- 遍歷 StateClass 的 Fields,根據(jù)當前 Field 生成一個 Key 為 Field 名字,Value 為 Field 表達式的 MapLiteralEntry,添加到 MapLiteralEntry 數(shù)組中。
- 如果 stateClass 有父類,需要循環(huán)向上找到 Field 并生成對應的 MapLiteralEntry 添加到數(shù)組中。
- 數(shù)組 MapLiteralEntry 轉(zhuǎn)成 MapLiteral,創(chuàng)建 toStringMap實例調(diào)用 并包裝成帶有返回值的描述 Statement,通過這個描述 創(chuàng)建 FunctionNode,通過 FunctionNode 創(chuàng)建 toStringProcedure,添加到 StateClass 的 Procedures 中。
注意:需要存在一個 toStringProcedure 模版,不會憑空創(chuàng)建。
圖片
關鍵實現(xiàn)
- 通過對象和屬性定義獲取對象屬性,即 StateClass 屬性保存的 Field 對象。
- 如果當前 Field 對象是數(shù)組的話,打印出來的會是 Instance of xxxModel,我們需要獲取 xxxModel 內(nèi)部信息,所以需要對 xxxModel 進行 toJson()。
- 根據(jù)當前 Field 生成一個 Key 為 Field 名字,Value 為 Field 表達式的 MapLiteralEntry,添加到 MapLiteralEntry 數(shù)組中。
- 如果屬性定義對象為空,那么選擇以上生成的實例方法調(diào)用,否則使用 Field 對象即可。
圖片
圖片
最終效果
圖片
圖片
四、其他收益
Dart AOP 用途有很多,也可以解決疑難 Crash。比如前段時間,有一個線上疑難 Crash 問題持續(xù)影響了多個版本。Bugly 出現(xiàn)堆棧信息為 Null check operator used on a null value 的異常問題,最終定位的原因是 3.3.10 SDK 源碼里,TextSelectionOverlay 類通過持有的 Context 對象尋找 RenderObject 時,返回了Nil 值,在對其進行強制解包時觸發(fā)了異常。因此,小組成員選擇 Hook 系統(tǒng) SelectionOverlay._buildToolbar() 方法,在其內(nèi)部判斷對應 Context 是否已經(jīng) unmount,如果是則直接返回一個 Container。這么修改上線后問題已解決。
雖然可以 Hook 系統(tǒng)方法來處理問題或配置自定義內(nèi)容,但也需要選擇合理的合適的時機去觸發(fā),不可以過度使用。
五、總結(jié)
使用 Dart AOP 實現(xiàn)的 Flutter App 全埋點功能具有多重優(yōu)勢。首先,它不依賴于業(yè)務層,可以在端上自動采集并上報數(shù)據(jù),從而不會對業(yè)務代碼造成額外的負擔。其次,通過 AOP 的方式,我們可以在代碼中簡單地插入埋點邏輯,而不需要修改原有代碼,從而大大縮短了開發(fā)時間。此外,基于 AOP 的實現(xiàn)方式還能夠方便后期的維護工作,當需要新增或修改埋點邏輯時,只需修改 AOP 配置即可,而不需要對業(yè)務代碼進行大規(guī)模的修改。因此,基于 Dart AOP 實現(xiàn)的 Flutter App 全埋點功能不僅能夠提升開發(fā)效率,還能夠方便后期的維護工作,為項目的穩(wěn)定性和可維護性提供了有力支持,希望以后可以通過 AOP 技術解決更多難題。