作為外掛全埋點解決方案

語言: CN / TW / HK

關注“之家技術”,獲取更多技術乾貨

總篇154篇 2022年第29篇

使用者資料分析與埋點,網際網路產品不可缺少的部分,幫助產品分析功能決策,配合回溯使用者操作路徑及習慣,分析後產生更適合使用者的需求。

目前,業界主流埋點方式,主要為以下三種:

a.程式碼埋點

一直採用程式碼埋點,通過sdk方法進行觸發。程式碼埋點是“最原始”的埋點方式,同時也是“最萬能”的方式

優點: 1.精準控制埋點位置;2.方便採集自定義屬性和資料 ;3.滿足精細化分析需求

缺點: 1.埋點成本較高;2.需求發生變化時,需要重新設計修改

b.全埋點 (本文只關注作為外掛的全埋點的點選事件)

全埋點也叫無埋點,自動埋點等。目的是寫少量程式碼,收集使用者所有或大部分資料的行為,然後進行整理分析

優點:前期埋點成本較低 發生設計變化無需修改埋點 有效解決歷史資料回溯問題

缺點:無法覆蓋複雜操作 無法滿足更精細化分析需求 不同版本可能存在相容性

c.視覺化埋點 

建立在全埋點上,通過圈選方式埋點

解決方案

結合全埋點和程式碼埋點的優點,設計出一套全埋點點選收集方案作為程式碼埋點及產品臨時查詢埋點的補充。針對業務情況做出如下。

思考:

  • 點選收集如何獲取所有的點選事件,並標記唯一id的規則。

  • 市面上的全埋點方案都是以AOP方式進行。是否我們也可行

  • AOP優點明顯入侵量小,不涉及業務程式碼,但缺點也明顯他需要侵入系統級方法。

  • 我們在APP內只是個業務線,切面到系統的方法可以做到,但作為外掛不應影響其他業務線,AOP未作為我們可選方案。

解決: 本文共兩個部分:IOS全埋點,flutter全埋點。

  • IOS全埋點

我們專案內點選的事件主要有 UIButton,UISwitch,UITableView ,主軟體提供的公共控制元件點選回撥 既然AOP不行那我們就採用繼承方式。然後AOP切入我們自己的方法內這就不會影響其他業務線程式碼

1.對於UIButton和UISwitch等繼承自UIControl的控制元件就簡單了 獲取唯一標識方法如下

a.只需要在內部實現,為保證切入方法獨立並沒在子類處理埋點內容

@interface XXYButton : UIButton
- (void)sendAction:(SEL)action to:(nullable id)target forEvent:(nullable UIEvent *)event {
    [super sendAction:action to:target forEvent:event];
}
b.生成同名分類XXYButton+AOP.h
+ (void)load {
   static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        Method srcMethod = class_getInstanceMethod([self class], @selector(sendAction:to:forEvent:));
        Method tarMethod = class_getInstanceMethod([self class],@selector(xxy_sendAction:to:forEvent:));
        method_exchangeImplementations(srcMethod, tarMethod);
    });
}
-(void)xxy_sendAction:(SEL)action to:(id)target forEvent:(UIEvent *)event
{
    [self xxy_sendAction:action to:target forEvent:event];

    NSString * identifier = [NSString stringWithFormat:@"%@/%@/%ld", [target class], NSStringFromSelector(action),self.tag];
}

identifier就是我們要獲取的唯一標識,包括類名方法名和tag

2.對於UITableView

獲取唯一標識方法如下

a.子類實現代理

 - (void)setDelegate:(id<UITableViewDelegate>)delegate {
    [super setDelegate:delegate];
}

b.同理生成同名分類XXYTableView+AOP.h

