iOS無侵入的埋點(diǎn)方案如何實(shí)現(xiàn)?
在開發(fā)過程中,埋點(diǎn)可以解決兩大類問題:一是了解用戶使用 App 的行為,二是降低分析線上問題的難度。目前,iOS 開發(fā)中常見的埋點(diǎn)方式,主要包括:
- 代碼埋點(diǎn)
- 可視化埋點(diǎn)
- 無埋點(diǎn)
代碼埋點(diǎn)
代碼埋點(diǎn)主要就是通過手寫代碼的方式來埋點(diǎn),能很精確的在需要埋點(diǎn)的代碼處加上埋點(diǎn)的代碼,可以很方便地記錄當(dāng)前環(huán)境的變量值,方便調(diào)試,并跟蹤埋點(diǎn)內(nèi)容,但存在開發(fā)工作量大,并且埋點(diǎn)代碼到處都是,后期難以維護(hù)等問題。
缺點(diǎn):
1. 顯而易見,你會(huì)在后期維護(hù)的時(shí)候?qū)懙膽岩扇松?/p>
2. 復(fù)用性差,幾乎不能移植給其他項(xiàng)目
3. 工作量大,而且會(huì)越寫越多
4. 統(tǒng)計(jì)代碼上線之后,如果出現(xiàn)問題,只能后續(xù)版本迭代
5. 如果統(tǒng)計(jì)項(xiàng)目名字改變了,原來老的APP版本依舊會(huì)統(tǒng)計(jì)老的頁面名字
優(yōu)點(diǎn):
1. 如果非要寫一個(gè)其他統(tǒng)計(jì)無法做到的優(yōu)點(diǎn)的話,那就是可自定義程度高吧,統(tǒng)計(jì)代碼想寫到那里寫到那里(其實(shí)這些也可以在后面的方案實(shí)現(xiàn),只是實(shí)現(xiàn)上稍微麻煩一點(diǎn)罷了)
2. 最容易想到的方案(前期費(fèi)時(shí)少,使用起來費(fèi)手不費(fèi)思路)
可視化埋點(diǎn)
就是將埋點(diǎn)增加和修改的工作可視化了,提升了增加和維護(hù)埋點(diǎn)的體驗(yàn)。
該方案的具體步驟就是:
1. 從后臺(tái)獲取需要統(tǒng)計(jì)的地方
2. hook住需要統(tǒng)計(jì)的類的load方法來Method Swizzing要統(tǒng)計(jì)的方法
3. 上傳統(tǒng)計(jì)到的事件給后臺(tái)分析
用UIViewController、UIControl為例子,講解一下該方案的思路。
UIViewController PV統(tǒng)計(jì),頁面的統(tǒng)計(jì)較為簡(jiǎn)單,利用Method Swizzing hook 系統(tǒng)的viewDidLoad, 直接通過頁面名稱即可鎖定頁面的展示代碼如下:
- +(void)load {
- static dispatch_once_t onceToken;
- dispatch_once(&onceToken, ^{
- SEL originalDidLoadSelector = @selector(viewDidLoad);
- SEL swizzingDidLoadSelector = @selector(analytic_viewDidLoad);
- [MethodSwizzingTool swizzingForClass:[self class] originalSel:originalDidLoadSelector swizzingSel:swizzingDidLoadSelector];
- });
- }
- -(void)analytic_viewDidLoad {
- [self analytic_viewDidLoad];
- //用當(dāng)前類的類名作為統(tǒng)計(jì)頁面的標(biāo)識(shí)符
- NSString * identifier = [NSString stringWithFormat:@"%@", [self class]];
- //通過當(dāng)前類名獲取PAGEPV表內(nèi)的對(duì)應(yīng)的頁面的pageid和pagename
- NSDictionary * dic = [[[AnalyticTool shareInstance].data objectForKey:@"PAGE"] objectForKey:identifier];
- if (dic) {
- NSString * pageid = dic[@"screenData"][@"pageid"];
- NSString * pagename = dic[@"screenData"][@"pagename"];
- [AnalyticTool upLoadScreenName:pagename withScreenID:pageid];
- }
- }
UIControl 點(diǎn)擊統(tǒng)計(jì),主要通過hook sendAction:to:forEvent: 來實(shí)現(xiàn), 其唯一標(biāo)識(shí)符我們用 targetname/selector/tag來標(biāo)記,具體代碼如下:
- +(void)load
- {
- static dispatch_once_t onceToken;
- dispatch_once(&onceToken, ^{
- SEL originalSelector = @selector(sendAction:to:forEvent:);
- SEL swizzingSelector = @selector(analytic_sendAction:to:forEvent:);
- [MethodSwizzingTool swizzingForClass:[self class] originalSel:originalSelector swizzingSel:swizzingSelector];
- });
- }
- -(void)analytic_sendAction:(SEL)action to:(id)target forEvent:(UIEvent *)event
- {
- [self analytic_sendAction:action to:target forEvent:event];
- NSString * identifier = [NSString stringWithFormat:@"%@/%@/%ld", [target class],
- NSStringFromSelector(action),self.tag]; NSDictionary * dic = [[[AnalyticTool shareInstance].data objectForKey:@"ACTION"] objectForKey:identifier];
- if (dic) {
- NSString * eventid = dic[@"ActionData"][@"eventid"];
- NSString * targetname = dic[@"ActionData"][@"target"];
- NSString * pageid = dic[@"ActionData"][@"pageid"];
- NSString * pagename = dic[@"ActionData"][@"pagename"];
- [AnalyticTool upLoadActionEventWithScreenName:pagename withScreenID:pageid withTargetName:targetname withEventID:eventid];
- }
- }
缺點(diǎn):
1. 需要后臺(tái)配合
2. 可拓展性不是很高,因?yàn)樾枰薷暮笈_(tái)下發(fā)的統(tǒng)計(jì)內(nèi)容來做每次的版本統(tǒng)計(jì)擴(kuò)展
優(yōu)點(diǎn):
1. 相對(duì)于第一種方案,代碼量少了很多。
2. 動(dòng)態(tài)化從后臺(tái)獲取統(tǒng)計(jì)內(nèi)容,方便線上修改
無埋點(diǎn)
無埋點(diǎn),并不是不需要埋點(diǎn),而更確切地說是“全埋點(diǎn)”,而且埋點(diǎn)代碼不會(huì)出現(xiàn)在業(yè)務(wù)代碼中,容易管理和維護(hù)。它的缺點(diǎn)在于,埋點(diǎn)成本高,后期的解析也比較復(fù)雜,再加上 view_path 的不確定性。所以,這種方案并不能解決所有的埋點(diǎn)需求,但對(duì)于大量通用的埋點(diǎn)需求來說,能夠節(jié)省大量的開發(fā)和維護(hù)成本。
在這其中,可視化埋點(diǎn)和無埋點(diǎn),都屬于是無侵入的埋點(diǎn)方案,因?yàn)樗鼈兌疾恍枰诠こ檀a中寫入埋點(diǎn)代碼。所以,采用這樣的無侵入埋點(diǎn)方案,既可以做到埋點(diǎn)被統(tǒng)一維護(hù),又可以實(shí)現(xiàn)和工程代碼的解耦。
接下來,我們就通過今天這篇文章,一起來分析一下無侵入埋點(diǎn)方案的實(shí)現(xiàn)問題吧。
運(yùn)行時(shí)方法替換方式進(jìn)行埋點(diǎn)
我們都知道,在 iOS 開發(fā)中最常見的三種埋點(diǎn),就是對(duì)頁面進(jìn)入次數(shù)、頁面停留時(shí)間、點(diǎn)擊事件的埋點(diǎn)。對(duì)于這三種常見情況,我們都可以通過運(yùn)行時(shí)方法替換技術(shù)來插入埋點(diǎn)代碼,以實(shí)現(xiàn)無侵入的埋點(diǎn)方法。具體的實(shí)現(xiàn)方法是:先寫一個(gè)運(yùn)行時(shí)方法替換的類 ViewHook,加上替換的方法 hookClass:fromSelector:toSelector,代碼如下:
- #import "ViewHook.h"
- #import <objc/runtime.h>
- @implementation ViewHook
- + (void)hookClass:(Class)classObject fromSelector:(SEL)fromSelector toSelector:(SEL)toSelector {
- Class class = classObject;
- // 得到被替換類的實(shí)例方法
- Method fromMethod = class_getInstanceMethod(class, fromSelector);
- // 得到替換類的實(shí)例方法
- Method toMethod = class_getInstanceMethod(class, toSelector);
- // class_addMethod 返回成功表示被替換的方法沒實(shí)現(xiàn),然后會(huì)通過 class_addMethod 方法先實(shí)現(xiàn);返回失敗則表示被替換方法已存在,可以直接進(jìn)行 IMP 指針交換
- if(class_addMethod(class, fromSelector, method_getImplementation(toMethod), method_getTypeEncoding(toMethod))) {
- // 進(jìn)行方法的替換
- class_replaceMethod(class, toSelector, method_getImplementation(fromMethod), method_getTypeEncoding(fromMethod));
- } else {
- // 交換 IMP 指針
- method_exchangeImplementations(fromMethod, toMethod);
- }
- }
- @end
這個(gè)方法利用運(yùn)行時(shí)method_exchangeImplementations 接口將方法的實(shí)現(xiàn)進(jìn)行了交換,原方法調(diào)用時(shí)就會(huì)被hook 住,從而去執(zhí)行指定的方法。
頁面進(jìn)入次數(shù)、頁面停留時(shí)間都需要對(duì) UIViewController 生命周期進(jìn)行埋點(diǎn),你可以創(chuàng)建一個(gè) UIViewController 的 Category,代碼如下:
- @implementation UIViewController (logger)
- + (void)load {
- static dispatch_once_t onceToken;
- dispatch_once(&onceToken, ^{
- // 通過 @selector 獲得被替換和替換方法的 SEL,作為 ViewHook:hookClass:fromeSelector:toSelector 的參數(shù)傳入
- 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 {
- // 先執(zhí)行插入代碼,再執(zhí)行原 viewWillAppear 方法
- [self insertToViewWillAppear];
- [self hook_viewWillAppear:animated];
- }
- - (void)hook_viewWillDisappear:(BOOL)animated {
- // 執(zhí)行插入代碼,再執(zhí)行原 viewWillDisappear 方法
- [self insertToViewWillDisappear];
- [self hook_viewWillDisappear:animated];
- }
- - (void)insertToViewWillAppear {
- // 在 ViewWillAppear 時(shí)進(jìn)行日志的埋點(diǎn)
- [[[[SMLogger create]
- message:[NSString stringWithFormat:@"%@ Appear",NSStringFromClass([self class])]]
- classify:ProjectClassifyOperation]
- save];
- }
- - (void)insertToViewWillDisappear {
- // 在 ViewWillDisappear 時(shí)進(jìn)行日志的埋點(diǎn)
- [[[[SMLogger create]
- message:[NSString stringWithFormat:@"%@ Disappear",NSStringFromClass([self class])]]
- classify:ProjectClassifyOperation]
- save];
- }
- @end
可以看到,Category 在+load() 方法里使用了 ViewHook 進(jìn)行方法替換,在替換的方法里執(zhí)行需要埋點(diǎn)的方法 [self insertToViewWillAppear]。 這樣的話,每個(gè)UIViewController生命周期到了ViewWillAppear 時(shí)都會(huì)去執(zhí)行insertToViewWillAppear 方法。
那么,我們要怎么區(qū)別不同的 UIViewController 呢?我一般采取的做法都是,使用NSStringFromClass([self class]) 方法來取類名。這樣,我就能夠通過類名來區(qū)別不同的 UIViewController 了。
對(duì)于點(diǎn)擊事件來說,我們也可以通過運(yùn)行時(shí)方法替換的方式進(jìn)行無侵入埋點(diǎn)。這里最主要的工作是,找到這個(gè)點(diǎn)擊事件的方法 sendAction:to:forEvent:,然后在 +load() 方法使用 ViewHook 替換成為你定義的方法。完整代碼實(shí)現(xiàn)如下:
- + (void)load {
- static dispatch_once_t onceToken;
- dispatch_once(&onceToken, ^{
- // 通過 @selector 獲得被替換和替換方法的 SEL,作為 ViewHook:hookClass:fromeSelector:toSelector 的參數(shù)傳入
- 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 生命周期埋點(diǎn)不同的是,UIButton 在一個(gè)視圖類中可能有多個(gè)不同的繼承類,相同 UIButton 的子類在不同視圖類的埋點(diǎn)也要區(qū)別開。所以,我們需要通過 “action 選擇器名NSStringFromSelector(action)” +“視圖類名 NSStringFromClass([target class])”組合成一個(gè)唯一的標(biāo)識(shí),來進(jìn)行埋點(diǎn)記錄。
除了 UIViewController、UIButton 控件以外,Cocoa 框架的其他控件都可以使用這種方法來進(jìn)行無侵入埋點(diǎn)。以 Cocoa 框架中最復(fù)雜的 UITableView 控件為例,你可以使用 hook setDelegate 方法來實(shí)現(xiàn)無侵入埋點(diǎn)。另外,對(duì)于 Cocoa 框架中的手勢(shì)事件(Gesture Event),我們也可以通過 hook initWithTarget:action: 方法來實(shí)現(xiàn)無侵入埋點(diǎn)。
事件唯一標(biāo)識(shí)
通過運(yùn)行時(shí)方法替換的方式,我們能夠 hook 住所有的 Objective-C 方法,可以說是大而全了,能夠幫助我們解決絕大部分的埋點(diǎn)問題。
但是,這種方案的精確度還不夠高,還無法區(qū)分相同類在不同視圖樹節(jié)點(diǎn)的情況。比如,一個(gè)視圖下相同 UIButton 的不同實(shí)例,僅僅通過 “action 選擇器名”+“視圖類名”的組合還不能夠區(qū)分開。這時(shí),我們就需要有一個(gè)唯一標(biāo)識(shí)來區(qū)分不同的事件。接下來,我就跟你說說如何制定出這個(gè)唯一標(biāo)識(shí)。
這時(shí),我首先想到的就是,能不能通過視圖層級(jí)的路徑來解決這個(gè)問題。因?yàn)槊總€(gè)頁面都有一個(gè)視圖樹結(jié)構(gòu),通過視圖的 superview 和 subviews 的屬性,我們就能夠還原出每個(gè)頁面的視圖樹。視圖樹的頂層是 UIWindow,每個(gè)視圖都在樹的子節(jié)點(diǎn)上。如下圖所示:
一個(gè)視圖下的子節(jié)點(diǎn)可能是同一個(gè)視圖的不同實(shí)例,比如上圖中 UIView 視圖節(jié)點(diǎn)下的兩個(gè) UIButton 是同一個(gè)類的不同實(shí)例,所以光靠視圖樹的路徑還是沒法唯一確定出視圖的標(biāo)識(shí)。那么,這種情況下,我們又應(yīng)該如何區(qū)別不同的視圖呢?
這時(shí),我們想到了索引:每個(gè)子視圖在父視圖中都會(huì)有自己的索引,所以如果我們?cè)偌由线@個(gè)索引的話,每個(gè)視圖的標(biāo)識(shí)就是唯一的了。
接下來的一個(gè)問題是,視圖層級(jí)路徑加上在父視圖中的索引來進(jìn)行唯一標(biāo)識(shí),是不是就能夠涵蓋所有情況了呢?
當(dāng)然不是。我們還需要考慮類似 UITableViewCell 這種具有可復(fù)用機(jī)制的視圖,Cell 會(huì)在頁面滾動(dòng)時(shí)不斷復(fù)用,所以加索引的方式還是沒法用。
但這個(gè)問題也并不是無解的。UITableViewCell 需要使用 indexPath,這個(gè)值里包含了 section 和 row 的值。所以,我們可以通過 indexPath 來確定每個(gè) Cell 的唯一性。
除了 UITableViewCell 這種情況之外, UIAlertController 也比較特殊。它的特殊性在于視圖層級(jí)的不固定,因?yàn)樗赡艹霈F(xiàn)在任何頁面中。但是,我們都知道它的功能區(qū)分往往通過彈窗內(nèi)容來決定,所以可以通過內(nèi)容來確定它的唯一標(biāo)識(shí)。
除此之外,還有更多需要特殊處理的情況,但我們總是可以通過一些辦法去確定它們的唯一性,所以我在這里也就不再一一列舉了。思路上來說就是,想辦法找出元素間不相同的因素然后進(jìn)行組合,最后形成一個(gè)能夠區(qū)別于其他元素的標(biāo)識(shí)來。
除了上面提到的這些特殊情況外,還有一種情況使得我們也難以得到準(zhǔn)確的唯一標(biāo)識(shí)。如果視圖層級(jí)在運(yùn)行時(shí)會(huì)被更改,比如執(zhí)行 insertSubView:atIndex:、removeFromSuperView 等方法時(shí),我們也無法得到唯一標(biāo)識(shí),即使只截取部分路徑也無法保證后期代碼更新時(shí)不會(huì)動(dòng)到這個(gè)部分。就算是運(yùn)行時(shí)視圖層級(jí)不會(huì)修改,以后需求迭代頁面更新頻繁的話,視圖唯一標(biāo)識(shí)也需要同步的更新維護(hù)。
這種問題就不好解決了,事件唯一標(biāo)識(shí)的準(zhǔn)確性難以保障,這也是通過運(yùn)行時(shí)方法替換進(jìn)行無侵入埋點(diǎn)很難在各個(gè)公司全面鋪開的原因。雖然無侵入埋點(diǎn)無法覆蓋到所有情況,全面鋪開面臨挑戰(zhàn),但是無侵入埋點(diǎn)還是解決了大部分的埋點(diǎn)需求,也節(jié)省了大量的人力成本。
最好的方案永遠(yuǎn)是針對(duì)于不同的場(chǎng)景來說的,我們不可能在一個(gè)創(chuàng)業(yè)團(tuán)隊(duì)一開始就選擇方案3的架構(gòu),所以對(duì)于你來說,你要自己抉擇目前而言對(duì)你最好的方案,如果你沒有后臺(tái)業(yè)務(wù)同學(xué)的支持,方案1也許對(duì)你來說真的是最好的方案了,起碼是可以完成統(tǒng)計(jì)需求,雖然苦點(diǎn)累點(diǎn)。但是在合適的時(shí)間,切換不同的選擇,才是成長(zhǎng)的體現(xiàn),還是最開始的話,如果你在的團(tuán)隊(duì),已經(jīng)給你了資源和時(shí)間去完善埋點(diǎn)這個(gè)模塊,如果你把它做的更好,那一定是一件很酷的事情。
參考資料
1. 網(wǎng)易HubbleData無痕埋點(diǎn)SDK實(shí)現(xiàn)
2. iOS無埋點(diǎn)數(shù)據(jù)SDK實(shí)踐之路
4. 微信讀書團(tuán)隊(duì)Aspects的基本原理
【本文是51CTO專欄機(jī)構(gòu)“AiChinaTech”的原創(chuàng)文章,微信公眾號(hào)( id: tech-AI)”】