iOS無侵入的埋點方案如何實現?

2020-04-29     AiChinaTech

在開發過程中,埋點可以解決兩大類問題:一是了解用戶使用 App 的行為,二是降低分析線上問題的難度。目前,iOS 開發中常見的埋點方式,主要包括:

· 代碼埋點

· 可視化埋點

· 無埋點

代碼埋點

代碼埋點主要就是通過手寫代碼的方式來埋點,能很精確的在需要埋點的代碼處加上埋點的代碼,可以很方便地記錄當前環境的變量值,方便調試,並跟蹤埋點內容,但存在開發工作量大,並且埋點代碼到處都是,後期難以維護等問題。

缺點:

1. 顯而易見,你會在後期維護的時候寫的懷疑人生

2. 復用性差,幾乎不能移植給其他項目

3. 工作量大,而且會越寫越多

4. 統計代碼上線之後,如果出現問題,只能後續版本疊代

5. 如果統計項目名字改變了,原來老的APP版本依舊會統計老的頁面名字

優點:

1. 如果非要寫一個其他統計無法做到的優點的話,那就是可自定義程度高吧,統計代碼想寫到那裡寫到那裡(其實這些也可以在後面的方案實現,只是實現上稍微麻煩一點罷了)

2. 最容易想到的方案(前期費時少,使用起來費手不費思路)

可視化埋點

就是將埋點增加和修改的工作可視化了,提升了增加和維護埋點的體驗。

該方案的具體步驟就是:

1. 從後台獲取需要統計的地方

2. hook住需要統計的類的load方法來Method Swizzing要統計的方法

3. 上傳統計到的事件給後台分析

用UIViewController、UIControl為例子,講解一下該方案的思路。

UIViewController PV統計,頁面的統計較為簡單,利用Method Swizzing hook 系統的viewDidLoad, 直接通過頁面名稱即可鎖定頁面的展示代碼如下:

UIControl 點擊統計,主要通過hook sendAction:to:forEvent: 來實現, 其唯一標識符我們用 targetname/selector/tag來標記,具體代碼如下:

缺點:

1. 需要後台配合

2. 可拓展性不是很高,因為需要修改後台下發的統計內容來每次的版本統計擴展

優點:

1. 相對於第一種方案,代碼量少了很多。

2. 動態化從後台獲取統計內容,方便線上修改

無埋點

無埋點,並不是不需要埋點,而更確切地說是「全埋點」,而且埋點代碼不會出現在業務代碼中,容易管理和維護。它的缺點在於,埋點成本高,後期的解析也比較複雜,再加上 view_path 的不確定性。所以,這種方案並不能解決所有的埋點需求,但對於大量通用的埋點需求來說,能夠節省大量的開發和維護成本。

在這其中,可視化埋點和無埋點,都屬於是無侵入的埋點方案,因為它們都不需要在工程代碼中寫入埋點代碼。所以,採用這樣的無侵入埋點方案,既可以做到埋點被統一維護,又可以實現和工程代碼的解耦。

接下來,我們就通過今天這篇文章,一起來分析一下無侵入埋點方案的實現問題吧。

運行時方法替換方式進行埋點

我們都知道,在 iOS 開發中最常見的三種埋點,就是對頁面進入次數、頁面停留時間、點擊事件的埋點。對於這三種常見情況,我們都可以通過運行時方法替換技術來插入埋點代碼,以實現無侵入的埋點方法。具體的實現方法是:先寫一個運行時方法替換的類 ViewHook,加上替換的方法 hookClass:fromSelector:toSelector,代碼如下:

#import "ViewHook.h"

#import

@implementation ViewHook

+ (void)hookClass:(Class)classObject fromSelector:(SEL)fromSelector toSelector:(SEL)toSelector {

Class class = classObject;

// 得到被替換類的實例方法

Method fromMethod = class_getInstanceMethod(class, fromSelector);

// 得到替換類的實例方法

Method toMethod = class_getInstanceMethod(class, toSelector);

// class_addMethod 返回成功表示被替換的方法沒實現,然後會通過 class_addMethod 方法先實現;返回失敗則表示被替換方法已存在,可以直接進行 IMP 指針交換

if(class_addMethod(class, fromSelector, method_getImplementation(toMethod), method_getTypeEncoding(toMethod))) {

// 進行方法的替換

class_replaceMethod(class, toSelector, method_getImplementation(fromMethod), method_getTypeEncoding(fromMethod));

} else {

// 交換 IMP 指針

method_exchangeImplementations(fromMethod, toMethod);

}

}

@end

