iOS用戶行爲追蹤——無侵入埋點

  本文章系作者原創文章,如需轉載學習,請註明該文章的原始出處和網址鏈接。
  在閱讀的過程中,如若對該文章有不懂或值得優化的建議,歡迎大家加QQ:690091622 進行技術交流和探討。


前言:
  前幾日做項目,需要做這樣的一個功能:
    記錄應用Crash之前用戶操作的最後20步
  看到這樣的需求,第一感覺就是有些懵,excuse me? 用戶咋操作的我咋知道???應用啥時候Crash我咋知道???

  最後,經過各方查找資料,終於搞定了。
  先不多說,放一張控制檯輸出的運行結果的截圖。


User_Trace_Sequence.jpg

1. 技術原理

 1.1 Method-Swizzling

  在Objective-C中調用一個方法,其實是向一個對象發送消息,查找消息的唯一依據是selector的名字。
  利用Objective-C的動態特性,可以實現在運行時偷換selector對應的方法實現,達到給方法hook的目的。
  每個類都有一個方法列表,存放着selector的名字和方法實現的映射關係。IMP有點類似函數指針,指向具體的Method實現。


IMP.jpg
  1. 用 method_exchangeImplementations 方法來交換2個方法中的IMP,
  2. 用 class_replaceMethod 方法來修改類,
  3. 用 method_setImplementation 方法來直接設置某個方法的IMP,

  其實,就是在程序運行中偷換了selector的IMP,如下圖所示:


IMP_exchange.jpg

 1.2 Target-Action

  對於一個給定的事件,UIControl會調用sendAction:to:forEvent:來將行爲消息轉發到UIApplication對象,再由UIApplication對象調用其sendAction:to:fromSender:forEvent:方法來將消息分發到指定的target上,而如果我們沒有指定target,則會將事件分發到響應鏈上第一個想處理消息的對象上。
  而如果子類想監控或修改這種行爲的話,則可以重寫這個方法。

2.實現分析

  用戶的操作行爲軌跡在應用上的體現無非就是以下這幾種情況:

  • 點擊了哪個按鈕
  • 哪個頁面跳轉到哪個頁面
  • 當前停留在是哪個界面 

  1. 對於我們需要實現的功能中關於記錄用戶交互的操作,我們使用runtime中的方法hook下sendAction:to:forEvent:便可以知道用戶進行了什麼樣的交互操作。
這個方法對UIControl及繼承於UIControl而實現的子類對象是有效的,比如UIButton、UISlider、UIDatePicker、UISegmentControl等。
  2. iOS中頁面切換有兩種方式:UIViewController中的presentViewController:animated:dismissViewController:completion:;UINavigationController中的pushViewController:animated:popViewControllerAnimated:
  但是,對於UIViewController來說,我們不對這兩個方法hook,因爲頁面跳來跳去,記錄下來的各種數據會很多很亂,不利於後續查看。所以hook下ViewDidAppear:這個方法知道哪個頁面顯示了就足夠了,而所有顯示的頁面按時間順序連成序列,便是用戶操作後應用中的頁面跳轉的軌跡。

  這個解決方案看起來很不錯,這樣既沒有在項目中到處插入埋點函數,也沒有給項目增加多少代碼量,是一個兩全其美的辦法。

3. 代碼實現

  以下是對三個類進行hook的主要實現代碼。

 3.1. UIApplication

@interface UIApplication (HLCHook)
+ (void)hookUIApplication;
@end


@implementation UIApplication (HLCHook)
+ (void)hookUIApplication
{
Method controlMethod = class_getInstanceMethod([UIApplication class], @selector(sendAction:to:from:forEvent:));
Method hookMethod = class_getInstanceMethod([self class], @selector(hook_sendAction:to:from:forEvent:));
method_exchangeImplementations(controlMethod, hookMethod);
}


- (BOOL)hook_sendAction:(SEL)action to:(nullable id)target from:(nullable id)sender forEvent:(nullable UIEvent *)event;
{
NSString *actionDetailInfo = [NSString stringWithFormat:@" %@ - %@ - %@", NSStringFromClass([target class]), NSStringFromClass([sender class]), NSStringFromSelector(action)];
NSLog(@"%@", actionDetailInfo);
return [self hook_sendAction:action to:target from:sender forEvent:event];
}
@end

 3.2. UIViewController

