基於自建 VTree 的全鏈路埋點方案

語言: CN / TW / HK

本文作者: dl

一、背景

  在當前移動網際網路時代,一個產品想快速、準確的搶佔市場,無疑是需要產品快速迭代更新,如何協助產品經理對產品當前的資料做出最優判斷是關鍵,這就需要客戶端側提供高精度穩定全鏈路的埋點資料;做客戶端開發的同學都深刻知道,想要在開發過程中滿足上述三點,開發過程都是頭大的;

  針對這個問題,我們自研了一套全鏈路埋點方案,從埋點設計、到客戶端三端(iOSAndroidH5)開發、以及埋點校驗&稽查、再到埋點資料使用,目前已經廣泛應用於雲音樂各個主要APP。

二、先聊聊傳統埋點方案的弊端

  傳統埋點,就是BI資料人員根據策劃想要的資料,設計出一個個的單點的坑位埋點,然後客戶端人員逐個埋進來,這些埋點經常都存在以下特點:

  1. 坑位的事件埋點很簡單:點選/雙擊/滑動等明確的事件類埋點,很簡單,根據需求一個一個埋上去即可
  2. 資源位曝光埋點是噩夢:在列表/非列表資源的曝光埋點場景,想做到高精度(埋點精度提到 99.99%)難度很大,你有可能每一個曝光埋點都需要考慮如下大部分場景:
  3. 每個坑位都是獨立的:坑位之間的埋點沒有關係,需要給每一個坑位起名字(比如通過隨機字串,或者組合引數來標識),頁面、列表、元素之間,存在大量的重複引數,以達到資料分析要求
  4. 漏斗/歸因分析難:由於每一個坑位埋點都是獨立的,APP使用過程中先後產生的埋點是無關聯的,想要做到漏斗/歸因分析,需要客戶端做魔鬼引數傳遞,然後資料分析時再逐個場景的做引數關聯分析
  5. 坑位黑盒:想知道一個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), 埋點時間等一些基本資訊之外,還得有業務埋點引數,以及能體現出物件上下級的結構

先來看下一個普通埋點的格式: json { "_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": "數字,毫秒單位" }

  1. _eventcode: 埋點的型別,比如元素點選(_ec), 元素曝光開始(_ev), 元素曝光結束(_ed), 頁面曝光開始(_pv), 頁面曝光結束(_pd) 等等
  2. _elist: 從當前元素節點開始,向上所有元素節點的集合,是一個數組,倒敘
  3. _plist: 從當前節點開始,向上所有頁面結點的即可,是一個數組,倒敘
  4. _spm: 上面已經介紹(SPM),可以唯一定位該坑位

從上面的資料結構可以看出,資料結構是結構化的,坑位不是獨立的,存在層級關係的

5.2 點選事件

大部分的點選事件,都發生在如下四個場景上:

  1. UIView上新增的TapGesture單擊手勢
  2. UIControl的子類新增的TouchUpInside單擊事件
  3. UITableViewCell的 didSelectedRowAtIndexPath 單擊事件
  4. UICollectionViewCell的 didSelectedItemAtIndexPath 單擊事件

對於上述四種場景,我們採用了AOP的方式來內部承接掉,這裡簡單說明下如何做的; 1. UIView: 通過 Method Swizzling 方式來進行對關鍵方法進行hock,當需要給view新增TapGesture時,順便新增一個我們自己的 TapGesture, 這樣我們就可以在點選事件觸發的時候增加點選埋點,關鍵方法如下: 1. initWithTarget:action: 2. addTarget:action: 3. removeTarget:action:

  1. 對UIView點選事件的hock注意需要做到隨著業務側事件的增加/刪除而一起增加/刪除
  2. 同時,我們做到了在 所有業務側點選事件觸發之前(pre) & 所有業務側點選事件觸發之後(after) 兩個維度的hock

