成人免费xxxxx在线视频软件_久久精品久久久_亚洲国产精品久久久_天天色天天色_亚洲人成一区_欧美一级欧美三级在线观看

Flutter 全埋點的實現

開發 前端
使用 Dart AOP 實現的 Flutter App 全埋點功能具有多重優勢。首先,它不依賴于業務層,可以在端上自動采集并上報數據,從而不會對業務代碼造成額外的負擔。

一、前言

目前,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);
}
  1. 執行 Dart 代碼時,先進行詞法分析和語法分析來構建 AST Outline,接著第二次會構建完整 AST;
  2. 運行語法糖脫糖、Tree-shaking 和 TFA 等來進行優化;
  3. 將優化后的 AST 二進制寫入 Dill 文件中。

Dart AOP

設計思路

通過對前端編譯流程的簡單梳理,我們已經知道要想實現編譯期的 Dart 切面能力,需要在 Transfromer 優化之前注入 AOP 能力,因為 Transfromer 優化中會發生 Tree Shaking,如果在此之后才注入可能會因為沒有用到而被樹搖搖掉。設計流程如下:

圖片圖片

  1. Dart 編譯成 Kernel 前注入自定義 AopTransformer,通過 AopTransformer 提取自定義注解信息,遍歷 AST 節點,對注解中聲明的節點進行修改;
  2. 編譯 host_release,生成新的 frontend_server.dart.snapshot 來替換 App 對應 SDK 的原前端編譯器快照;
  3. 針對原方法新建一個帶有切面注解信息的 Hook 方法,當程序執行到原方法時,其實執行的是對應的樁方法。

注意:AOP 之前,B 方法調用 A 方法:B -> A。

圖片圖片

支持的 Hook 方式有兩種:

圖片圖片

閑魚有一套開源的面向 Dart 的 AOP 框架 AspectD,不直接使用它的原因如下:

  • AspectD 支持的 SDK 版本過低且對外不再維護,當 Flutter SDK 升級到 3.3.10 后,AST 中的部分 API 發生了較大變更,其中代碼生成相關邏輯需要進行較大的調整來適配新 API,無法直接使用;
  • AspectD 沒有支持空安全(Null Safety)這個很重要的語法特性;
  • 缺少調用方的作用域能力:實際開發中可能存在這樣一種場景,插件 A 和 插件 B 都有打印功能,只想 Hook 插件 B 的打印的話,目前缺少這個能力;
  • 方法調用替換會生成重復的樁方法:不同的調用方執行同一個原始方法的調用替換(Call)時,生成了多個重復的樁方法,應只保留一個樁方法即可;
  • AspectD 使用 Flutter_tools 調用工具鏈較為繁瑣,可以直接編譯并替換前端編譯器快照,化繁為簡。

方案描述可能比較抽象,可以參考以下 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 中的具體操作流程,從而更好地理解和分析用戶行為,更準確更及時的定位問題。

路徑追蹤

關鍵字段的拼接規則如下:

  • 用戶操作路徑:控件類:Dart文件名:行數:列數;
  • 組件路徑 ID (從根節點到子節點):Widget 名字[位置]/ ... / Widget 名字[位置]。

源碼分析

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 信息的能力。

圖片圖片

關鍵實現

  • Release 和 Profile 模式創建 DebugCreator

圖片圖片

  • 組件路徑優化

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() 方法開始:

圖片圖片

  1. 創建 HitTestResult 對象:PointerEvent 為 PointerDownEvent、PointerSignalEvent、PointerHoverEvent、PointerPanZoomStartEvent 時創建 HitTestResult 對象,該對象內部有一個 _path 字段,表示 HitTestEntry 集合。
  2. 命中測試,調用 RendererBinding 的 hitTest() 方法:調用 hitTest() 方法進行命中測試,該方法將自身作為參數創建 HitTestEntry 對象,然后將 HitTestEntry 對象添加到 HitTestResult 的 _path 中,HitTestEntry 中只有 HitTestTarget 屬性字段。即創建的 HitTestEntry 添加到 HitTestResult 的 _path 中,被當做事件分發冒泡排序中的一個路徑節點。