@interface UIViewController (HLCHook)
+ (void)hookUIViewController;
@end


@implementation UIViewController (HLCHook)
+ (void)hookUIViewController
{
Method appearMethod = class_getInstanceMethod([self class], @selector(viewDidAppear:));
Method hookMethod = class_getInstanceMethod([self class], @selector(hook_ViewDidAppear:));
method_exchangeImplementations(appearMethod, hookMethod);
}


- (void)hook_ViewDidAppear:(BOOL)animated
{
NSString *appearDetailInfo = [NSString stringWithFormat:@" %@ - %@", NSStringFromClass([self class]), @"didAppear"];
NSLog(@"%@", appearDetailInfo);
[self hook_ViewDidAppear:animated];
}
@end

 3.3. UINavigatinoController

@interface UINavigationController (HLCHook)
+ (void)hookUINavigationController_push;
+ (void)hookUINavigationController_pop;
@end


@implementation UINavigationController (HLCHook)
+ (void)hookUINavigationController_push
{
Method pushMethod = class_getInstanceMethod([self class], @selector(pushViewController:animated:));
Method hookMethod = class_getInstanceMethod([self class], @selector(hook_pushViewController:animated:));
method_exchangeImplementations(pushMethod, hookMethod);
}


- (void)hook_pushViewController:(UIViewController *)viewController animated:(BOOL)animated
{
NSString *popDetailInfo = [NSString stringWithFormat: @"%@ - %@ - %@", NSStringFromClass([self class]), @"push", NSStringFromClass([viewController class])];
NSLog(@"%@", popDetailInfo);
[self hook_pushViewController:viewController animated:animated];
}


+ (void)hookUINavigationController_pop
{
Method popMethod = class_getInstanceMethod([self class], @selector(popViewControllerAnimated:));
Method hookMethod = class_getInstanceMethod([self class], @selector(hook_popViewControllerAnimated:));
method_exchangeImplementations(popMethod, hookMethod);
}


- (void)hook_popViewControllerAnimated:(BOOL)animated
{
NSString *popDetailInfo = [NSString stringWithFormat:@"%@ - %@", NSStringFromClass([self class]), @"pop"];
NSLog(@"%@", popDetailInfo);
[self hook_popViewControllerAnimated:animated];
}
@end


  至此,核心代碼已經完成了。
  那麼如何使用該功能來記錄用戶操作軌跡呢?
  在appDelegate.m文件中的 application:didFinishLaunchingWithOptions: 添加如下四行代碼: 
    [UIApplication hookUIApplication];
[UIViewController hookUIViewController];
[UINavigationController hookUINavigationController_push];
[UINavigationController hookUINavigationController_pop];

  啓動程序,並觀察控制檯輸出,神奇的事情將會發生,用戶的每一次操作和頁面跳轉都會被記錄下來。

提醒

1.UITabBarItem
  當用戶點擊了UITabBarItem時,會同時記錄三次事件,分別是:

  • _buttonDown:
  • _buttonUp:
  • _tabBarItemClicked:

  所以,對於這三個事件,我們可以只需保留一個,將其他兩個在記錄的時候過濾掉。若記錄空間有限,過濾掉冗餘的信息,這樣可以在有限的記錄空間上記錄更多的用戶操作數據。

總結

  1.hook方式非常強大,幾乎可以截取任何用戶想截取的消息事件,但是,每次觸發hook,必然存在置換IMP整個過程,頻繁的置換IMP必然會影響到應用及手機資源的消耗,不到非不得已,建議少用。
  2.什麼時候用hook的方式來埋點呢?例如,當應用有10個頁面,而我們只需在其中兩個頁面上埋點,那麼就沒必要用這種方式了。具體什麼時候用,由開發者根據項目實際需求來權衡,我們的原則就是要力圖資源消耗最少。
  3.對於View上的手勢觸摸事件touchBegan:withEvent:等,這種方式截取不到消息。之所以暫時不做,也是因爲消耗的問題,因爲蘋果手機都是觸摸屏的,每進行一次觸摸屏幕,不管會不會產生交互事件都會觸發該事件的。有興趣的小夥伴可以根據以上提供的思路來自己嘗試實現下,測試下系統消耗,看適不適合來做。