關鍵程式碼如下: ```objc @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 *> *ne_et_validTargetActions; /// MARK: Add Category Property @end

@implementation UITapGestureRecognizer (AOP)

  • (instancetype)ne_et_tap_initWithTarget:(id)target action:(SEL)action { if ([self _ne_et_needsAOP]) { [self _ne_et_initPreAndAfterGesHanderIfNeeded]; }

    if (target && action) { UITapGestureRecognizer *ges = [self init]; [self addTarget:target action:action]; return ges; }

    return [self ne_et_tap_initWithTarget:target action:action]; }

  • (void)ne_et_tap_addTarget:(id)target action:(SEL)action { if (!target || !action || ![self _ne_et_needsAOP] || [[self.ne_et_validTargetActions objectForKey:target] containsObject:NSStringFromSelector(action)]) { [self ne_et_tap_addTarget:target action:action]; return; }

    SEL handlerAction = @selector(view_action_gestureRecognizerEvent:);

    // 1. pre [self _ne_et_initPreAndAfterGesHanderIfNeeded]; if (self.ne_et_validTargetActions.count == 0) { // 第一個 target+action 被新增的時候,才新增 pre [self ne_et_tap_addTarget:self.ne_et_preGesHandler action:handlerAction]; } [self ne_et_tap_removeTarget:self.ne_et_afterGesHandler action:handlerAction]; // 保障 after 是最後一個,所以先行嘗試刪除一次

    // 2. original [self ne_et_tap_addTarget:target action:action]; NSMutableSet *actions = [self.ne_et_validTargetActions objectForKey:target] ?: [NSMutableSet set]; [actions addObject:NSStringFromSelector(action)]; [self.ne_et_validTargetActions setObject:actions forKey:target];

    // 3. after [self ne_et_tap_addTarget:self.ne_et_afterGesHandler action:handlerAction]; }

  • (void)ne_et_tap_removeTarget:(id)target action:(SEL)action { [self ne_et_tap_removeTarget:target action:action];

    NSMutableSet *actions = [self.ne_et_validTargetActions objectForKey:target]; [actions removeObject:NSStringFromSelector(action)]; if (actions.count == 0) { [self.ne_et_validTargetActions removeObjectForKey:target]; }

    if (self.ne_et_validTargetActions.count > 0) { // 刪除當前 target+action 之後,還有其他的,則不需做任何處理,否則清理掉 pre+after return; }

    SEL handlerAction = @selector(view_action_gestureRecognizerEvent:); [self ne_et_tap_removeTarget:self.ne_et_preGesHandler action:handlerAction]; [self ne_et_tap_removeTarget:self.ne_et_afterGesHandler action:handlerAction]; }

  • (BOOL)_ne_et_needsAOP { return self.numberOfTapsRequired == 1 && self.numberOfTouchesRequired == 1; }

  • (void)_ne_et_initPreAndAfterGesHanderIfNeeded { if (!self.ne_et_preGesHandler) { UIViewEventTracingAOPTapGesHandler *preGesHandler = [[UIViewEventTracingAOPTapGesHandler alloc] init]; preGesHandler.isPre = YES; self.ne_et_preGesHandler = preGesHandler; } if (!self.ne_et_afterGesHandler) { self.ne_et_afterGesHandler = [[UIViewEventTracingAOPTapGesHandler alloc] init]; } } @end ```

  • UIControl: 通過 Method Swizzling 方式對關鍵方法進行hock,關鍵方法: sendAction:to:forEvent:

對UIcontrol點選事件的hock需要注意業務側添加了多個 Target-Action 事件,不能埋點埋了多次 同樣,也支援 pre & after 兩個維度的hock

關鍵程式碼如下: ```objc @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 actions = @[].mutableCopy; [self.allTargets enumerateObjectsUsingBlock:^(id _Nonnull obj, BOOL * _Nonnull stop) { NSArray *actionsForTarget = [self actionsForTarget:obj forControlEvent:UIControlEventTouchUpInside]; if (actionsForTarget.count) { [actions addObjectsFromArray:actionsForTarget]; } }]; BOOL valid = [actions containsObject:selStr]; if (!valid) { [self ne_et_Control_sendAction:action to:target forEvent:event]; return; }

// pre
if ([self.ne_et_lastClickActions count] == 0) {
    /// MAKR: 這裡是 Pre 程式碼位置
}
[self.ne_et_lastClickActions addObject:[NSString stringWithFormat:@"%@-%@", [target class], NSStringFromSelector(action)]];

// original
[self ne_et_Control_sendAction:action to:target forEvent:event];

// after
if (self.ne_et_lastClickActions.count == actions.count) {
    /// MARK: 這裡是 After 程式碼位置
    [self.ne_et_lastClickActions removeAllObjects];
}

} @end ```

  1. 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等