圖片圖片

  1. 調用 RenderView 的 hitTest() 方法(從根節點 RenderView 開始命中測試);
  2. 調用父類的 hitTest() 方法,即 GestureBinding 的 hitTest() 方法。
  1. 事件分發:經過一系列的 hitTest 后,調用到 GestureBinding 的 dispatchEvent() 方法。

圖片圖片

dispatchEvent() 方法遍歷 _path 中的每個 HitTestEntry,取出其 target 進行事件分發,而 HitTestTarget 除了幾個Binding,其具體都是由 RenderObject 實現的,所以也就是對每個 RenderObject 節點進行事件分發,也就是我們說的“事件冒泡”,冒泡的第一個節點是最小 child 節點(最內部的組件),最后一個是 GestureBinding。

所以,handlePointerEvent() 方法主要就是不斷通過 hitTest() 方法計算出所需的 HitTestResult,然后再通過 dispatchEvent() 對事件進行分發。

關鍵實現

通過分析手勢事件,選擇以下兩個切入點:

  • 獲取到點擊的控件:通過攔截 GestureBinding 的 dispatchEvent() 方法,獲取到傳給該方法的 PointerEvent 和 HitTestResult 參數;
  • 攔截點擊事件:攔截 GestureRecognizer 中的 invokeCallback() 方法,可以通過傳遞的參數,得到是不是點擊狀態(判斷 eventName == "onTap")。

圖片圖片

業務信息

即使我們獲取了用戶的操作路徑信息,如果缺少關鍵業務代碼,也無法快速排查問題。因此,在全埋點中,我們需要上報與業務流程相關的日志。為了避免對業務層代碼的侵入,我們可以通過 Hook 來獲取業務內容,并將其上傳到全量日志。那么,如何獲取業務信息呢?

設計思路

以下敘述均以新版 Bloc 為例。

在 App 中,存在多種設計模式。以新版 Bloc 為例,與業務相關的信息保存在一個 State 類中。我們可以通過獲取當前 State 對象中的所有信息來還原模擬用戶操作。然而,Flutter 缺少動態能力,無法通過反射機制動態獲取 State 對象的所有信息。因此,我們可以為每個 State 對象生成 toString() 方法,以獲取對象中的所有信息(方法返回的是 Map 對象轉成的字符串)。然而,手動編寫大量的 toString() 代碼不僅侵入了業務層代碼,而且效率極低。為了解決這些問題,我們可以嘗試在編譯期提前生成 State 對象的 toString() 方法,以更高效地獲取業務流程信息。當 Hook 方法被調用時,我們可以通過調用 toString() 方法獲取到 State 對象所有信息并上報。

如何判斷當前的類是否為需要的 State 類呢?

  1. 自定義 CreateToStringMethodVisitor 繼承 Transformer,重寫訪問實例調用(visitInstanceInvocation)方法;
  2. 遍歷 AST,獲取當前實例調用 methodInvocation 的接口目標引用(interfaceTargetReference)的節點 node;
  3. 判斷該節點如果為 Procedure,獲取到它的 Class 和 Library,從而獲得 importUri、clsName、methodName;
  4. 由于 State 沒有明顯的繼承關系,無法直接判斷出一個類是否為 State,所以從 Emit 方法調用點出發,通過 Emit 方法調用點傳入的參數來獲取 State 對應的類,這么可分別對比 ImportUri、clsName、methodName 和新版 Bloc 的 Emit() 方法所在的類、Import 名字 和 Call() 方法所在的類、Import 名字,完全匹配則說明找到了 State 類的實力調用遍歷實例調用的位置參數列表中的表達式,根據表達式不同的類型獲取到對應的 state 的 Class;
  5. 遍歷 stateClass 的 Procedures,如果沒有 toStringProcedure,為當前 StateClass 生成 toStringProcedure 并插入到 Procedures 中。

