iOS 無侵入埋點組件總結
本文轉載自微信公眾號「網羅開發」,作者Perry_6。轉載本文請聯系網羅開發公眾號。
一. 埋點方案
代碼埋點
由開發人員在觸發事件的具體方法里,添加多行代碼把需要上傳的參數上報至服務端。
可視化埋點
根據標識來識別每一個事件, 針對指定的事件進行取參埋點。而事件的標識與參數信息都寫在配置表中,通過動態下發配置表來實現埋點統計。
無埋點
無埋點并不是不需要埋點,更準確的說應該是“全埋”, 前端的任意一個事件都被綁定一個標識,所有的事件都別記錄下來。通過定期上傳記錄文件,配合文件解析,解析出來我們想要的數據, 并生成可視化報告 , 因此實現“無埋點”統計。
二. 方案選擇
通常業務都需要加埋點統計事件,但在每個業務類里埋點會導致每個頁面內耦合了大量的無關業務的埋點代碼使得代碼不夠整潔,所以放棄了代碼埋點。
考慮到無埋點成本較高,后期解析也復雜,選擇了可視化埋點,即通過配置事件唯一標識,設置需要埋點分析的業務。
2.1 實現可視化埋點核心問題
- 封裝埋點組件,降低耦合
- 如何實現后臺配置唯一標識
- 埋點上報
2.2 針對第一個問題想到的方案如下:
- 每個業務頁面添加一個埋點類,單獨將埋點的方法提取到這個類中。
- 利用 Runtime 在底層進行方法攔截,從而添加埋點代碼。
結合AOP的核心思想:將應用程序中的業務邏輯同對其提供支持的通用服務進行分離,最后采用了第2種方案。
2.3 配置唯一標識問題
唯一標識的組成方式主要是又 target + action 來確定, 即任何一個事件都存在一個 target 與 action。在此引入 AOP 編程,AOP(Aspect-Oriented-Programming) 即面向切面編程的思想,基于 Runtime 的 Method Swizzling 能力,來 hook 相應的方法,從而在 hook 方法中進行統一的埋點處理。例如所有的按鈕被點擊時,都會觸發 UIApplication 的 sendAction 方法,我們 hook 這個方法,即可攔截所有按鈕的點擊事件。
2.3.1 唯一標識(viewPath)的獲取:
整個 APP 的視圖結構可以看成是一顆樹(viewTree),樹的根節點就是 UIWindow,樹的枝干由 UIViewController 及 UIView 組成,樹的葉節點都是由 UIView 組成。
那么在 viewTree 中用什么信息來表示其中任意一個 view 的位置呢?很容易想到的就是使用目標 view到根之間的每個節點的深度(層次)組成一個路徑,而節點的深度(層次)是指此節點在父節點中的 index。這樣確實能夠唯一的表示此 view 了,但是有一個缺點:它的可讀性很差。因此在此基礎上又增加了每個節點的名稱,節點的名稱由當前節點的 view 的類名來表示。同時在開頭都添加了一個頁面名稱作為標識。
因此,在 viewTree 中,由一個 view 到根節點之間的每個節點的名稱與深度(層次)共同組成的信息構成了此 view 的 viewPath。另外,由于在做 view 的統計分析時,都是以頁面為單位的,因此 SDK 在生成 viewPath 時,只到 view 所在的 UIViewController 級別,而非根部的 UIWindow。這樣做也在一定程度上減少了 viewPath 的長度。
UITableView 和 UICollectionView 的樹級關系沒有到每個具體的 cell,避免產生很多無用的 id,而是將 indexpath 作為描述信息傳入。實現邏輯如下圖:
2.3.4 唯一標識的作用主要分為兩個部分
- 事件的鎖定
事件的鎖定主要是靠 “事件唯一標識符”來鎖定,而事件的唯一標識是由我們寫入配置表中的。
- 埋點數據的上報。
埋點數據的數據又分為兩種類型: 固定數據與可變的業務數據, 而固定數據我們可以直接寫到配置表中, 通過唯一標識來獲取。而對于業務數據,數據是有持有者的, 例如我們 Controller 的一個屬性值, 或者數據在 Model 的某一個層級。就可以通過 KVC 的的方式來遞歸獲取該屬性的值來取到業務數據。
2.4 埋點上報
自定義埋點上報數據類型,上報到 elastic,后臺進行數據分析
三. 實現部分
3.1 SDK 架構
3.2 技術原理
3.2.1 Method-Swizzling
OC 中的方法調用其實是向一個對象發送消息 ,利用 OC 的動態性可以實現方法的交換。
- 用 method_exchangeImplementations 方法來交換兩個方法中的IMP
- 用 class_replaceMethod 方法來替換類的方法,
- 用 method_setImplementation 方法來直接設置某個方法的 IMP
3.2.2 Target-Action
按鈕的點擊事件,UIControl 會調用 sendAction:to:forEvent: 來將行為消息轉發到 UIApplication,再由 UIApplication 調用其 sendAction:to:fromSender:forEvent: 方法來將消息分發到指定的 target 上。
3.3 分析及實現
3.3.1 需要添加埋點統計的地方
- button 相關的點擊事件
- 頁面進入、頁面推出
- tableView 的點擊
- collectionView 的點擊
- 手勢相關事件
3.3.2 分析
- 對于用戶交互的操作,我們使用 runtime 對應的方法 hook 下 sendAction:to:forEvent: 便可以得到進行的交互操作。這個方法對 UIControl 及繼承 UIControl 的子類對象有效,如:UIButton、UISlider 等。
- 對于 UIViewController,hook 下 ViewDidAppear: 這個方法知道哪個頁面顯示了就足夠了。
- 對于 tableview 及 collectionview,我們 hook下setDelegate: 方法。檢測其有沒有實現對應的點擊代理,因為 tableView:didSelectRowAtIndexPath: 及 collectionView:didSelectItemAtIndexPath: 是 option 的不是必須要實現的。
- 對于手勢,我們在創建的時候進行 hook,方法為 initWithTarget:action:。
3.3.3 實現原理
用運行時方法替換方法實現無侵入的埋點方法。
實現原理圖:
具體實現方法:
創建一個運行時方法替換類 HGMethodSwizzingTool,實現替換的方法 `swizzingForClass: originalSel: swizzingSel:``
- #import "LZMethodSwizzingTool.h"
- #import <objc/runtime.h>
- @implementation LZMethodSwizzingTool
- + (void)swizzingForClass:(Class)cls originalSel:(SEL)originalSelector swizzingSel:(SEL)swizzingSelector {
- Class class = cls;
- Method originalMethod = class_getInstanceMethod(class, originalSelector);
- Method swizzingMethod = class_getInstanceMethod(class, swizzingSelector);
- BOOL addMethod = class_addMethod(class, originalSelector, method_getImplementation(swizzingMethod), method_getTypeEncoding(swizzingMethod));
- if (addMethod) {
- class_replaceMethod(class, swizzingSelector, method_getImplementation(originalMethod), method_getTypeEncoding(originalMethod));
- } else {
- method_exchangeImplementations(originalMethod, swizzingMethod);
- }
- }
- @end
這個方法利用運行時 method_exchangeImplementations 進行交換,當原方法被調用時,就會 hook 到指定的新方法去執行。
3.3.4 埋點分類實現
1. UIViewController+Track(頁面進入、頁面推出)
- @implementation UIViewController (Track)
- + (void)initialize {
- static dispatch_once_t onceToken;
- dispatch_once(&onceToken, ^{
- SEL originalWillAppearSelector = @selector(viewWillAppear:);
- SEL swizzingWillAppearSelector = @selector(hg_viewWillAppear:);
- [LZMethodSwizzingTool swizzingForClass:[self class] originalSel:originalWillAppearSelector swizzingSel:swizzingWillAppearSelector];
- SEL originalWillDisappearSel = @selector(viewWillDisappear:);
- SEL swizzingWillDisappearSel = @selector(hg_viewWillDisappear:);
- [LZMethodSwizzingTool swizzingForClass:[self class] originalSel:originalWillDisappearSel swizzingSel:swizzingWillDisappearSel];
- SEL originalDidLoadSel = @selector(viewDidLoad);
- SEL swizzingDidLoadSel = @selector(hg_viewDidLoad);
- [LZMethodSwizzingTool swizzingForClass:[self class] originalSel:originalDidLoadSel swizzingSel:swizzingDidLoadSel];
- });
- }
- - (void)hg_viewWillAppear:(BOOL)animated {
- [self hg_viewWillAppear:animated];
- //埋點實現區域
- [self dataTrack:@"viewWillAppear"];
- }
- - (void)hg_viewWillDisappear:(BOOL)animated {
- [self hg_viewWillDisappear:animated];
- //埋點實現區域
- [self dataTrack:@"viewWillDisappear"];
- }
- - (void)hg_viewDidLoad {
- [self hg_viewDidLoad];
- //埋點實現區域
- [self dataTrack:@"viewDidLoad"];
- }
- - (void)dataTrack:(NSString *)methodName {
- NSString *identifier = [NSString stringWithFormat:@"%@/%@",[[LZFindVCManager currentViewController] class],methodName];
- NSDictionary *eventDict = [[[LZDataTrackTool shareInstance].trackData objectForKey:@"ViewController"] objectForKey:identifier];
- if (eventDict) {
- NSDictionary *useDefind = [eventDict objectForKey:@"userDefined"];
- //預留參數配置,以后拓展
- NSDictionary *param = [eventDict objectForKey:@"eventParam"];
- __block NSMutableDictionary *eventParam = [NSMutableDictionary dictionaryWithCapacity:0];
- [param enumerateKeysAndObjectsUsingBlock:^(id _Nonnull key, id _Nonnull obj, BOOL * _Nonnull stop) {
- //在此處進行屬性獲取
- id value = [LZCaptureTool captureVarforInstance:self varName:key];
- if (key && value) {
- [eventParam setObject:value forKey:key];
- }
- }];
- if (eventParam.count) {
- NSLog(@"identifier:%@-------useDefind:%@----eventParam:%@",identifier,useDefind,eventParam);
- }
- }
- }
- @end
Category 在 +openTrackSelector() 方法里使用了 HGMethodSwizzingTool 進行方法替換,在替換的方法里執行需要埋點的方法 - (void)dataTrack:(NSString *)methodName 實現埋點。這樣每個 UIViewController 生命周期到了 ViewWillAppear 都會執行埋點的方法。
在這里,我們是通過類名 NSStringFromClass([self class]) 來區分不同的控制器的。
2. UIControl+Track(button相關的點擊事件)
- @implementation UIControl (Track)
- + (void)initialize {
- static dispatch_once_t onceToken;
- dispatch_once(&onceToken, ^{
- SEL originalSelector = @selector(sendAction:to:forEvent:);
- SEL swizzingSelector = @selector(hg_sendAction:to:forEvent:);
- [LZMethodSwizzingTool swizzingForClass:[self class] originalSel:originalSelector swizzingSel:swizzingSelector];
- });
- }
- - (void)hg_sendAction:(SEL)action to:(id)target forEvent:(UIEvent *)event {
- [self hg_sendAction:action to:target forEvent:event];
- //埋點實現區域====
- //頁面/方法名/tag用來區分不同的點擊事件
- NSString *identifier = [NSString stringWithFormat:@"%@/%@/%@", [target class], NSStringFromSelector(action),@(self.tag)];
- if ([target isKindOfClass:[UIView class]]) {
- UIView *view = (id)[target superview];
- while (view.nextResponder) {
- identifier =[NSString stringWithFormat:@"%@/%@",NSStringFromClass(view.class),identifier];
- if ([view.class isSubclassOfClass:[UIViewController class]]) {
- break;
- }
- view = (id)view.nextResponder;
- }
- }
- NSDictionary *eventDict = [[[LZDataTrackTool shareInstance].trackData objectForKey:@"Action"] objectForKey:identifier];
- if (eventDict) {
- NSDictionary *useDefind = [eventDict objectForKey:@"userDefined"];
- //預留參數配置,以后拓展
- NSDictionary *param = [eventDict objectForKey:@"eventParam"];
- __block NSMutableDictionary *eventParam = [NSMutableDictionary dictionaryWithCapacity:0];
- [param enumerateKeysAndObjectsUsingBlock:^(id _Nonnull key, id _Nonnull obj, BOOL * _Nonnull stop) {
- //在此處進行屬性獲取
- id value = [LZCaptureTool captureVarforInstance:target varName:key];
- if (key && value) {
- [eventParam setObject:value forKey:key];
- }
- }];
- NSLog(@"useDefind:%@----eventParam:%@",useDefind,eventParam);
- }
- }
- // UIView 分類
- - (NSString *)obtainSameSuperViewSameClassViewTreeIndexPat
- {
- NSString *classStr = NSStringFromClass([self class]);
- //cell的子view
- //UITableView 特殊的superview (UITableViewContentView)
- //UICollectionViewCell
- BOOL shouldUseSuperView =
- ([classStr isEqualToString:@"UITableViewCellContentView"]) ||
- ([[self.superview class] isKindOfClass:[UITableViewCell class]])||
- ([[self.superview class] isKindOfClass:[UICollectionViewCell class]]);
- if (shouldUseSuperView) {
- return [self obtainIndexPathByView:self.superview];
- }else {
- return [self obtainIndexPathByView:self];
- }
- }
- - (NSString *)obtainIndexPathByView:(UIView *)view
- {
- NSInteger viewTreeNodeDepth = NSIntegerMin;
- NSInteger sameViewTreeNodeDepth = NSIntegerMin;
- NSString *classStr = NSStringFromClass([view class]);
- NSMutableArray *sameClassArr = [[NSMutableArray alloc]init];
- //所處父view的全部subviews根節點深度
- for (NSInteger index =0; index < view.superview.subviews.count; index ++) {
- //同類型
- if ([classStr isEqualToString:NSStringFromClass([view.superview.subviews[index] class])]){
- [sameClassArr addObject:view.superview.subviews[index]];
- }
- if (view == view.superview.subviews[index]) {
- viewTreeNodeDepth = index;
- break;
- }
- }
- //所處父view的同類型subviews根節點深度
- for (NSInteger index =0; index < sameClassArr.count; index ++) {
- if (view == sameClassArr[index]) {
- sameViewTreeNodeDepth = index;
- break;
- }
- }
- return [NSString stringWithFormat:@"%ld",sameViewTreeNodeDepth];
- }
- @end
找到點擊事件的方法 sendAction:to:forEvent:,然后在 +openTrackSelector() 方法里使用 HGMethodSwizzingTool 替換新的方法。
和 UIViewController 生命周期埋點不同的是,一個類中可能有許多不同的 UIButton 子類,相同的 UIButton 子類在不同的視圖中的埋點也要區分出來,所以我們通過 NSStringFromClass([target class]) + NSStringFromSelector(action) 來區別,即類名加方法名的格式作為唯一標識。
tableView、collectionView、手勢的點擊事件與上述實現方法類似。
3.3.5 埋點配置文件
埋點配置文件通過唯一標識鎖定事件,可以使用 json 文件或 plist 文件,Demo 里就隨便寫了一些測試數據,LZDataTrack.json 是直接放在了項目資源里,實際項目是通過 API 從服務器下載的配置文件,以實現實時更新埋點配置。
測試 json 文件:
- {
- "Gesture":{
- "RootViewController/gestureclicked:":{
- "userDefined": {
- "action": "click",
- "pageid": "1234",
- "pageName": "首頁",
- "eventName":"點擊手勢"
- },
- "eventParam":{
- "spm":"a-b-c-spm",
- "pageName":"",
- "tips":""
- }
- }
- },
- "ViewController":{
- "RootViewController/viewWillAppear":{
- "userDefined": {
- "action": "show",
- "pageid": "1234",
- "pageName": "首頁",
- "eventName":"首頁展示"
- },
- "eventParam":{
- "spm":"",
- "pageName":"",
- "tips":""
- }
- },
- "SecondViewController/viewWillAppear":{
- "userDefined": {
- "action": "show",
- "pageid": "1235",
- "pageName": "靈感頁",
- "eventName":"靈感頁展示"
- },
- "eventParam":{
- "spm":"",
- "pageName":"",
- "tips":""
- }
- }
- },
- "CollectionView":{
- "ThirdViewController/0":{
- "viewcontroller":true,
- "userDefined": {
- "action": "click",
- "pageid": "12345",
- "pageName": "靈感頁",
- "eventName":"點擊collectionview"
- },
- "eventParam":{
- "spm":"a-b-c-spm",
- "pageName":"",
- "tips":""
- }
- }
- },
- "TableView":{
- "SecondViewController/0":{
- "viewcontroller":true,
- "userDefined": {
- "action": "click",
- "pageid": "12345",
- "pageName": "靈感頁",
- "eventName":"點擊tableview"
- },
- "eventParam":{
- "spm":"a-b-c-spm",
- "pageName":"",
- "tips":""
- }
- }
- },
- "Action":{
- "RootViewController/testButtonClick:/0":{
- "userDefined": {
- "action": "click",
- "pageid": "1234",
- "pageName": "首頁",
- "eventName":"點擊測試按鈕"
- },
- "eventParam":{
- "spm":"a-b-c-spm",
- "pageName":"",
- "tips":""
- }
- },
- "SecondViewController/UIView/UITableView/TableViewCell/testButtonClick:/0":{
- "userDefined": {
- "action": "click",
- "pageid": "1234",
- "pageName": "靈感",
- "eventName":"cell里的點擊測試按鈕"
- },
- "eventParam":{
- "spm":"a-b-c-spm",
- "pageName":"",
- "tips":""
- }
- }
- }
- }
總結
使用運行時方法的替換實現了無侵入埋點,但仍存在很多問題,比如唯一標識難以維護、準確性有待驗證。目前的方式只能實現頁面進、出以及點擊事件的埋點統計,涉及到具體業務的埋點統計,比如開機啟動、需要上報參數信息等類型的埋點還是要依賴代碼埋點。所以無侵入埋點方案還有很大優化空間。