關鍵示例程式碼幾個重要的相關方法 (程式碼較多不再展示,三方有多個庫均可以借鑑): objc - (id)forwardingTargetForSelector:(SEL)selector; - (NSMethodSignature *)methodSignatureForSelector:(SEL)selector; - (void)forwardInvocation:(NSInvocation *)invocation; - (BOOL)respondsToSelector:(SEL)selector; - (BOOL)conformsToProtocol:(Protocol *)aProtocol;

5.3 曝光埋點

曝光埋點在傳統埋點場景下是最棘手的,很難做到高精度埋點,埋點時機總是窮舉不完,即使有了完善的規範,開發人員還總是會遺漏場景

我們這裡的方案讓開發者完全忽略曝光埋點的時機,開發者只把精力放在構建物件(或者說構建VTree),以及給物件新增引數上,下面看下是如何基於VTree做曝光的:

  1. 持續構建VTree: 前面提到,VTree是源源不斷的構建的,每一個view發生了變化,View的新增/刪除/層級變化/位移/大小變動/hidden/alpha,等等(這裡均是AOP方式hock),都會引起重新構建一顆新的VTree
  2. 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的曝光策略,得出如下:

  1. 這種策略,完全抹平了列表和非列表
  2. 曝光時機問題,轉而變成了何時構建VTree問題上
  3. 資源是否曝光的問題, 轉而變成了VTree中節點的可見性問題上

5.4 埋點開發步驟

  基於VTree的埋點,不管是點選、滑動等事件埋點,還是元素、頁面的曝光埋點,轉化成了如下兩個開發步驟:

  1. 給View設定oid => 成為物件 (構建VTree)

第一步: 給View設定oid

  1. 給物件設定埋點引數

第二步: 給物件設定埋點引數

六、VTree的構建

6.1 VTree構建過程

  構建一個VTree,是需要遍歷原始view樹的,構建過程中有如下特點:

  1. 一個節點是否可見,跟 view 的 hidden, alpha 有關,並且必須新增到window上
  2. 子節點的可見區域小於等於父節點的可見區域
  3. 節點的可見區域,可以自定義的 擴大 或者 縮小, 就像 UIButton 的 contentEdgeInsets 那樣

修改可見區域

  1. 節點是可以被遮擋的: 一個page節點可以遮擋父節點名下新增順序早於自己的其他節點

被遮擋了

從虛擬樹上來看,被遮擋的結果: 從虛擬樹上來看,被遮擋的結果

  1. 可打破原有view層級關係: 可以手工干預上下層級關係,以做到邏輯掛載的能力 > 事實上,目前提供了三種邏輯掛載能力,這裡簡單提下,不做詳細展開 > 1. 手動邏輯掛載: 指定將 A 掛載到 B 名下 > 2. 自動邏輯掛載: 將 A 掛載到當前 rootPage(當前VTree最下層最右側的page節點) 名下 > 3. spm形式邏輯掛載: 指定將 A 掛載到 spm 名下(對於解耦特別有用)
  2. 虛擬父節點: 可以給多個節點虛擬出一個父節點,對於雙端UI差異時,但是要求同一套埋點結構時,很有用

一個常見的例子,拿雲音樂首頁列表舉例子,每一個模組的title和資源容器(內部可橫向滑動),分別是一個cell;圖中的淺紅色(模組)其實沒有一個UIView與之對應,業務側埋點需要我們提供 模組 維度的曝光資料(但是Android開發過程中,通常都有UI與之對應) 虛擬父節點

