IOS用户统计实现
用户统计
用户行为统计(User Behavior Statistics, UBS)一直是移动互联网产品中必不可少的环节,也俗称埋点。在保证移动端流量不会受较大影响的前提下,PM们总是希望埋点覆盖面越广越好。目前常规的做法是将埋点代码封装成工具类,但凡工程中需要埋点(如点击事件、页面跳转)的地方都插入埋点代码。一旦项目越来越复杂,你会发现埋点的代码散落在程序的各个角落,不利于维护以及复用。
常规埋点做法
接着开头的话题,我们先回顾一下主流的埋点是怎么做的。我粗糙地将埋点分为两种:
1、页面统计,包括页面停留时间、页面进入次数;
2、交互事件统计,包括单击、双击、手势交互等。
常规页面统计埋点
以统计页面进入次数为例,最简单粗暴的做法是在所有页面的viewDidAppear:以及viewDidDisappear:中分别埋点,将自己对应的pageID上传给服务端。代码大概长酱紫:
@implementation HomeViewController
- (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点提到的“脏代码”意思是用户行为分析这种业务其实跟主业务没太大关系,不应该保持如此高的耦合度,因为这些代码会干扰我们对项目主业务的维护。
常规交互事件埋点
常规做法一般在交互事件的selector中获取该事件的ID并上传给服务端,代码大概长酱紫:
- (IBAction)onFavBtnPressed:(id)sender{
[WUserStatistics sendEventToServer:@"CTRL_EVENT_HOME_FAV"];
//...do other things }
稍微大一点的APP如果采用这种方式,那诸如此类的埋点代码将遍地都是。它的缺点参考页面统计埋点部分,其复用性基本为零,也就是在新项目中根本无法复用埋点代码。 小总结一下,采用常规的做法虽然直观方便,但在可复用性、可维护性等方面有所欠缺。在我看来,借助运行时可以很好地避开这些缺点。
Method Swizzling、Hook与代码注入
在iOS中,我们可以在运行时替换两个方法的实现,达到“勾住”某个方法并注入代码的目的。具体做法是: 重载类的“+(void)load”方法,在程序加载到内存时利用Runtime的method_exchangeImplementations等接口将方法(设为M)的实现互相交换。当方法M被调用时就会被勾住(Hook),执行我们的方法。 这种技术也称为Method Swizzling,属于面向切面编程(Aspect-Oriented Programming)的一种实现。 替换两个方法的实现,代码一般长酱紫:
@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:方法,可以这样做:
@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];
});
}
- (void)swiz_viewWillAppear:(BOOL)animated{
//插入需要执行的代码
NSLog(@"我在viewWillAppear执行前偷偷插入了一段代码");
[self swiz_viewWillAppear:animated];
}
@end
基于运行时的埋点方案
为了便于下文叙述,先引入一个简单的项目,共有两个页面(HomeViewController,DetailViewController) 需求是:
- 统计两个页面的展示与离开次数
- 统计收藏、分享单击事件的次数
- 对现有工程代码影响越小越好
统计两个页面的展示与离开次数
这部分应该比较直观了,摒弃掉在每个controller中埋点的方式,我们对UIViewController添加category从而Hook到viewWillAppear:与viewWillDisappear:。在这两个方法中注入埋点代码:
@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];
SEL originalSelector2 = @selector(viewWillDisappear:);
SEL swizzledSelector2 = @selector(swiz_viewWillDisappear:);
[WHookUtility swizzlingInClass:[self class] originalSelector:originalSelector2 swizzledSelector:swizzledSelector2];
});
}
pragma mark - Method Swizzling
- (void)swiz_viewWillAppear:(BOOL)animated{
//插入需要执行的代码
[self inject_viewWillAppear];
[self swiz_viewWillAppear:animated];
}
- (void)swiz_viewWillDisappear:(BOOL)animated{
[self inject_viewWillDisappear];
[self swiz_viewWillDisappear:animated];
}
//利用hook 统计所有页面的停留时长
- (void)inject_viewWillAppear{
NSString *pageID = [self pageEventID:YES];
if (pageID) {
[WUserStatistics sendEventToServer:pageID];
}
}
- (void)inject_viewWillDisappear{
NSString *pageID = [self pageEventID:NO];
if (pageID) {
[WUserStatistics sendEventToServer:pageID];
}
}
这时候问题来了,项目中每个页面都会有自己的页面事件编号(pageEventID),此处的埋点代码如何知道要发送什么pageEventID给服务端呢?轻松祭出if-else神器: 为了便于下文叙述,先引入一个简单的项目,共有两个页面(HomeViewController,DetailViewController),如下:
- (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 (<#expression#>)...
}
当然,我们可以有更优雅的方式,比如用一个配置表替代上面一长串的if判断,这样无论页面数怎么增加,代码始终是那么一小段。我们新建一个WGlobalUserStatisticsConfig.plist的配置表来存放每个页面在进入以及离开时的pageEventID,结构如下:
- (上效果图可见用户统计实现图片文件夹中的PageEventID.png)
因此,页面进出统计中获取pageEventID的代码始终是以下这几句:
- (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;
}
效果如下:
- (上效果图可见用户统计实现图片文件夹中的Jietu.png)
以上就是完成了页面进出统计的埋点,并且达到了我们的第三点预期:对现有代码基本无影响。通过Method Swizzling的方式现有的工程甚至不需要import任何文件!后期代码变动时需要维护的仅仅是plist配置表。
统计收藏、分享单击事件的次数
与上一节思路一致,要做到解耦显然需要通过category+hook来实现。本文demo中收藏跟分享都是UIButton类型,可以考虑添加UIButton的catogory。但更好的方式是添加UIControl的category,这样可以让埋点代码覆盖到所有UIControl的子类中去,比如button、switch、segment等,提高复用性。 既然要hook,那就要清楚到底要hookUIControl的哪(几)个方法,只有部分方法是满足埋点需求的,最好是所hook的方法能提供target、actionName等信息。这是个尝试的过程。代码如下:
@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如下图:
- (上效果图可见用户统计实现图片文件夹中的Log.png)
可以看到,通过category+method swizzling的方式在没有修改现有工程任何代码的情况下已经成功Hook到所有点击事件,在Hook代码中我们知道了一个点击事件的target也就是ViewController,也知道了点击事件的响应函数名,知道了点击的TouchSet。这些信息已经能满足埋点需求了。 与页面统计埋点类似,我们同样采用plist配置表的方式避免一大长串的if-else判断: 有了这张配置表就很容易得到某次单击事件的事件ID(ControlEventID):
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配置表
- 维护配置表比维护散落在工程各个角落的代码简单
小总结:
合理的单元测试可以为本文方案的后期维护减轻相当大的负担,测试用例的完备性很重要,需要用心设计考虑周全。
结语
以上就是结合运行时所设计出的用户统计思路全部内容。应该说该方案的可复用性与解耦程度都是不错的,既适合于新建的工程,也适合于已经创建的工程。看起来内容多,其实总结起来无非几个步骤:plist配置表+Hook+单元测试。利用Method Swizzling把埋点代码集中管理其实也是合理的,有利于专人开发、跟踪及维护。当然以上思路只考虑简单的情形,更复杂的情况就需要变通了,但总体思路就是如此。
- 本文demo地址