如何生成 toStringProcedure 呢?

  1. 初始化一個空數組,里面存放的是映射文字條目(MapLiteralEntry)。
  2. 遍歷 StateClass 的 Fields,根據當前 Field 生成一個 Key 為 Field 名字,Value 為 Field 表達式的 MapLiteralEntry,添加到 MapLiteralEntry 數組中。
  3. 如果 stateClass 有父類,需要循環向上找到 Field 并生成對應的 MapLiteralEntry 添加到數組中。
  4. 數組 MapLiteralEntry 轉成 MapLiteral,創建 toStringMap實例調用 并包裝成帶有返回值的描述 Statement,通過這個描述 創建 FunctionNode,通過 FunctionNode 創建 toStringProcedure,添加到 StateClass 的 Procedures 中。

注意:需要存在一個 toStringProcedure 模版,不會憑空創建。

圖片圖片

關鍵實現

  1. 通過對象和屬性定義獲取對象屬性,即 StateClass 屬性保存的 Field 對象。
  2. 如果當前 Field 對象是數組的話,打印出來的會是 Instance of xxxModel,我們需要獲取 xxxModel 內部信息,所以需要對 xxxModel 進行 toJson()。
  3. 根據當前 Field 生成一個 Key 為 Field 名字,Value 為 Field 表達式的 MapLiteralEntry,添加到 MapLiteralEntry 數組中。
  4. 如果屬性定義對象為空,那么選擇以上生成的實例方法調用,否則使用 Field 對象即可。

圖片圖片

圖片圖片

最終效果

圖片圖片

圖片圖片

四、其他收益

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

責任編輯:武曉燕 來源: 得物技術
相關推薦

2024-11-01 12:39:04

2020-04-29 16:24:55

開發iOS技術

2023-02-08 19:37:37

大數據技術

2019-08-12 10:45:54

Flutter框架Native

2023-09-05 07:28:02

Java自動埋點

2023-04-19 09:05:44

2017-12-28 14:54:04

Android代碼埋點全埋點

2022-08-31 07:54:08

采集sdk埋點數據

2016-12-12 13:42:54

數據分析大數據埋點

2021-08-10 13:50:24

iOS

2021-02-19 07:59:21

數據埋點數據分析大數據

2023-01-10 09:08:53

埋點數據數據處理

2018-11-14 11:26:49

神策數據

2021-08-31 19:14:38

技術埋點運營

2023-11-21 07:14:43

埋點大數據

2016-08-12 00:30:45

互聯網數據埋點

2022-10-14 08:47:42

埋點統計優化

2022-11-01 18:21:14

數據埋點SDK

2024-08-29 14:44:01

質檢埋點

2024-03-06 19:57:56

探索商家可視化
點贊
收藏

51CTO技術棧公眾號

主站蜘蛛池模板: 中文字幕亚洲精品 | 伊人久久精品一区二区三区 | 91国内精品久久 | 日韩中文字幕在线观看 | 中文字幕视频在线 | 亚洲一区二区三区在线观看免费 | 91麻豆精品一区二区三区 | 欧美精品一区二区三区在线 | 欧美精品一区二区三区在线播放 | 久久精品一区 | 黄视频在线网站 | 日韩伦理电影免费在线观看 | 手机av在线| 亚洲黄色av网站 | 亚洲精品久久久久中文字幕欢迎你 | 黄色亚洲网站 | 免费看爱爱视频 | 午夜激情网 | 成人av看片| 免费高清av | 日韩欧美一级精品久久 | 欧美激情一区二区 | 久久精品国产一区二区三区不卡 | 亚洲91精品 | 国产精品视频一区二区三区, | 蜜桃在线一区二区三区 | 欧洲精品久久久久毛片完整版 | 欧美日韩精品一区二区 | 成人在线观看免费视频 | 青青草免费在线视频 | 国产乱码精品一区二区三区五月婷 | 国产精品无码久久久久 | 日韩精品一区二区三区中文字幕 | 日本久草视频 | 久久久久免费 | 人妖av| 天天摸天天干 | 日本不卡高字幕在线2019 | 国产一区久久 | 亚洲欧美综合精品另类天天更新 | 日韩欧美三级电影在线观看 |