目前,Flutter App(以下簡稱 App)的全量日志的模塊埋點功能采用業務層手動埋點的方式實現,這種方式不僅增加了研發成本,同時也限制了后續的擴展和維護。因此,可以基于 Dart AOP 實現 Flutter 全埋點功能來補齊全量日志。該方式不依賴于業務層,可以在端上自動采集并上報數據,并通過一定規則篩選出所需數據,用于分析和模擬用戶行為,幫助排查線上疑難問題。這種方法不僅能夠提高我們的效率,而且能夠加快問題的排查速度,從而提高 App 的穩定性。
隨著 App 的不斷迭代,項目復雜度也不斷提升。在該過程中,為了準確找出問題并排查,我們需要使用一些技術手段來輔助。在 Flutter 方面,Hook 能力是 App 缺少的基礎能力之一。因此,實現一套通用的 Dart AOP 基礎工具變得尤為重要。我們可以在關鍵的代碼調用點注入自定義邏輯,以實現數據收集、性能監控等功能,這種切面編程的技術被稱為 AOP(Aspect-Oriented Programming),它可以幫助我們更好地管理和組織代碼,提高代碼的可維護性和復用性。
要想實現 Flutter 側 Hook 能力,首先要簡單了解一下前端編譯。
圖片
CFE(Common Front-End):通用前端編譯器,當執行 Dart 代碼時,通過詞法分析(Scanner)和語法分析(parser)構建一顆 AST(Component)樹,再經過一系列的 Transformer 優化(TFA、Desugaring、Tree Shaking)后,將優化后的 AST 樹二進制寫入到 Dill 文件中;
TFA(Type Flow Analysis):全局類型流分析和相關轉換,比如簡化參數傳遞等;
Desugaring:語法脫糖,比如將 Async/Await 轉換成基于 Future 實現;
Tree Shaking:樹搖,從 Kernel 產物中摘除未使用的 Classes、Procedures、Fields等;
AST (Abstract Syntax Tree):抽象語法樹,是一種用于表示源代碼結構的樹形結構,每個節點代表一個語法單元,例如表達式、函數、變量等。它在編譯器和解釋器中扮演著非常重要的角色,是代碼優化、代碼轉換和運行的基礎。通過構建 AST,我們可以對代碼的結構和語義進行全面的分析和處理,同時也為開發人員提供了一種理解代碼表達方式和程序執行方式的框架,簡單看下 Component 結構。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.運行優化transformer:TFA、Desugaring、Tree Shaking result = await runGlobalTransformations(component);// 3. 序列化為二進制await writeDillFile(result);}
通過對前端編譯流程的簡單梳理,我們已經知道要想實現編譯期的 Dart 切面能力,需要在 Transfromer 優化之前注入 AOP 能力,因為 Transfromer 優化中會發生 Tree Shaking,如果在此之后才注入可能會因為沒有用到而被樹搖搖掉。設計流程如下:
圖片
注意:AOP 之前,B 方法調用 A 方法:B -> A。
圖片
支持的 Hook 方式有兩種:
圖片
閑魚有一套開源的面向 Dart 的 AOP 框架 AspectD,不直接使用它的原因如下:
方案描述可能比較抽象,可以參考以下 Demo 來加深理解。
分別使用 @Call 和 @Execute 注解對 hello() 方法執行切面操作:
圖片
打印日志信息:
圖片
圖片
偽代碼如下:
圖片
圖片
App 中,插件 A 和插件 B 里都有打印功能,但若只想對插件 B 的打印進行 hook,那就必須可精細化的控制 hook 范圍。根據上面的原理分析,@Execute 修改了原方法,插樁后只有一個變更點,保證了所有方法都能被 hook 到,所以無法支持調用方的作用域能力,無法精準控制 hook 范圍;而 @Call 不會修改原方法,只是替換了方法調用點,即將原方法調用替換為 hook 方法調用,所以插樁 N 次就會生成 N 個變更點。因此,在方法調用替換前首先判斷當前 class 的 uri,通過正則匹配定義的 scope,如果滿足,才可以進行插樁。
在經過 AOP 之后,B 方法調用 A 方法時會經過一層代理,也就是我們的 Hook 方法,然后才會調用到 A 方法,這個過程中就存在了對原方法參數的傳遞。
為了能夠把參數傳遞給原方法,在調用點進行替換時,會構造一個 PointCut 對象,將位置參數放入到 PointCut 對象的 List 屬性中,將命名參數放入到 PointCut 對象的 Map 屬性中,然后將 PointCut 對象作為參數傳遞給 Hook 方法。在替換方法調用時,還會為 PointCut 生成一個 Stub 樁方法,而這個 Stub 方法則是調用原來的 A 方法,即通過 A 方法參數列表定義,在 Stub 方法中分別取出 PointCut 對象的 List 屬性和 Map 屬性中存儲的實參,來拼接成 A 方法調用所需的 Arguments,然后在 Stub 方法中生成 A 方法調用的 Invocation。
所以,最終方法調用的實參都會存儲到 PointCut 對象的 List 屬性與 Map 屬性中,然后在 Stub 方法中取出并回調原方法。這種方式本身沒有問題,但是當參數是可選參數時就會出現問題。假如 A 方法中的參數 a 是可選參數,默認值是 "hello world",B 方法在調用 A 方法時并沒有為可選參數 a 傳值,理論上可選參數 a 的值是默認值 "hello world",但是 Stub 方法生成 Invocation 時,是通過 A 方法的參數列表定義去拼接參數的,這里會存在一定變數。
由于 B 方法沒有傳入可選參數 a,當 PointCut 對象構造時,Map 屬性中并沒有存入可選參數 a,所以,Stub 方法在拼接參數時,從 Map 屬性中獲取的可選參數 a 的值將是 null,這個 null 值是作為 Arguments 中的一員,這樣最終的 A 方法調用將會使用 null 值,而不是默認值 "hello world"。
為了解決這個問題,需要在 Stub 方法中生成 A 方法調用所需的 Arguments 時,對 PointCut 對象的 Map 屬性中的參數進行判斷。通過 A 方法參數列表定義從 Map 屬性中提取實參時,先判斷對應參數是否為可選參數,如果是可選參數,通過 Map 的 containsKey() 方法來判斷 Map 屬性中是否存在該可選參數。假如這個參數是可選參數,而且 Map 屬性中也不存在該參數,那么我們接下來該怎么辦呢?其實,我們在遍歷 A 方法的參數列表定義時,可以獲取到對應參數的變量聲明,通過這個變量聲明可以獲取到對應初始值的表達式。假如 Map 屬性中不包含對應的可選參數,我們可以使用對應可選參數的初始值表達式拼接到 Arguments 中,這樣就保證了 Arguments 是固定的,也保證了可選參數在沒有傳值的情況下依舊可以使用到默認值。
總結:判斷 Map 屬性中是否存在可選參數時,我們需要先構造出 Map 對象的 containsKey() 的 Invocation,然后再構建條件表達式(ConditionalExpression),將 containsKey() 的 Invocation 作為條件值,條件表達式兩個分支分別放入 Map 取值的表達式與可選參數初始值的表達式。
圖片
方法調用替換時,不同調用方執行同一個原方法的調用替換時,都會生成一個 Stub 方法,以便 pointCut.proceed() 能夠通過 Stub 方法來回調原方法。
假如,一個方法有 N 個調用點,那么我們就要為每個調用點都生成一個 Stub 方法,這顯然不合理,因為都是對同一個方法的調用,且方法調用所需的 Arguments 都是通過 PointCut 對象的 List 屬性與 Map 屬性中取出來拼接的,所以眾多的方法調用其實都可以復用一個 Stub 方法來完成原方法的回調。
圖片
當用戶觸發點擊事件時,我們可以通過命中點擊的最小 Widget 來回溯出該 Widget 在樹中的層次結構;通過獲取到的層次結構,我們可以去除中間無效和冗余的組件路徑,并按照一定的拼接規則來獲取用戶的操作路徑。簡言之,當用戶點擊某個 Widget 時,我們可以追蹤到它在 Widget 樹中的位置,并根據這個位置信息剔除無效和重復的組件路徑,從而得到有效的用戶操作路徑。這種操作路徑的獲取方法可以幫助我們了解用戶在 App 中的具體操作流程,從而更好地理解和分析用戶行為,更準確更及時的定位問題。
關鍵字段的拼接規則如下:
BuildContext 定義了一些如獲取 State、Widget、RenderObject、父子 Element 等重要的接口;Element 實現了 BuildContext 中的關鍵方法,比如實現了 visitAncestorElements (訪問祖先元素)方法等,且通過 Element.Widget 獲取與之對應的 Widget,根據此 Widget 可獲取到具體路徑;RenderObjectElement 繼承 Element,在 mount() 方法中初始化 _renderObject 對象;在 mount() 和 update() 方法中,通過斷言將當前 Element 傳入到 renderObject 的 debugCreator 屬性中保存。因此,可以通過 debugCreator 屬性獲取到對應的 Element,再通過 Element 獲取到對應的 Widget。由于 debugCreator 屬性賦值定義在斷言中,只在Debug 模式時能獲取到 Widget,因此需要分別 Hook mount() 和 update() 方法來支持 Release 和 Profile 模式時獲取對應 Widget 信息的能力。
圖片
關鍵實現
圖片
Widget_Inspctor 在 Debug 模式的編譯期間,通過一個特定的 Transform,讓最底層 Widget 實現了抽象類 xxHasCreationLocation,在 Widget 所有子類的構造方法中新增一個 xxLocation 類型的命名參數,同時會修改對應的構造方法調用點即傳入 xxLocation 對象,最終可通過 Widget 對象獲取到 Widget 構造時所在文件路徑和代碼行數?;诖?,可以在非 Debug 模式復用此邏輯(為了保留 Debug 模式時本身支持的 Dev-Tools 能力,Debug 模式不做修改)
修改源碼 track_widget_constructor_locations.dart
圖片
當前 Element 是否添加到 Path 中,用于去除中間無效冗余的組件路徑:
圖片
理解手勢
PointerEvent(指針事件)表示用戶交互的原始觸摸數據,例如 PointerDownEvent、PointerCancelEvent、PointerUpEvent 等;當手指觸摸屏幕的時候,發生觸摸事件,Flutter 會確定觸發的位置上有哪些組件,并將觸摸事件交給最內層的組件去響應,事件會從最內層的組件開始,沿著組件樹向根節點向上一級級冒泡分發。
處理 PointerEvent 是從 GestureBinding 的 handlePointerEvent() 方法開始:
圖片
圖片
圖片
dispatchEvent() 方法遍歷 _path 中的每個 HitTestEntry,取出其 target 進行事件分發,而 HitTestTarget 除了幾個Binding,其具體都是由 RenderObject 實現的,所以也就是對每個 RenderObject 節點進行事件分發,也就是我們說的“事件冒泡”,冒泡的第一個節點是最小 child 節點(最內部的組件),最后一個是 GestureBinding。
所以,handlePointerEvent() 方法主要就是不斷通過 hitTest() 方法計算出所需的 HitTestResult,然后再通過 dispatchEvent() 對事件進行分發。
通過分析手勢事件,選擇以下兩個切入點:
圖片
即使我們獲取了用戶的操作路徑信息,如果缺少關鍵業務代碼,也無法快速排查問題。因此,在全埋點中,我們需要上報與業務流程相關的日志。為了避免對業務層代碼的侵入,我們可以通過 Hook 來獲取業務內容,并將其上傳到全量日志。那么,如何獲取業務信息呢?
以下敘述均以新版 Bloc 為例。
在 App 中,存在多種設計模式。以新版 Bloc 為例,與業務相關的信息保存在一個 State 類中。我們可以通過獲取當前 State 對象中的所有信息來還原模擬用戶操作。然而,Flutter 缺少動態能力,無法通過反射機制動態獲取 State 對象的所有信息。因此,我們可以為每個 State 對象生成 toString() 方法,以獲取對象中的所有信息(方法返回的是 Map 對象轉成的字符串)。然而,手動編寫大量的 toString() 代碼不僅侵入了業務層代碼,而且效率極低。為了解決這些問題,我們可以嘗試在編譯期提前生成 State 對象的 toString() 方法,以更高效地獲取業務流程信息。當 Hook 方法被調用時,我們可以通過調用 toString() 方法獲取到 State 對象所有信息并上報。
如何判斷當前的類是否為需要的 State 類呢?
如何生成 toStringProcedure 呢?
注意:需要存在一個 toStringProcedure 模版,不會憑空創建。
圖片
圖片
圖片
最終效果
圖片
圖片
Dart AOP 用途有很多,也可以解決疑難 Crash。比如前段時間,有一個線上疑難 Crash 問題持續影響了多個版本。Bugly 出現堆棧信息為 Null check operator used on a null value 的異常問題,最終定位的原因是 3.3.10 SDK 源碼里,TextSelectionOverlay 類通過持有的 Context 對象尋找 RenderObject 時,返回了Nil 值,在對其進行強制解包時觸發了異常。因此,小組成員選擇 Hook 系統 SelectionOverlay._buildToolbar() 方法,在其內部判斷對應 Context 是否已經 unmount,如果是則直接返回一個 Container。這么修改上線后問題已解決。
雖然可以 Hook 系統方法來處理問題或配置自定義內容,但也需要選擇合理的合適的時機去觸發,不可以過度使用。
使用 Dart AOP 實現的 Flutter App 全埋點功能具有多重優勢。首先,它不依賴于業務層,可以在端上自動采集并上報數據,從而不會對業務代碼造成額外的負擔。其次,通過 AOP 的方式,我們可以在代碼中簡單地插入埋點邏輯,而不需要修改原有代碼,從而大大縮短了開發時間。此外,基于 AOP 的實現方式還能夠方便后期的維護工作,當需要新增或修改埋點邏輯時,只需修改 AOP 配置即可,而不需要對業務代碼進行大規模的修改。因此,基于 Dart AOP 實現的 Flutter App 全埋點功能不僅能夠提升開發效率,還能夠方便后期的維護工作,為項目的穩定性和可維護性提供了有力支持,希望以后可以通過 AOP 技術解決更多難題。
參考文獻:https://juejin.cn/post/6892371163859976199
本文鏈接:http://www.www897cc.com/showinfo-26-45507-0.htmlFlutter 全埋點的實現
聲明:本網頁內容旨在傳播知識,若有侵權等問題請及時與本網聯系,我們將在第一時間刪除處理。郵件:2376512515@qq.com
下一篇: 八個開發者不可不知的微服務設計模式