精細化埋點:

  1. 自定義可見區域 & 遮擋 & 節點的遞迴可見性 結合起來,可以做到精細化埋點效果
  2. 針對 tabbar, navbar, 再或者雲音樂app底部的mini播放條等場景引起的列表cell是否曝光的問題,可做到精細化控制
  3. 以及配合遮擋能力,真正做到了節點所見及曝光,不可見即曝光結束的效果

6.2 構建過程的效能考慮

view的任何變化,都會引起VTree構建,看上去這是一件很恐怖的事情,因為每一次構建VTree都需要遍歷整顆原始view樹,我們做了如下優化來保障效能:

  1. 主執行緒runloop空閒的時候構建VTree(而且需要該runloop已經執行的時間,小於等於16.7ms/3,這是拿固定幀率60幀舉例)
  2. runloop構建限流器

主執行緒runloop

關鍵程式碼如下: ```objc /// 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 Func void 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 }

```

  1. 列表滑動中區域性虛擬樹VTree
  1. 區域性構建VTree,可以大大減少構建一次VTree的工作量
  2. 區域性構建的前提時,距離上次構建虛擬樹,發生變動的view都是ScrollView或者是ScrollView的子view
  1. 列表滑動中限流器

滾動中構建VTree

6.3 效能相關資料

  1. 適當的曝光延後,滿足資料要求,比如延遲1、2幀(取決於手機的效能以及當前CPU的工作量)
  2. runloop最小時長限流器的作用,還保障了延後不會太大,目前使用的0.1s
  3. 用iPhone12手機,以雲音樂首頁複雜場景舉例子,不停地上下滑動,全量/區域性構建VTree分別大概需要3-8ms/1-2ms的樣子,CPU佔用2-3%左右(雲音樂原來的列表曝光元件佔用10%左右的CPU)
  4. 不會因為SDK的存在,引起明顯的主執行緒卡頓或者手機發燙

七、鏈路追蹤

這個是SDK的重中之重的功能,目標是將app產生的所有埋點起來,以協助資料側統一一套模型即可分析漏斗/歸因資料

7.1 鏈路追蹤 refer 的含義

refer是一段格式化的字串,可以通過該字串,在整個數倉中唯一定位到一個埋點,這就是鏈路追蹤

7.2 如何定義一個埋點

  1. _sessid: 每次app冷啟動時生成,格式: [timestap]#[rand]#[appver]#[buildver]
  2. _pgstep: 該app啟動範圍內,每一個page曝光,_pgstep +1
  3. _actseq: 該 rootPage 曝光周期內,每一次 互動 事件(_pv也算一次事件),_actseq +1

通過上述三個引數,即可定位某一次app啟動 & 一次頁面曝光 週期內,哪一次的 互動 事件

7.3 先來看看如何認識一個埋點坑位

  1. _spm: 埋點的坑位資訊,該字串描述該坑位是什麼
  2. _scm: 埋點坑位的內容資訊,該字串描述該資源的內容是什麼
  3. 格式: [cid:ctype:ctraceid:ctrp]
  4. cid: content id, 該資源的唯一id
  5. ctype: content type, 該資源的型別
  6. ctraceid: content traceid, 介面達到閘道器時生成,服務端/演算法/推薦使用該字串做資料邏輯,在後續埋點時關聯起來,用來聯合分析推薦/演算法的效果
  7. ctrp: 透傳的擴充套件欄位,用來在資源維度透傳服務端/演算法/推薦的自定義引數

7.3 refer格式解析

格式: [_dkey:${keys}][F:${option}][sessid][e/p/xxx][_actseq][_pgstep][spm][scm]

  1. option: 是一個運算的值,用以描述該refer字串包含什麼內容
  2. _dkey: 是對option的字串形式,可讀性強(目前僅開發期間才有,方便人工識別)

option解析

  1. undefine-xpath: 用以標識該refer指向的內容是被 降級 了的,隨著埋點覆蓋越來越全,有該標識的refer會越來越少

7.4 refer的使用

先舉一個典型的使用場景

歌曲播放-refer

過程解讀: 1. 點選歌曲cell,觸發了歌曲播放列表的更新,這些歌曲的播放歸因(_addrefer),就歸結到該cell的點選埋點 2. 同時又跳轉了歌曲播放頁,該歌曲播放的歸因(_pgrefer),也歸結到了該cell的點選