+ (void)load {
   .....
}
- (void)xxy_setDelegate:(id<UITableViewDelegate>)delegate {
    [self xxy_setDelegate:delegate];
    
    SEL sel = @selector(tableView:didSelectRowAtIndexPath:);
    
    SEL sel_ =  NSSelectorFromString([NSString stringWithFormat:@"%@/%@/%ld", NSStringFromClass([delegate class]), NSStringFromClass([self class]),self.tag]);
    
    //因為 tableView:didSelectRowAtIndexPath:方法是optional的,所以沒有實現的時候直接return
    if (![self isContainSel:sel inClass:[delegate class]]) {
        return;
    }
    BOOL addsuccess = class_addMethod([delegate class],
                                      sel_,
                                      method_getImplementation(class_getInstanceMethod([self class], @selector(user_tableView:didSelectRowAtIndexPath:))),
                                      nil);
    
    if (addsuccess) {
      ....
    }}
    
 -(void)user_tableView:(XXYTableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
    
    NSString *identifier = [NSString stringWithFormat:@"%@/%@/%ld", NSStringFromClass([self class]),  NSStringFromClass([tableView class]), tableView.tag];
    SEL sel = NSSelectorFromString(identifier);
    if ([self respondsToSelector:sel]) {
        IMP imp = [self methodForSelector:sel];
        void (*func)(id, SEL,id,id) = (void *)imp;
        func(self, sel,tableView,indexPath);
        }
    }

identifier就是我們要獲取的唯一標識,包括類名方法名和tag

3.對於主軟體公共控制元件 考慮過繼承,寫起來太麻煩,並且如果有更改我們還隨著改有可能影響業務,在不影響業務的前提下。我們選擇了在自己類重新實現下主軟體代理並用AOP切入當前類

Method srcMethod = class_getInstanceMethod([self class], @selector(onClickItemIndex:atIndex:));
  Method tarMethod = class_getInstanceMethod([self class],@selector(xxy_onClickItemIndex:atIndex:));
  
- (void)xxy_onClickItemIndex:(id)scrollTitleBar atIndex:(NSInteger)aIndex {
    [self xxy_onClickItemIndex:scrollTitleBar atIndex:aIndex];
    NSString * identifier = [NSString stringWithFormat:@"%@/%@/%ld",NSStringFromClass([self class]), NSStringFromClass([scrollTitleBar class]),aIndex];
ic];
}

identifier就是我們要獲取的唯一標識,包括類名方法名和index

原生獲取全埋點標識相對簡單,只需要關注不影響其他業務,拿到唯一id,在有self的情況下,也能通過執行時獲取物件內屬性,不在列舉

  • flutter全埋點

1.flutter頁面ID規則分析

首先頁面ID不像原生有個統一規則,我們在獲取某個widget會使用個唯一 GlobalKey 標識,除非有特殊情況通常不會去使用,更不會所有控制元件都標記獲取,這時候我們想到了flutter的檢視樹。

alt 屬性文字

每個元件都是根據父子,兄弟關係繪製,根據控制元件本身向上逐級遍歷拿到根節點,這樣就能得到一個樹的的路徑,這個路徑就是我們的唯一ID (引入網路圖片)

上面就是flutter經典的三棵樹,widget樹並沒有parent和child供我們遍歷,所以要從element樹入手,Element樹實現了BuildContext,而BuildContext實現了遍歷的一些方法

abstract class BuildContext {

T? findAncestorStateOfType<T extends State>();
void visitAncestorElements(bool visitor(Element element));
.....
}

Element實現的搜尋方法 (原始碼)

void visitAncestorElements(bool visitor(Element element)) {
assert(_debugCheckStateIsActiveForAncestorLookup());
Element? ancestor = _parent;
while (ancestor != null && visitor(ancestor))
 ancestor = ancestor._parent;
}

注:市面上flutter的全埋點也是採用AOP方式,都是採用閒魚的 Aspectd 框架,根據上面IOS分析,我們是無法觸及到底層flutter,更何況Aspectd更改了編譯方法。AOP行不通,繼續使用繼承是否就可以了,當然可以,flutter只佔了我們一個頁面。

