本文作者: dl
一、背景
在當前移動網際網路時代,一個產品想快速、準確的搶佔市場,無疑是需要產品快速迭代更新,如何協助產品經理對產品當前的資料做出最優判斷是關鍵,這就需要客戶端側提供
高精度
、
穩定
、
全鏈路
的埋點資料;做客戶端開發的同學都深刻知道,想要在開發過程中滿足上述三點,開發過程都是頭大的;
針對這個問題,我們自研了一套全鏈路埋點方案,從埋點設計、到客戶端三端(
iOS
、
Android
、
H5
)開發、以及埋點校驗&稽查、再到埋點資料使用,目前已經廣泛應用於雲音樂各個主要APP。
二、先聊聊傳統埋點方案的弊端
傳統埋點,就是BI資料人員根據策劃想要的資料,設計出一個個的
單點
的坑位埋點,然後客戶端人員逐個埋進來,這些埋點經常都存在以下特點:
坑位的事件埋點很簡單:點選/雙擊/滑動等明確的事件類埋點,很簡單,根據需求一個一個埋上去即可
資源位曝光埋點是噩夢:在列表/非列表資源的曝光埋點場景,想做到
高精度
(埋點精度提到
99.99%
)難度很大,你有可能每一個曝光埋點都需要考慮如下大部分場景:
每個坑位都是獨立的:坑位之間的埋點沒有關係,需要給每一個坑位
起名字
(比如透過隨機字串,或者組合引數來標識),頁面、列表、元素之間,存在大量的重複引數,以達到資料分析要求
漏斗/歸因分析難:由於每一個坑位埋點都是獨立的,APP使用過程中先後產生的埋點是無關聯的,想要做到漏斗/歸因分析,需要客戶端做
魔鬼引數
傳遞,然後資料分析時再逐個場景的做引數關聯分析
坑位黑盒:想知道一個app有多少坑位埋點,當前頁面下已經顯現出了多少坑位,坑位之間是什麼關係,管理成本高
三、我們曾經做過的一些嘗試
3。1 無痕埋點
市面上有很多人介紹
無痕埋點
,我們曾經也做過類似的嘗試;這種無痕,主要是針對一些坑位事件(比如點選、雙擊、滑動等事件)埋點做自動生成埋點,同時附帶上生成的
xpath
(根據view層級生成),然後把埋點上報到資料平臺後,再將xpath賦予真實的業務意義,從而可以進行資料分析;
但是這個方案的問題是隻能處理一些簡單事件場景,並且資料平臺做xpath關聯是一件噩夢,工作量大,最主要的是
不穩定
,對於埋點資料高精度場景,這個方案不可行(沒有哪個客戶端開發人員天天花費大量時間查詢 xpath 是什麼意義,以及隨著迭代業務的開發,xpath由於不受控制的變化帶來的資料問題帶來的排查工作量是巨大的)。
特別對於資源位的曝光上,想要做到真正的無痕,自動埋點,是不太可行的;比如列表場景,底層是不認識一個cell是什麼資源的,甚至都也不知道是不是一個資源。
四、我們的方案
4。1 物件
物件是我們方案埋點管理和開發的基本單位,給一個
UIView
設定
_oid
(物件Id: Object Id),該view就是一個物件; 物件分為兩大類,
page
&
element
;
物件&引數
page物件: 比如 UIViewController。view, WebView, 或者一個半屏浮層的view,再或者一個業務彈窗
element物件: 比如 UIButton, UICollectionViewCell, 或者一個自定義view
物件引數: 物件是埋點具體資訊的承載體,承載著物件維度的具體埋點引數
物件的複用: 物件的存在,其中一個很大的原因,就是需要做複用,對於一些通用UI元件,尤為合適
4。2 虛擬樹(VTree)
物件不是孤立存在的,而是以
虛擬樹(VTree)
的方式組合在一起的, 下面是一個示例:
虛擬樹 VTree
虛擬樹VTree有如下特點:
View樹子集: 原始view樹層級很複雜,被標識成物件的稱為節點,所有節點就組合成了VTree,是原始view樹的子集
上下文: 虛擬樹中的物件,是存在上下關係的,一個節點的所有祖先節點,就是該物件(節點)的上下文
物件引數: 有了節點的上下層級,不同維度的物件,只關心自己維度的引數,比如歌單詳情頁中歌曲cell不關心頁面請求級別的歌單id
SPM: 節點及其所有祖先結點的oid組成了SPM值(其實還有position引數的參與,稍後再詳解),該SPM可以唯一定位該節點
持續生成: VTree是源源不斷的構建的,每一個view發生了變化,View的新增/刪除/層級變化/位移/大小變動/hidden/alpha,等等,都會引起重新構建一顆新的VTree
五、埋點的產生
上面的方案介紹完之後,你一定存在很多疑惑,有了物件,有了虛擬樹,物件有了引數,埋點在哪兒?
5。1 先來看下埋點格式
一個埋點除了有事件型別(action), 埋點時間等一些基本資訊之外,還得有業務埋點引數,以及能體現出物件上下級的結構
先來看下一個普通埋點的格式:
{ “_elist”: [ { “_oid”: “【必選】元素的oid”, “_pos”: “【可選】,業務方配置的位置資訊”, “biz_param”: “【按需】業務引數” } ], “_plist”: [ { “_oid”: “【必選】page的oid”, “_pos”: “【可選】,業務方配置的位置資訊”, “_pgstep”: “【必選】, 該page/子page曝光時的頁面深度” } ], “_spm”: “【必選】這裡描述的是節點的“位置”資訊,用來定位節點”, “_scm”: “【必選】這裡描述的是節點的“內容”資訊,用來描述節點的內容”, “_sessid”: “【必選】冷啟動生成,會話id”, “_eventcode”: “【必選】事件: _ec/_ev/_ed/_pv/_pd”, “_duration”: “數字,毫秒單位”}
_eventcode: 埋點的型別,比如元素點選(_ec), 元素曝光開始(_ev), 元素曝光結束(_ed), 頁面曝光開始(_pv), 頁面曝光結束(_pd) 等等
_elist: 從當前元素節點開始,向上所有元素節點的集合,是一個數組,倒敘
_plist: 從當前節點開始,向上所有頁面結點的即可,是一個數組,倒敘
_spm: 上面已經介紹(SPM),可以唯一定位該坑位
從上面的資料結構可以看出,資料結構是結構化的,坑位不是獨立的,存在層級關係的
5。2 點選事件
大部分的點選事件,都發生在如下四個場景上:
UIView上新增的TapGesture單擊手勢
UIControl的子類新增的TouchUpInside單擊事件
UITableViewCell的 didSelectedRowAtIndexPath 單擊事件
UICollectionViewCell的 didSelectedItemAtIndexPath 單擊事件
對於上述四種場景,我們採用了AOP的方式來內部承接掉,這裡簡單說明下如何做的;
1。UIView: 透過 Method Swizzling 方式來進行對關鍵方法進行hock,當需要給view新增TapGesture時,順便新增一個我們自己的 TapGesture, 這樣我們就可以在點選事件觸發的時候增加點選埋點,關鍵方法如下:
initWithTarget:action:
addTarget:action:
removeTarget:action:
1。對UIView點選事件的hock注意需要做到隨著業務側事件的增加/刪除而一起增加/刪除
關鍵程式碼如下:
@interface UIViewEventTracingAOPTapGesHandler : NSObject@property(nonatomic, assign) BOOL isPre;- (void)view_action_gestureRecognizerEvent:(UITapGestureRecognizer *)gestureRecognizer;@end@implementation UIViewEventTracingAOPTapGesHandler- (void)view_action_gestureRecognizerEvent:(UITapGestureRecognizer *)gestureRecognizer { if (![gestureRecognizer isKindOfClass:[UITapGestureRecognizer class]] || gestureRecognizer。ne_et_validTargetActions。count == 0) { return; } UIView *view = gestureRecognizer。view; // for: pre if (self。isPre) { /// MARK: 這裡是 Pre 程式碼位置 return; } // for: after /// MARK: 這裡是 After 程式碼位置}@interface UITapGestureRecognizer (AOP)@property(nonatomic, strong, setter=ne_et_setPreGesHandler:) UIViewEventTracingAOPTapGesHandler *ne_et_preGesHandler; /// MARK: Add Category Property@property(nonatomic, strong, setter=ne_et_setAfterGesHandler:) UIViewEventTracingAOPTapGesHandler *ne_et_afterGesHandler; /// MARK: Add Category Property@property(nonatomic, strong, readonly) NSMapTable
2。UIControl: 透過 Method Swizzling 方式對關鍵方法進行hock,關鍵方法: sendAction:to:forEvent:
對UIcontrol點選事件的hock需要注意業務側添加了多個 Target-Action 事件,不能埋點埋了多次
關鍵程式碼如下:
@interface UIControl (AOP)@property(nonatomic, copy, readonly) NSMutableArray *ne_et_lastClickActions; /// MARK: Add Category Property@end@implementation UIControl (AOP)- (void)ne_et_Control_sendAction:(SEL)action to:(id)target forEvent:(UIEvent *)event { NSString *selStr = NSStringFromSelector(action); NSMutableArray
3。UITableViewCell: 先對 setDelegate: 進行hock,然後以 NSProxy 的形式將 Original Delegate 進行
封裝
,組成 Delegate Chain 的形式,然後在 DelegateProxy 內部做訊息分發,從而可以完全掌控點選事件
1。該 Delegate Chain 的方式可以hock的不支援 點選事件,可以hock所有 Delegate 的方法
2。同樣,也支援 pre & after 兩個維度的hock
3。特別注意: 需要做到真正的 DelegateChain,不然會跟不少三方庫衝突,比如 RXSwift,RAC,BlocksKit,IGListKit等
關鍵示例程式碼幾個重要的相關方法 (程式碼較多不再展示,三方有多個庫均可以借鑑):
- (id)forwardingTargetForSelector:(SEL)selector;- (NSMethodSignature *)methodSignatureForSelector:(SEL)selector;- (void)forwardInvocation:(NSInvocation *)invocation;- (BOOL)respondsToSelector:(SEL)selector;- (BOOL)conformsToProtocol:(Protocol *)aProtocol;
5。3 曝光埋點
曝光埋點在傳統埋點場景下是最棘手的,很難做到
高精度
埋點,埋點時機總是窮舉不完,即使有了完善的規範,開發人員還總是會遺漏場景
我們這裡的方案讓開發者完全忽略曝光埋點的時機,開發者只把精力放在構建物件(或者說構建VTree),以及給物件新增引數上,下面看下是如何基於VTree做曝光的:
持續構建VTree: 前面提到,VTree是源源不斷的構建的,每一個view發生了變化,View的新增/刪除/層級變化/位移/大小變動/hidden/alpha,等等(這裡均是AOP方式hock),都會引起重新構建一顆新的VTree
VTree Diff: 先後兩個VTree的diff,就是我們曝光埋點的結果
隨著時間,會源源不斷的生成新的VTree:
遠遠不斷地生成VTree
比如T1時刻生成的VTree:
T1時刻的VTree
T2時刻生成的VTree:
T2時刻的VTree
先後兩顆VTree的diff:
T1存在T2不存在的節點: 3, 4, 6, 7, 8, 11
T1不存在T2存在的節點: 20, 21, 22, 23
上面的diff結果,就是曝光埋點的結論
曝光結束: 3, 4, 6, 7, 8, 11
曝光開始: 20, 21, 22, 23
從上面以及VTree Diff的曝光策略,得出如下:
這種策略,完全抹平了列表和非列表
曝光時機問題,轉而變成了何時構建VTree問題上
資源是否曝光的問題, 轉而變成了VTree中節點的可見性問題上
5。4 埋點開發步驟
基於VTree的埋點,不管是點選、滑動等事件埋點,還是元素、頁面的曝光埋點,轉化成了如下兩個開發步驟:
給View設定oid => 成為物件 (構建VTree)
第一步: 給View設定oid
給物件設定埋點引數
第二步: 給物件設定埋點引數
六、VTree的構建
6。1 VTree構建過程
構建一個VTree,是需要遍歷原始view樹的,構建過程中有如下特點:
一個節點是否可見,跟 view 的 hidden, alpha 有關,並且必須新增到window上
子節點的可見區域小於等於父節點的可見區域
節點的可見區域,可以自定義的
擴大
或者
縮小
, 就像 UIButton 的 contentEdgeInsets 那樣
修改可見區域
節點是可以被遮擋的: 一個page節點可以遮擋父節點名下新增順序早於自己的其他節點
被遮擋了
從虛擬樹上來看,被遮擋的結果:
從虛擬樹上來看,被遮擋的結果
可打破原有view層級關係: 可以手工干預上下層級關係,以做到邏輯掛載的能力
> 事實上,目前提供了三種邏輯掛載能力,這裡簡單提下,不做詳細展開
> 1。 手動邏輯掛載: 指定將 A 掛載到 B 名下
> 2。 自動邏輯掛載: 將 A 掛載到當前 rootPage(當前VTree最下層最右側的page節點) 名下
> 3。 spm形式邏輯掛載: 指定將 A 掛載到
spm
名下(對於解耦特別有用)
虛擬父節點: 可以給多個節點虛擬出一個父節點,對於雙端UI差異時,但是要求同一套埋點結構時,很有用
一個常見的例子,拿雲音樂首頁列表舉例子,每一個模組的title和資源容器(內部可橫向滑動),分別是一個cell;圖中的淺紅色(模組)其實沒有一個UIView與之對應,業務側埋點需要我們提供
模組
維度的曝光資料(但是Android開發過程中,通常都有UI與之對應)
虛擬父節點
精細化埋點:
自定義可見區域 & 遮擋 & 節點的遞迴可見性 結合起來,可以做到精細化埋點效果
針對 tabbar, navbar, 再或者雲音樂app底部的mini播放條等場景引起的列表cell是否曝光的問題,可做到精細化控制
以及配合遮擋能力,真正做到了節點所見及曝光,不可見即曝光結束的效果
6。2 構建過程的效能考慮
view的任何變化,都會引起VTree構建,看上去這是一件很恐怖的事情,因為每一次構建VTree都需要遍歷整顆原始view樹,我們做了如下最佳化來保障效能:
主執行緒runloop空閒的時候構建VTree(而且需要該runloop已經執行的時間,小於等於16。7ms/3,這是拿固定幀率60幀舉例)
runloop構建限流器
主執行緒runloop
關鍵程式碼如下:
/// MARK: 新增最小時長限流器 _throtte = [[NEEventTracingTraversalRunnerDurationThrottle alloc] init]; /// 至少間隔 0。1s 才做一次 _throtte。tolerentDuration = 0。1f; _throtte。callback = self; /// MAKR: runloop observer CFRunLoopObserverContext context = {0, (__bridge void *) self, NULL, NULL, NULL}; const CFIndex CFIndexMax = LONG_MAX; _runloopObserver = CFRunLoopObserverCreate(kCFAllocatorDefault, kCFRunLoopAllActivities, YES, CFIndexMax, &ETRunloopObserverCallback, &context);/// MAKR: Observer Funcvoid ETRunloopObserverCallback(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info) { NEEventTracingTraversalRunner *runner = (__bridge NEEventTracingTraversalRunner *)info; switch (activity) { case kCFRunLoopEntry: [runner _runloopDidEntry]; break; case kCFRunLoopBeforeWaiting: [runner。throtte pushValue:nil]; break; case kCFRunLoopAfterWaiting: [runner _runloopDidEntry]; break; default: break; }}- (void)_runloopDidEntry { _currentLoopEntryTime = CACurrentMediaTime() * 1000。f;}- (void)_needRunTask { CFTimeInterval now = CACurrentMediaTime() * 1000。f; // 如果本次主執行緒的runloop已經使用了了超過 16。7/2。f 毫秒,則本次runloop不再遍歷,放在下個runloop的beforWaiting中 // 按照目前手機一秒60幀的場景,一幀需要1/60也就是16。7ms的時間來執行程式碼,主執行緒不能被卡住超過16。7ms // 特別是針對 iOS 15 之後,iPhone 13 Pro Max 幀率可以設定到 120hz static CFTimeInterval frameMaxAvaibleTime = 0。f; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ NSInteger maximumFramesPerSecond = 60; if (@available(iOS 10。3, *)) { maximumFramesPerSecond = [UIScreen mainScreen]。maximumFramesPerSecond; } frameMaxAvaibleTime = 1。f / maximumFramesPerSecond * 1000。f / 3。f; }); if (now - _currentLoopEntryTime > frameMaxAvaibleTime) { return; } BOOL runModeMatched = [[NSRunLoop mainRunLoop]。currentMode isEqualToString:(NSString *) self。currentRunMode]; /// MARK: 這裡回撥,開始構建 VTree}
列表滑動中區域性虛擬樹VTree
區域性構建VTree,可以大大減少構建一次VTree的工作量
區域性構建的前提時,距離上次構建虛擬樹,發生變動的view都是ScrollView或者是ScrollView的子view
列表滑動中限流器
滾動中構建VTree
6。3 效能相關資料
適當的曝光延後,滿足資料要求,比如延遲1、2幀(取決於手機的效能以及當前CPU的工作量)
runloop最小時長限流器的作用,還保障了延後不會太大,目前使用的0。1s
用iPhone12手機,以雲音樂首頁複雜場景舉例子,不停地上下滑動,全量/區域性構建VTree分別大概需要3-8ms/1-2ms的樣子,CPU佔用2-3%左右(雲音樂原來的列表曝光元件佔用10%左右的CPU)
不會因為SDK的存在,引起明顯的主執行緒卡頓或者手機發燙
七、鏈路追蹤
這個是SDK的重中之重的功能,目標是將app產生的所有埋點
鏈
起來,以協助資料側統一一套模型即可分析漏斗/歸因資料
7。1 鏈路追蹤 refer 的含義
refer是一段格式化的字串,可以透過該字串,在整個數倉中唯一定位到一個埋點,這就是鏈路追蹤
7。2 如何定義一個埋點
_sessid: 每次app冷啟動時生成,格式:
[timestap]#[rand]#[appver]#[buildver]
_pgstep: 該app啟動範圍內,每一個page曝光,
_pgstep
+1
_actseq: 該
rootPage
曝光週期內,每一次
互動
事件(_pv也算一次事件),
_actseq
+1
透過上述三個引數,即可定位某一次app啟動 & 一次頁面曝光 週期內,哪一次的
互動
事件
7。3 先來看看如何認識一個埋點坑位
[cid:ctype:ctraceid:ctrp]
7。3 refer格式解析
格式:
[_dkey:${keys}][F:${option}][sessid][e/p/xxx][_actseq][_pgstep][spm][scm]
位
option解析
undefine-xpath: 用以標識該refer指向的內容是被
降級
了的,隨著埋點覆蓋越來越全,有該標識的refer會越來越少
7。4 refer的使用
先舉一個典型的使用場景
歌曲播放-refer
過程解讀:
_addrefer_pgrefer
refer的查詢:
自動向前查詢: 這是絕大部分使用的策略,自動向前在refer佇列中找到合適的refer
undefine-xpath降級: 如果找到的refer生成的時間,早於最後一次AOP捕獲到的
點選事件
時間,則表明該位置沒有埋點,說明refer不可信,則被降級到最後一次
rootPage曝光
所對應的refer上
精確refer查詢: 也有多個策略的精確refer查詢機制,不過使用起來不方便,沒有被大範圍使用
7。5 refer的統一解析
根據上面refer的格式,數倉側梳理出refer的格式統一解析,配合埋點管理平臺,讓規範化的漏斗/歸因分析變為可能
7。6 其他refer使用場景
multirefers: 在實時分析場景,對一些關鍵埋點,帶上了五級(甚至更多級)的refer陣列,直接描述該操作的前五步做了什麼(實時分析要求高,不能做離線資料關聯)
_hsrefer: 一鍵歸因,可以一次性歸因到該消費操作來源於app級別的哪個場景,比如首頁、搜尋頁、我的頁面等
_rqrefer: 讓客戶端埋點跟服務端埋點橋接了起來
7。7 refer對開發人員透明
refer的複雜性: refer的複雜度很高,真實的refer處理比上述描述的還要複雜很多,對於普通客戶端開發人員,想要完整理解,成本過於高
開發時透明: 對於開發人員來說,就是在對應的節點上增加相應的引數即可
物件維度的三個標準私參(組成了_scm): cid, ctype, ctraceid, ctrp
可平臺校驗: 物件的事件是否參與鏈路追蹤, 引數完整性,等等,都可以在平臺做合法性校驗,進一步保障了refer的正確性
八、H5、RN
RN: 做了一層橋接,可以在RN維度給view設定節點,同時設定引數
RN橋接
站內H5: 採用了半白盒方案,H5內部區域性虛擬樹,所有埋點透過客戶端SDK產生,H5埋點到達SDK後,在native側做虛擬樹融合,從而將站內H5跟native無縫地銜接了起來
H5半白盒方案
九、視覺化工具
客戶端上傳統的埋點都是看不見摸不著的,基於VTree的方案是結構化的,可以做到視覺化檢視埋點的資料,以及如何埋點的,下面是幾個工具的截圖
視覺化工具-埋點層級結構
視覺化工具-埋點資料
十、埋點校驗&稽查
埋點是結構化的,虛擬樹是在埋點平臺管理起來的,埋點的校驗,可以做到精確校驗,校驗出客戶端的埋點虛擬樹是否正確
以及每一個物件上埋點的引數是否正確
稽查:
在測試包、灰度包中,對產生的所有埋點在平臺側做稽查,並輸出稽查報告,在版本釋出前,對有問題的埋點問題進行及時的修復,避免上線帶來資料問題
十一、落地
該全鏈路埋點方案,已經全面在雲音樂各個app鋪開,並且P0場景已經完成資料側切割,得到了充分的驗證。
十二、未來規劃
基於VTree可以做非常多的事情,比如:
自動化測試: 關鍵點是對view做標識,同時可以使用該標識查詢到該view(基於VTree的UI自動化測試,已經落地,後面考慮再單獨跟大家聊)
頁面標識: 跨端的統一頁面標識能力,用來做各種維度的場景標識
基於VTree的資料視覺化能力: 可以在手機上看整個app級別的資料趨勢
站內H5的視覺化埋點: 進一步降低H5場景的埋點工作量
refer能力的自動校驗和資料稽查: refer能力很強,但是出了問題後排查問題,有了相關工具來配合,會讓本來對開發人員透明的refer能力也能輕鬆排查
作者:雲音樂技術團隊-dl