为了达到更好的阅读效果,强烈建议跳转到这里查看文章。
iOS动态性是我的关于iOS运行时的系列文章,由浅入深,从理论到实践。本文是第5篇。有兴趣可以看看我之前的文章。
用户行为统计(User Behavior Statistics, UBS)一直是移动互联网产品中必不可少的环节,也俗称埋点。在保证移动端流量不会受较大影响的前提下,PM们总是希望埋点覆盖面越广越好。目前常规的做法是将埋点代码封装成工具类,但凡工程中需要埋点(如点击事件、页面跳转)的地方都插入埋点代码。一旦项目越来越复杂,你会发现埋点的代码散落在程序的各个角落,不利于维护以及复用。本文旨在探讨利用iOS的运行时机制实现一种可复、解耦、容易维护的用户统计方案。探讨毕竟是探讨,欢迎到留言讨论。本文虽有些长却是用心之作,希望你有耐心看完。
注:本文需要一些iOS的Runtime基础
该方案的完成将会用到以下知识:
- Method Swizzling(Hook)
- 单元测试
一、常规埋点做法
接着开头的话题,我们先回顾一下主流的埋点是怎么做的。我粗糙地将埋点分为两种:1、页面统计,包括页面停留时间、页面进入次数;2、交互事件统计,包括单击、双击、手势交互等。
1)常规页面统计埋点
以统计页面进入次数为例,最简单粗暴的做法是在所有页面的viewDidAppear:
以及viewDidDisappear:
中分别埋点,将自己对应的pageID上传给服务端。代码大概长酱紫:
@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并上传给服务端,代码大概长酱紫:
- (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)的一种实现。
替换两个方法的实现,代码一般长酱紫:
@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];
});
}
#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
神器:
- (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"]) {
pageEven