這個方法利用運行時method_exchangeImplementations 接口將方法的實現進行了交換,原方法調用時就會被hook 住,從而去執行指定的方法。

頁面進入次數、頁面停留時間都需要對 UIViewController 生命周期進行埋點,你可以創建一個 UIViewController 的 Category,代碼如下:

@implementation UIViewController (logger)

+ (void)load {

static dispatch_once_t onceToken;

dispatch_once(&onceToken, ^{

// 通過 @selector 獲得被替換和替換方法的 SEL,作為 ViewHook:hookClass:fromeSelector:toSelector 的參數傳入

SEL fromSelectorAppear = @selector(viewWillAppear:);

SEL toSelectorAppear = @selector(hook_viewWillAppear:);

[ViewHook hookClass:self fromSelector:fromSelectorAppear toSelector:toSelectorAppear];

SEL fromSelectorDisappear = @selector(viewWillDisappear:);

SEL toSelectorDisappear = @selector(hook_viewWillDisappear:);

[ViewHook hookClass:self fromSelector:fromSelectorDisappear toSelector:toSelectorDisappear];

});

}

- (void)hook_viewWillAppear:(BOOL)animated {

// 先執行插入代碼,再執行原 viewWillAppear 方法

[self insertToViewWillAppear];

[self hook_viewWillAppear:animated];

}

- (void)hook_viewWillDisappear:(BOOL)animated {

// 執行插入代碼,再執行原 viewWillDisappear 方法

[self insertToViewWillDisappear];

[self hook_viewWillDisappear:animated];

}

- (void)insertToViewWillAppear {

// 在 ViewWillAppear 時進行日誌的埋點

[[[[SMLogger create]

message:[NSString stringWithFormat:@"%@ Appear",NSStringFromClass([self class])]]

classify:ProjectClassifyOperation]

save];

}

- (void)insertToViewWillDisappear {

// 在 ViewWillDisappear 時進行日誌的埋點

[[[[SMLogger create]

message:[NSString stringWithFormat:@"%@ Disappear",NSStringFromClass([self class])]]

classify:ProjectClassifyOperation]

save];

}

@end

可以看到,Category 在+load() 方法裡使用了 ViewHook 進行方法替換,在替換的方法裡執行需要埋點的方法 [self insertToViewWillAppear]。 這樣的話,每個UIViewController生命周期到了ViewWillAppear 時都會去執行insertToViewWillAppear 方法。

那麼,我們要怎麼區別不同的 UIViewController 呢?我一般採取的做法都是,使用NSStringFromClass([self class]) 方法來取類名。這樣,我就能夠通過類名來區別不同的 UIViewController 了。

對於點擊事件來說,我們也可以通過運行時方法替換的方式進行無侵入埋點。這裡最主要的工作是,找到這個點擊事件的方法 sendAction:to:forEvent:,然後在 +load() 方法使用 ViewHook 替換成為你定義的方法。完整代碼實現如下:

+ (void)load {

static dispatch_once_t onceToken;

dispatch_once(&onceToken, ^{

// 通過 @selector 獲得被替換和替換方法的 SEL,作為 ViewHook:hookClass:fromeSelector:toSelector 的參數傳入

SEL fromSelector = @selector(sendAction:to:forEvent:);

SEL toSelector = @selector(hook_sendAction:to:forEvent:);

[ViewHook hookClass:self fromSelector:fromSelector toSelector:toSelector];

});

}

- (void)hook_sendAction:(SEL)action to:(id)target forEvent:(UIEvent *)event {

[self insertToSendAction:action to:target forEvent:event];

[self hook_sendAction:action to:target forEvent:event];

}

- (void)insertToSendAction:(SEL)action to:(id)target forEvent:(UIEvent *)event {

// 日誌記錄

if ([[[event allTouches] anyObject] phase] == UITouchPhaseEnded) {

NSString *actionString = NSStringFromSelector(action);

NSString *targetName = NSStringFromClass([target class]);

[[[SMLogger create] message:[NSString stringWithFormat:@"%@ %@",targetName,actionString]] save];

}

}

和 UIViewController 生命周期埋點不同的是,UIButton 在一個視圖類中可能有多個不同的繼承類,相同 UIButton 的子類在不同視圖類的埋點也要區別開。所以,我們需要通過 「action 選擇器名NSStringFromSelector(action)」 +「視圖類名 NSStringFromClass([target class])」組合成一個唯一的標識,來進行埋點記錄。