a.收集專案內可點選方法,當時覺得用系統的麻煩還傳些預設引數,我們都給做了封裝UCGesture(),現在正好能用上。我們要關注的點選方法 GestureDetector(),InkWell()

為了方便我們做了統一封裝 UCGesture() , 點選操作必須使用規範,保證了我們點選事件的入口統一 是否存在像IOS主軟的外掛需要單獨處理,flutter寫法檢視使用 UCGesture()不需要處理。

b.確定頁面路徑終點,一直向上遍歷找到APP入口層級,我們是否需要這麼長路徑確定唯一,答案是否定的 我們這麼做,增加一層state繼承AHContainerState,這就是我們一個小模組的路徑根檢視

abstract class UCSamoCommonState<T extends StatefulWidget> extends AHContainerState<T> 

讓我們的state在繼承自UCSamoCommonState ,這時候我們只需要找到 UCSamoCommonState 頁面即可,不需要遍歷到APP層級。

UCSamoCommonState statetHome = context.findAncestorStateOfType<UCSamoCommonState>();
String pagename = statetHome.widget.runtimeType.toString();

我們拿到了當前層級name,這就是我們ID標識的第一個節點,同時這個UCSamoCommonState也是作為我們遍歷的終止條件

因為我們在點選是在UCGesture()方法觸發,這裡的context就是我們遍歷的第一個節點

static List<Element> getElementPathList(Element element) {
List<Element> elementPathList = [];
elementPathList.add(element);
element.visitAncestorElements((element) {
if (_isLocalState(element)) {
 elementPathList.add(element);
 return false;
  } else {
    elementPathList.add(element);
  }
    return true;
  });
    return listreversed;
  }
}

通過上面的遞迴我們拿到了一個List ,下到上的物件,路徑要求相反,我們在listreversed下得到正向資料

下面是產生ID路徑的方法,利用他的層級_getIndex方法獲取他的index屬性確定層級

static String getPath(List list, String pagename) {
  var listResult = list;

  String finalResult = "";
  if (pagename?.length > 0) {
    finalResult = "${pagename}[0]";
  }

  listResult.forEach((ele) {
    finalResult += "/${ele.widget.runtimeType.toString()}";
    int slot = _getIndex(ele);
    if (slot >= 0) {
      finalResult += "[$slot]";
    }
    if (ele == listResult.last) {
      finalResult += "-[${ele.hashCode.toString()}]";
    }
  });

  if (finalResult.startsWith('/')) {
    finalResult = finalResult.replaceFirst('/', '');
  }
  return finalResult;
}

產生路徑舉例,flutter看過模組產生的路徑有這麼長

UCLookCardPageB[0]/Container[0]/Container[0]/Padding[0]/Padding[0]/Row[0]/Row[0]/Expanded[1]/Expanded[1]/Stack[1]/Stack[1]/Container[0]/Container[0]/ConstrainedBox[0]/ConstrainedBox[0]/ListView[0]/ListView[0]/Scrollable[0]/Scrollable[0]/_ScrollSemantics[0]/_ScrollSemantics[0]/_ScrollableScope[0]/_ScrollableScope[0]/Listener[0]/Listener[0]/RawGestureDetector[0]/RawGestureDetector[0]/_GestureSemantics[0]/_GestureSemantics[0]/Listener[0]/Listener[0]/Semantics[0]/Semantics[0]/IgnorePointer[0]/IgnorePointer[0]/Viewport[0]/Viewport[0]/SliverPadding[0]/SliverPadding[0]/MediaQuery[0]/MediaQuery[0]/SliverList[0]/SliverList[0]/KeyedSubtree[0]/KeyedSubtree[0]/AutomaticKeepAlive[0]/AutomaticKeepAlive[0]/KeepAlive[0]/KeepAlive[0]/NotificationListener<KeepAliveNotification>[0]/NotificationListener<KeepAliveNotification>[0]/IndexedSemantics[0]/IndexedSemantics[0]/RepaintBoundary[0]/RepaintBoundary[0]/Container[0]/Container[0]/ConstrainedBox[0]/ConstrainedBox[0]/Stack[0]/Stack[0]/Container[0]/Container[0]/Padding[0]/Padding[0]/ConstrainedBox[0]/ConstrainedBox[0]/UCGesture[0]-[1379]

