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















 
 
 













 
 
 
 