除了 UIViewController、UIButton 控制項以外,Cocoa 框架的其他控制項都可以使用這種方法來進行無侵入埋點。以 Cocoa 框架中最複雜的 UITableView 控制項為例,你可以使用 hook setDelegate 方法來實現無侵入埋點。另外,對於 Cocoa 框架中的手勢事件(Gesture Event),我們也可以通過 hook initWithTarget:action: 方法來實現無侵入埋點。

事件唯一標識

通過運行時方法替換的方式,我們能夠 hook 住所有的 Objective-C 方法,可以說是大而全了,能夠幫助我們解決絕大部分的埋點問題。

但是,這種方案的精確度還不夠高,還無法區分相同類在不同視圖樹節點的情況。比如,一個視圖下相同 UIButton 的不同實例,僅僅通過 「action 選擇器名」+「視圖類名」的組合還不能夠區分開。這時,我們就需要有一個唯一標識來區分不同的事件。接下來,我就跟你說說如何制定出這個唯一標識。

這時,我首先想到的就是,能不能通過視圖層級的路徑來解決這個問題。因為每個頁面都有一個視圖樹結構,通過視圖的 superview 和 subviews 的屬性,我們就能夠還原出每個頁面的視圖樹。視圖樹的頂層是 UIWindow,每個視圖都在樹的子節點上。如下圖所示:

iOS無侵入的埋點方案如何實現?

一個視圖下的子節點可能是同一個視圖的不同實例,比如上圖中 UIView 視圖節點下的兩個 UIButton 是同一個類的不同實例,所以光靠視圖樹的路徑還是沒法唯一確定出視圖的標識。那麼,這種情況下,我們又應該如何區別不同的視圖呢?

這時,我們想到了索引:每個子視圖在父視圖中都會有自己的索引,所以如果我們再加上這個索引的話,每個視圖的標識就是唯一的了

接下來的一個問題是,視圖層級路徑加上在父視圖中的索引來進行唯一標識,是不是就能夠涵蓋所有情況了呢?

當然不是。我們還需要考慮類似 UITableViewCell 這種具有可復用機制的視圖,Cell 會在頁面滾動時不斷復用,所以加索引的方式還是沒法用。

但這個問題也並不是無解的。UITableViewCell 需要使用 indexPath,這個值里包含了 section 和 row 的值。所以,我們可以通過 indexPath 來確定每個 Cell 的唯一性。

除了 UITableViewCell 這種情況之外, UIAlertController 也比較特殊。它的特殊性在於視圖層級的不固定,因為它可能出現在任何頁面中。但是,我們都知道它的功能區分往往通過彈窗內容來決定,所以可以通過內容來確定它的唯一標識。

除此之外,還有更多需要特殊處理的情況,但我們總是可以通過一些辦法去確定它們的唯一性,所以我在這裡也就不再一一列舉了。思路上來說就是,想辦法找出元素間不相同的因素然後進行組合,最後形成一個能夠區別於其他元素的標識來。

除了上面提到的這些特殊情況外,還有一種情況使得我們也難以得到準確的唯一標識。如果視圖層級在運行時會被更改,比如執行 insertSubView:atIndex:、removeFromSuperView 等方法時,我們也無法得到唯一標識,即使只截取部分路徑也無法保證後期代碼更新時不會動到這個部分。就算是運行時視圖層級不會修改,以後需求疊代頁面更新頻繁的話,視圖唯一標識也需要同步的更新維護。

這種問題就不好解決了,事件唯一標識的準確性難以保障,這也是通過運行時方法替換進行無侵入埋點很難在各個公司全面鋪開的原因。雖然無侵入埋點無法覆蓋到所有情況,全面鋪開面臨挑戰,但是無侵入埋點還是解決了大部分的埋點需求,也節省了大量的人力成本。

最好的方案永遠是針對於不同的場景來說的,我們不可能在一個創業團隊一開始就選擇方案3的架構,所以對於你來說,你要自己抉擇目前而言對你最好的方案,如果你沒有後台業務同學的支持,方案1也許對你來說真的是最好的方案了,起碼是可以完成統計需求,雖然苦點累點。但是在合適的時間,切換不同的選擇,才是成長的體現,還是最開始的話,如果你在的團隊,已經給你了資源和時間去完善埋點這個模塊,如果你把它做的更好,那一定是一件很酷的事情。

參考資料

1. 網易HubbleData無痕埋點SDK實現

2. iOS無埋點數據SDK實踐之路

3. 美團前端無痕埋點方案

4. 微信讀書團隊Aspects的基本原理

5. iOS打點雜談

文章來源: https://twgreatdaily.com/zh-cn/Irgd4XEBfGB4SiUwxREn.html