2.關於ID路徑優化

路徑優化 :ID路徑過長,上面我們自定義的state已經做了優化,只找到我們元件名位置UCLookCardPageB,如果找到APP裡內容更長。

只關注關鍵路徑, 移除平臺差異產生的路徑,進一步優化去掉無關的路徑。

方法也很簡單隻需要遍歷時快取關鍵路徑即可

if (element is StatelessElement || element is StatefulElement) {
 elementPathList.add(element);
}
if (element.widget is Column || element.widget is Row ) {
 elementPathList.add(element);
}

Column和Row 是否也能省掉,答案是不能,這樣同一層級控制元件將無法區分

優化後路徑基本能滿足要求

UCLookCardPageB[0]/Container[0]/Row[0]/Container[0]/ListView[0]/Scrollable[0]/RawGestureDetector[0]/KeyedSubtree[0]/AutomaticKeepAlive[0]/NotificationListener<KeepAliveNotification>[0]/Container[0]/Container[0]/UCGesture[0]-[1268]
UCSelectedCarPageB[0]/Container[0]/Container[0]/Column[0]/Row[0]/UCGesture[0]-[994]

3.頁面名稱

按照上面模組名稱很容易 建立個 UCContainerState 當我們頁面名稱根節點

UCContainerState stateHome =
context.findAncestorStateOfType<UCContainerState>();
String pageViewId = stateHome.widget.runtimeType.toString();
return pageViewId;

4.元件位置座標

也是通過Element獲取方法如下

final RenderBox box = element.renderObject as RenderBox;
final size = box.size;
final offset = box.localToGlobal(Offset.zero);
Rect.fromLTWH(offset.dx, offset.dy, size.width, size.height);

按照螢幕左側頂點對應座標

5.遍歷自己頁面找到所有點選事件

a.先找到最上層UCContainerState,上面已經提到我們自定義的state,作為反向遍歷起點。

b.向下遍歷找到UCGesture為終止

static List<Element> getAllEvent(BuildContext context) {
List<Element> dataList = [];
UCContainerState stateHome =
context.findAncestorStateOfType<UCContainerState>();
final viewPageElement =findElementByType<InheritedElement>(stateHome.context);
viewPageElement.visitChildElements((element) {
 traverseAllElement(dataList, element);
});
 return dataList;
}

c.記錄所有的element,優化方案和上面相同,獲取路徑方法和上面相同。

d.獲取座標需要注意 if(offset.dx.isNaN || offset.dy.isNaN) 增加判斷的原因是發現未顯示在頁面的檢視還沒座標,直接使用會產生異常。

最後我們定義物件,生成一個如下陣列

{"left":151,"top":132,"width":72,"height":62,"viewid":"UCBusinessPageB[0]/Container[0]/Column[0]/Container[1]/Container[0]/Row[0]/UCGesture[2]-[905]","pageid":"HomePageB"}
{"left":223,"top":132,"width":72,"height":62,"viewid":"UCBusinessPageB[0]/Container[0]/Column[0]/Container[1]/Container[0]/Row[0]/UCGesture[3]-[932]","pageid":"HomePageB"}

基於外掛的特點我們整理出了一套自己可以用的方案,前期改動位置多,但好在我們頁面少,並沒有對業務有多大影響。原理上大概分享到這。

作者簡介

汽車之家

賈錫瑞

二手車事業部-技術部

加入汽車之家多年,一直從事研發工作,現負責二手車之家以及其他汽車之家二手車業務的相關研發工作。

閱讀更多:

▼ 關注「 之家技術 」,獲取更多技術乾貨