引言:無痕埋點,眾所周知是移動端一個收集用戶行為和數據分析很重要的一項技術手段。Flutter作為近幾年年大熱的移動端跨平臺技術生態圈已慢慢建設起來,而全埋點始終沒有很好的解決方案,于是通過閱讀源碼找尋了一些思路分享出來。
一、頁面埋點
思路:在CupertinoApp中添加NavigatorObserver全局頁面監聽,當頁面push和pop時維護一個自定義的路由棧用來存儲需要的信息,方便回溯。監聽方法:didPush、didPop、didRemove、didReplace。當觸發頁面push時監聽頁面渲染完成,然后從根節點遍歷Element樹尋找類型為Scaffold或CupertinoPageScaffold的Element,則最后一個元素命中當前頁面,記錄保存信息在堆棧中。當頁面pop時從棧頂取出頁面信息上報。
定位到Scaffold或CupertinoPageScaffold則需要遍歷子節點尋找我們需要的信息。確定AppBar或NavigationBar有PreferredSize或ObstructingPreferredSizeWidget兩種實例,如果AppBar有Title為Text,則data則為我們需要的信息,否則向下遍歷子樹尋找第一個Text標簽。
二、用戶點擊行為埋點
思路:系統處理手勢是在底層生成GestureDetector,傳入OnTap等手勢事件調用CallBack,分離widget樹。包裝GestureArenaMember進行競技場競爭,由獲勝者acceptGesture調用手勢事件(分層解耦思想體現地淋漓盡致)。當然競技場還有管理者GestureArenaManager,競技場管理者放在GestureBinding中,最后調用dispatchEvent處理。我們需要獲取到獲勝手勢,并且關聯RenderObject,然后遍歷Element樹匹配獲勝者。因為GestureDetector為Button的chid或者只有自身,所以我們判斷常用的Button類,當無法匹配到的前提下說明沒有包裝Button,則再去尋找最近的GestureDetector,獲取其Widget。最后提煉關鍵信息Text、Icon、Image、SvgPicture等(總體思路就是繼承系統組件,添加自己需要的字段,然后回傳給系統調用,最方便還是在運行時動態插入代碼)。
第一步:參照Flutter框架的WidgetsFlutterBinding進行實現,將其子類化,并將必要的變量與方法替換掉(類似Flutter的testing框架,自定義了TestWidgetsFlutterBinding)。
void runApp(Widget app) {
var widget = EventMonitorWidget(
monitor: eventMonitor,
child: app,
);
//此處應當照搬Flutter的全局函數runApp中的代碼
scheduleAttachRootWidget(widget);
scheduleWarmUpFrame();
}
第二步:替換掉競技場管理者的實例,安插一個由我們實現的實例,用于監測獲勝的手勢。
@override
final GestureArenaManager gestureArena = EventMonitorGestureArenaManager();
第三步:覆蓋原先的dispatchEvent方法。將HitTestResult替換為由我們實現的實例,將用于建立手勢GestureArenaMember與手勢的創建者HitTestTarget之間的對應關系。
@override
void dispatchEvent(PointerEvent event, HitTestResult hitTestResult, {TestBindingEventSource source: TestBindingEventSource.device}) {
var monitoredResult = (gestureArena as EventMonitorGestureArenaManager).dispatchEvent(event, hitTestResult);
super.dispatchEvent(event, monitoredResult);
}
第四步:在自定義的競技場成員獲勝者調用acceptGesture方法里通知監聽者。并傳入RenderObject遍歷Element樹確定widget,然后向下查找需要的標識信息。
void acceptGesture(int pointer) {
if ((member is GestureRecognizer) && (onAcceptGesture != null)) {
onAcceptGesture(this);
}
member.acceptGesture(pointer);
}
優化
:點擊事件up狀態并不一定還在原觸發控件里,需要過濾掉部分手勢事件,防止誤報信息。
思路:hook掉系統的類GestureArenaMember、GestureArenaManager、HitTestTarget、HitTestEntry、HitTestResult方便添加我們需要的信息。用戶的觸摸事件在從Down到Up的整個歷程中,某個手勢一旦獲得競技勝利,只會觸發一次AcceptGesture,為了保證整個歷程中的每一個事件都能通知給外部處理者,需要補發一次回調。在監聽者處過濾event不為PointerUpEvent且GestureRecognizerState不為possible的Tap手勢勝利者進行點擊處理上報。
//只在Tap手勢結束時刻才進行進一步的判斷處理
if (!(ge.event is PointerUpEvent)) {
return;
}
//Tap手勢仍為成功狀態,或者Tap手勢是在PointerUpEvent事件中才競技勝出的,都視為有效的點擊事件
var gesture = ge.gesture as TapGestureRecognizer;
if ((gesture.state != GestureRecognizerState.possible) &&
(ge.type != EventMonitorGestureEventType.winArena)) {
return;
}
三、列表元素曝光
思路:在widget頂部添加全局滑動監聽NotificationListener<ScrollNotification>。我們發現在ScrollNotification中帶有滾動發起者context(為GestureDetector),則可通過往上查找widget樹匹配第一個為ScrollView或SingleChildScrollView類型的列表目標element。需要拿到列表全部cell高度,則可以通過ScrollNotification中metrics.pixels的偏移量,結合列表高度計算出曝光范圍width和height。這里我們往下查找第一個元素為RenderSliverMultiBoxAdaptor的RenderObject(通過看源碼得知常用ListView等列表widget底層生成的RenderObject),記錄.paintBounds.size并查詢所有的child為RenderIndexedSemantics類型是我們找到的cell。注意:這里獲取到的曝光cell和緩存區的cell,并不一定是全部的列表元素,所以我們需要記錄一個Map類型數據緩存用來進行列表數據更新,保證獲取到所有cell的size。考慮到內存問題,所以引入了LRU緩存機制進行優化。最后計算在曝光范圍內元素的索引范圍。
上報規則:在用戶開始滾動時記錄曝光元素和當前時間,當有額外交互時檢查是否有之前的記錄,并且超過一定間隔才做上報處理,否則清空記錄。自定義了widget方便業務側上傳需要上報的源數據或提供列表的上下Globalkey獲取上下組件的坐標計算出模板列表坐標和size。
優化
:1.復雜列表比如NestedScrollView有頭部和內容聯動,導致查找出來的元素偏多,因為獲取到列表高度是整體nestedScrollView不是內部內容高度。2.列表查詢的RenderObject覆蓋面有待查驗,可能有遺漏特殊列表類型。3.當頁面加載完畢就應該記錄初始化曝光的元素。
int start, end;
double addHeigh = 0;
for (var i = 0; i < itemHeighList.length; i++) {
double itemHeigh = itemHeighList[i];
if (start == null && (addHeigh + itemHeigh * 0.5) >= 偏移量) {
//確定開始范圍
start = i;
}
if ((addHeigh + itemHeigh * 0.5) < (offset + 列表高度)) {
//確定結束范圍
end = i;
}
addHeigh += itemHeigh;
}
四、用戶切換前后臺
思路:在最頂層widget中聚合WidgetsBindingObserver,監聽didChangeAppLifecycleState方法。當AppLifecycleState為paused表示進入后臺,resumed表示進入前臺。
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
if (state == AppLifecycleState.paused) {
//后臺
}
if (state == AppLifecycleState.resumed) {
//前臺
}
}