使用者行為統計(User Behavior Statistics, UBS)一直是移動網際網路產品中必不可少的環節,也俗稱埋點。在保證移動端流量不會受較大影響的前提下,PM們總是希望埋點覆蓋面越廣越好。目前常規的做法是將埋點程式碼封裝成工具類,但凡工程中需要埋點(如點選事件、頁面跳轉)的地方都插入埋點程式碼。一旦專案越來越複雜,你會發現埋點的程式碼散落在程式的各個角落,不利於維護以及複用。本文旨在探討利用iOS的執行時機制實現一種可復、解耦、容易維護的使用者統計方案。探討畢竟是探討,歡迎到在簡書留言討論。本文雖有些長卻是用心之作,希望你有耐心看完。
注:本文需要一些iOS的Runtime基礎
該方案的完成將會用到以下知識:
- Method Swizzling(Hook)
- 單元測試
一、常規埋點做法
接著開頭的話題,我們先回顧一下主流的埋點是怎麼做的。我粗糙地將埋點分為兩種:1、頁面統計,包括頁面停留時間、頁面進入次數;2、互動事件統計,包括單擊、雙擊、手勢互動等。
1)常規頁面統計埋點
以統計頁面進入次數為例,最簡單粗暴的做法是在所有頁面的viewDidAppear:
以及viewDidDisappear:
中分別埋點,將自己對應的pageID上傳給服務端。程式碼大概長醬紫:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
@implementation HomeViewController //...other methods - (void)viewDidAppear:(BOOL)animated { [super viewWillAppear:animated]; [WUserStatistics sendEventToServer:@"PAGE_EVENT_HOME_ENTER"]; } - (void)viewDidDisappear:(BOOL)animated { [super viewDidDisappear:animated]; [WUserStatistics sendEventToServer:@"PAGE_EVENT_HOME_LEAVE"]; } @end |
+[WUserStatistics sendEventToServer:]
封裝網路請求,將ID上傳給伺服器。上述方案有以下弊端:
1、複用性差。這部分埋點程式碼很難給其他專案複用
2、工作量大。尤其當頁面較多時,需要修改的程式碼較多
3、引入“髒程式碼”,不易維護
第3點提到的“髒程式碼”意思是使用者行為分析這種業務其實跟主業務沒太大關係,不應該保持如此高的耦合度
,因為這些程式碼會干擾我們對專案主業務的維護。這個我個人看法。
2)常規互動事件埋點
常規做法一般在互動事件的selector中獲取該事件的ID並上傳給服務端,程式碼大概長醬紫:
1 2 3 4 5 |
- (IBAction)onFavBtnPressed:(id)sender { [WUserStatistics sendEventToServer:@"CTRL_EVENT_HOME_FAV"]; //...do other things } |
稍微大一點的APP如果採用這種方式,那諸如此類的埋點程式碼將遍地都是。它的缺點參考頁面統計埋點部分,其複用性基本為零,也就是在新專案中根本無法複用埋點程式碼。
小總結一下,採用常規的做法雖然直觀方便,但在可複用性、可維護性等方面有所欠缺。在我看來,藉助執行時可以很好地避開這些缺點。
二、Method Swizzling、Hook與程式碼注入
由於Runtime知識不屬於本文的重點,這裡只簡單介紹。
在iOS中,我們可以在執行時替換兩個方法的實現,達到“勾住”某個方法並注入程式碼的目的。具體做法是:
過載類的“+(void)load”方法,在程式載入到記憶體時利用Runtime的
method_exchangeImplementations
等介面將方法(設為M)的實現互相交換。當方法M被呼叫時就會被勾住(Hook),執行我們的方法。
這種技術也稱為Method Swizzling
,屬於面向切面程式設計(Aspect-Oriented Programming)的一種實現。
替換兩個方法的實現,程式碼一般長醬紫:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
@interface WHookUtility : NSObject + (void)swizzlingInClass:(Class)cls originalSelector:(SEL)originalSelector swizzledSelector:(SEL)swizzledSelector; @end @implementation WHookUtility + (void)swizzlingInClass:(Class)cls originalSelector:(SEL)originalSelector swizzledSelector:(SEL)swizzledSelector { Class class = cls; Method originalMethod = class_getInstanceMethod(class, originalSelector); Method swizzledMethod = class_getInstanceMethod(class, swizzledSelector); BOOL didAddMethod = class_addMethod(class, originalSelector, method_getImplementation(swizzledMethod), method_getTypeEncoding(swizzledMethod)); if (didAddMethod) { class_replaceMethod(class, swizzledSelector, method_getImplementation(originalMethod), method_getTypeEncoding(originalMethod)); } else { method_exchangeImplementations(originalMethod, swizzledMethod); } } @end |
這個WHookUtility
工具類下文會用到。比如現在我們要勾住UIViewController
的viewWillAppear:
方法,可以這樣做:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
@implementation UIViewController (userStastistics) + (void)load { static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ SEL originalSelector = @selector(viewWillAppear:); SEL swizzledSelector = @selector(swiz_viewWillAppear:); [WHookUtility swizzlingInClass:[self class] originalSelector:originalSelector swizzledSelector:swizzledSelector]; }); } #pragma mark - Method Swizzling - (void)swiz_viewWillAppear:(BOOL)animated { //插入需要執行的程式碼 NSLog(@"我在viewWillAppear執行前偷偷插入了一段程式碼"); //不能干擾原來的程式碼流程,插入程式碼結束後要讓本來該執行的程式碼繼續執行 [self swiz_viewWillAppear:animated]; } @end |
更多關於Runtime、method swizzling、面向切面程式設計的介紹請參考這裡
三、基於執行時的埋點方案
為了便於下文敘述,先引入一個簡單的專案,共有兩個頁面(HomeViewController
,DetailViewController
),如下:
需求是
- 統計兩個頁面的展示與離開次數
- 統計收藏、分享單擊事件的次數
- 對現有工程程式碼影響越小越好
1)統計兩個頁面的展示與離開次數
這部分應該比較直觀了,摒棄掉在每個controller中埋點的方式,我們對UIViewController新增category從而Hook到viewWillAppear:
與viewWillDisappear:
。在這兩個方法中注入埋點程式碼:
這時候問題來了,專案中每個頁面都會有自己的頁面事件編號(pageEventID),此處的埋點程式碼如何知道要傳送什麼pageEventID給服務端呢?輕鬆祭出if-else
神器:
1 2 3 4 5 6 7 8 9 10 11 |
- (NSString *)pageEventID:(BOOL)bEnterPage { NSString *selfClassName = NSStringFromClass([self class]); NSString *pageEventID = nil; if ([selfClassName isEqualToString:@"HomeViewController"]) { pageEventID = bEnterPage ? @"EVENT_HOME_ENTER_PAGE" : @"EVENT_HOME_LEAVE_PAGE"; } else if ([selfClassName isEqualToString:@"DetailViewController"]) { pageEventID = bEnterPage ? @"EVENT_DETAIL_ENTER_PAGE" : @"EVENT_DETAIL_LEAVE_PAGE"; } //else if ()... } |
當然,我們可以有更優雅的方式,比如用一個配置表替代上面一長串的if
判斷,這樣無論頁面數怎麼增加,程式碼始終是那麼一小段。我們新建一個WGlobalUserStatisticsConfig.plist
的配置表來存放每個頁面在進入以及離開時的pageEventID,結構如下:
因此,頁面進出統計中獲取pageEventID的程式碼始終是以下這幾句:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
- (NSString *)pageEventID:(BOOL)bEnterPage { NSDictionary *configDict = [self dictionaryFromUserStatisticsConfigPlist]; NSString *selfClassName = NSStringFromClass([self class]); return configDict[selfClassName][@"PageEventIDs"][bEnterPage ? @"Enter" : @"Leave"]; } - (NSDictionary *)dictionaryFromUserStatisticsConfigPlist { NSString *filePath = [[NSBundle mainBundle] pathForResource:@"WGlobalUserStatisticsConfig" ofType:@"plist"]; NSDictionary *dic = [NSDictionary dictionaryWithContentsOfFile:filePath]; return dic; } |
效果如下:
以上就是完成了頁面進出統計的埋點,並且達到了我們的第三點預期:對現有程式碼基本無影響。通過Method Swizzling的方式現有的工程甚至不需要import
任何檔案!後期程式碼變動時需要維護的僅僅是plist配置表。
2)統計收藏、分享單擊事件的次數
與上一節思路一致,要做到解耦顯然需要通過category+hook來實現。本文demo中收藏跟分享都是UIButton型別,可以考慮新增UIButton的catogory。但更好的方式是新增UIControl的category,這樣可以讓埋點程式碼覆蓋到所有UIControl的子類中去,比如button、switch、segment等,提高複用性。
既然要hook,那就要清楚到底要hookUIControl
的哪(幾)個方法,只有部分方法是滿足埋點需求的,最好是所hook的方法能提供target、actionName等資訊。這是個嘗試的過程。
UIControl
的方法列表有以下:
通過觀察方法名和引數,我們有理由懷疑是倒數第二個,因其攜帶了不少貌似有價值的資訊:
1 |
- (void)sendAction:(SEL)action to:(nullable id)target forEvent:(nullable UIEvent *)event; |
於是寫出測試程式碼看看:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
@implementation UIControl (userStastistics) + (void)load { static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ SEL originalSelector = @selector(sendAction:to:forEvent:); SEL swizzledSelector = @selector(swiz_sendAction:to:forEvent:); [WHookUtility swizzlingInClass:[self class] originalSelector:originalSelector swizzledSelector:swizzledSelector]; }); } #pragma mark - Method Swizzling - (void)swiz_sendAction:(SEL)action to:(id)target forEvent:(UIEvent *)event; { //插入埋點程式碼 [self performUserStastisticsAction:action to:target forEvent:event]; [self swiz_sendAction:action to:target forEvent:event]; } - (void)performUserStastisticsAction:(SEL)action to:(id)target forEvent:(UIEvent *)event; { NSLog(@"n***hook success.n[1]action:%@n[2]target:%@ n[3]event:%ld", NSStringFromSelector(action), target, (long)event); } @end |
Log如下圖:
可以看到,通過category+method swizzling的方式在沒有修改現有工程任何程式碼的情況下已經成功Hook到所有點選事件,在Hook程式碼中我們知道了一個點選事件的target
也就是ViewController,也知道了點選事件的響應函式名,知道了點選的TouchSet
。這些資訊已經能滿足埋點需求了。
與頁面統計埋點類似,我們同樣採用plist配置表的方式避免一大長串的if-else
判斷:
有了這張配置表就很容易得到某次單擊事件的事件ID(ControlEventID):
1 2 3 4 |
NSString *actionString = NSStringFromSelector(action);//獲取SEL string NSString *targetName = NSStringFromClass([target class]);//viewController name NSDictionary *configDict = [self dictionaryFromUserStatisticsConfigPlist]; eventID = configDict[targetName][@"ControlEventIDs"][actionString]; |
事實上,我把某個頁面單元的所有事件ID分成了兩類:頁面事件ID(PageEventIDs,頁面的進出等)、互動事件ID(ControlEventIDs,單擊、雙擊、手勢等)。分類有助於下文使用單元測試(Unit Test)進行自動化後期維護。
埋點效果如圖:
到這裡先做了階段性的總結,本文提出的思路有以下優越性:
- 與工程程式碼基本解耦,避免引入“髒程式碼”
- 即使後期工程程式碼發生重構,需要修改的僅僅是plist配置表
- 維護配置表比維護散落在工程各個角落的程式碼簡單
四、基於單元測試的後期維護
俗話說,創業難守業更難。前面的思路基本可以完成初步的埋點需求。但是在實際專案中程式碼重構是很頻繁的。這意味著在多人協作開發、程式碼重構頻繁的專案中響應事件方法甚至頁面名稱都可能被改掉,造成事件ID獲取不到導致埋點失效。
程式碼變動的情況無非以下幾種(這裡只介紹響應事件發生改變的情況):
1、響應事件方法名稱改變或者刪除
比如收藏事件原先是onFavBtnPressed:
,之後被改成onFavouriteBtnPressed:
。程式碼發生變動但是plist配置表中由於開發人員疏忽忘記同步修改了。這種疏忽在開發壓力大進度趕的情況下是有很大概率發生的。由於程式碼與配置表不匹配將導致eventID為nil。在這種情況下單元測試就很有必要了,使用完備的測試用例能在發版前檢測到這種不匹配情況從而避免埋點失效。
在單元測試中我們首先讀取plist配置檔案,遍歷所有的頁面。在一個頁面內遍歷所有的ControlEventIDs,對每個響應函式名進行respondsToSelector:
判斷:
單測程式碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 |
- (void)testIfUserStatisticsConfigPlistValid { NSDictionary *configDict = [self dictionaryFromUserStatisticsConfigPlist]; XCTAssertNotNil(configDict, @"WGlobalUserStatisticsConfig.plist載入失敗"); [configDict enumerateKeysAndObjectsUsingBlock:^(NSString * _Nonnull key, id _Nonnull obj, BOOL * _Nonnull stop) { XCTAssert([obj isKindOfClass:[NSDictionary class]], @"plist檔案結構可能已經改變,請確認"); NSString *targetPageName = key; Class pageClass = NSClassFromString(targetPageName); id pageInstance = [[pageClass alloc] init]; //一個pageDict對應一個頁面,存放pageID,所有的action及對應的eventID NSDictionary *pageDict = (NSDictionary *)obj; //頁面配置資訊 NSDictionary *pageEventIDDict = pageDict[@"PageEventIDs"]; //互動配置資訊 NSDictionary *controlEventIDDict = pageDict[@"ControlEventIDs"]; XCTAssert(pageEventIDDict, @"plist檔案未包含PageID欄位或者該欄位值為空"); XCTAssert(controlEventIDDict, @"plist檔案未包含EventIDs欄位或者該欄位值為空"); [pageEventIDDict enumerateKeysAndObjectsUsingBlock:^(NSString * _Nonnull key, id _Nonnull value, BOOL * _Nonnull stop) { XCTAssert([value isKindOfClass:[NSString class]], @"plist檔案結構可能已經改變,請確認"); XCTAssertNotNil(value, @"EVENT_ID為空,請確認"); }]; [controlEventIDDict enumerateKeysAndObjectsUsingBlock:^(NSString * _Nonnull key, id _Nonnull value, BOOL * _Nonnull stop) { XCTAssert([value isKindOfClass:[NSString class]], @"plist檔案結構可能已經改變,請確認"); NSString *actionName = key; SEL actionSel = NSSelectorFromString(actionName); XCTAssert([pageInstance respondsToSelector:actionSel], @"程式碼與plist檔案函式不匹配,請確認:-[%@ %@]", targetPageName, actionName); //EVENT_ID不能為空 XCTAssertNotNil(value, @"EVENT_ID為空,請確認"); }]; }]; } |
我們來測試一下,如果把HomeViewController
的onFavBtnPressed:
改成onMyFavBtnPressed:
後單元測試的結果就是:
這種改變給單測輕鬆捕捉到了,
只要XCTAssert的log夠詳細,維護起來其實相當輕鬆的。
上圖中的log已經明確指出-[HomeViewController onFavBtnPressed:]
方法發生了改變。
2、程式碼中新增了響應事件
這種情況常見於新版本中有新的埋點需求。如果程式碼中新增了響應事件並且該響應事件是在PM要求的埋點列表中,但是plist有可能會漏掉該事件。這種情況是比較棘手的。上一種情況是基於plist列表去校驗程式碼,這裡就要反過來,根據程式碼去校驗plist是否有缺失。但問題來了,一個專案中響應函式往往是非常多的,並不是任何響應函式都需要埋點。需要埋點的響應函式與其他響應函式並沒有區別。
對於這種情況,一種方式是加強code review避免忘記往配置表中新增埋點(這簡直就是廢話);一種是:要求埋點響應函式的方法名中包含約定的字串,比如收藏事件的方法名為onFavBtnPressed_UA:
表示這個事件是需要埋點的。然後在單元測試中使用執行時APIclass_copyMethodList
取出標記了_UA
的所有函式,隨後到plist中校驗是否存在。不存在則表示測試用例不通過,提示開發人員校驗。
程式碼略。如果對單元測試不熟悉,可以參考單元測試
小總結:
合理的單元測試可以為本文方案的後期維護減輕相當大的負擔,測試用例的完備性很重要,需要用心設計考慮周全。
五、結語
以上就是結合執行時所設計出的使用者統計思路全部內容。應該說該方案的可複用性與解耦程度都是不錯的,既適合於新建的工程,也適合於已經建立的工程。看起來內容多,其實總結起來無非幾個步驟:plist配置表+Hook+單元測試。利用Method Swizzling把埋點程式碼集中管理其實也是合理的,有利於專人開發、跟蹤及維護。當然以上思路只考慮簡單的情形,更復雜的情況就需要變通了,但總體思路就是如此。
思路可能不完美,但作為一種嘗試也未嘗不可。路都是走出來的。