refer的查詢: 1. 自動向前查詢: 這是絕大部分使用的策略,自動向前在refer佇列中找到合適的refer 2. undefine-xpath降級: 如果找到的refer生成的時間,早於最後一次AOP捕獲到的點選事件時間,則表明該位置沒有埋點,說明refer不可信,則被降級到最後一次 rootPage曝光 所對應的refer上 3. 精確refer查詢: 也有多個策略的精確refer查詢機制,不過使用起來不方便,沒有被大範圍使用

7.5 refer的統一解析

根據上面refer的格式,數倉側梳理出refer的格式統一解析,配合埋點管理平臺,讓規範化的漏斗/歸因分析變為可能

7.6 其他refer使用場景

  1. multirefers: 在實時分析場景,對一些關鍵埋點,帶上了五級(甚至更多級)的refer陣列,直接描述該操作的前五步做了什麼(實時分析要求高,不能做離線資料關聯)
  2. _hsrefer: 一鍵歸因,可以一次性歸因到該消費操作來源於app級別的哪個場景,比如首頁、搜尋頁、我的頁面等
  3. _rqrefer: 讓客戶端埋點跟服務端埋點橋接了起來

7.7 refer對開發人員透明

  1. refer的複雜性: refer的複雜度很高,真實的refer處理比上述描述的還要複雜很多,對於普通客戶端開發人員,想要完整理解,成本過於高
  2. 開發時透明: 對於開發人員來說,就是在對應的節點上增加相應的引數即可

    物件維度的三個標準私參(組成了_scm): cid, ctype, ctraceid, ctrp

  3. 可平臺校驗: 物件的事件是否參與鏈路追蹤, 引數完整性,等等,都可以在平臺做合法性校驗,進一步保障了refer的正確性

八、H5、RN

  • RN: 做了一層橋接,可以在RN維度給view設定節點,同時設定引數

RN橋接

  • 站內H5: 採用了半白盒方案,H5內部區域性虛擬樹,所有埋點通過客戶端SDK產生,H5埋點到達SDK後,在native側做虛擬樹融合,從而將站內H5跟native無縫地銜接了起來

H5半白盒方案

九、視覺化工具

客戶端上傳統的埋點都是看不見摸不著的,基於VTree的方案是結構化的,可以做到視覺化檢視埋點的資料,以及如何埋點的,下面是幾個工具的截圖

視覺化工具-埋點層級結構 視覺化工具-埋點資料

十、埋點校驗&稽查

  • 埋點是結構化的,虛擬樹是在埋點平臺管理起來的,埋點的校驗,可以做到精確校驗,校驗出客戶端的埋點虛擬樹是否正確
  • 以及每一個物件上埋點的引數是否正確

稽查: - 在測試包、灰度包中,對產生的所有埋點在平臺側做稽查,並輸出稽查報告,在版本釋出前,對有問題的埋點問題進行及時的修復,避免上線帶來資料問題

十一、落地

該全鏈路埋點方案,已經全面在雲音樂各個app鋪開,並且P0場景已經完成資料側切割,得到了充分的驗證。

十二、未來規劃

基於VTree可以做非常多的事情,比如: 1. 自動化測試: 關鍵點是對view做標識,同時可以使用該標識查詢到該view(基於VTree的UI自動化測試,已經落地,後面考慮再單獨跟大家聊) 2. 頁面標識: 跨端的統一頁面標識能力,用來做各種維度的場景標識 3. 基於VTree的資料視覺化能力: 可以在手機上看整個app級別的資料趨勢 4. 站內H5的視覺化埋點: 進一步降低H5場景的埋點工作量 5. refer能力的自動校驗和資料稽查: refer能力很強,但是出了問題後排查問題,有了相關工具來配合,會讓本來對開發人員透明的refer能力也能輕鬆排查

本文釋出自網易雲音樂技術團隊,文章未經授權禁止任何形式的轉載。我們常年招收各類技術崗位,如果你準備換工作,又恰好喜歡雲音樂,那就加入我們 grp.music-fe(at)corp.netease.com!