else 地址 注入 响应事件 加载失败 tor top perf
转自:http://www.jianshu.com/p/0497afdad36d
用户统计. jpeg
用户行为统计 (User Behavior Statistics, UBS) 一直是移动互联网产品中必不可少的环节,也俗称埋点。在保证移动端流量不会受较大影响的前提下,PM 们总是希望埋点覆盖面越广越好。目前常规的做法是将埋点代码封装成工具类,但凡工程中需要埋点 (如点击事件、页面跳转) 的地方都插入埋点代码。一旦项目越来越复杂,你会发现埋点的代码散落在程序的各个角落,不利于维护以及复用。本文旨在探讨利用 iOS 的运行时机制实现一种可复、解耦、容易维护的用户统计方案。探讨毕竟是探讨,欢迎到在简书留言讨论。本文虽有些长却是用心之作,希望你有耐心看完。
注:本文需要一些 iOS 的 Runtime 基础
该方案的完成将会用到以下知识:
接着开头的话题,我们先回顾一下主流的埋点是怎么做的。我粗糙地将埋点分为两种:1、页面统计,包括页面停留时间、页面进入次数;2、交互事件统计,包括单击、双击、手势交互等。
以统计页面进入次数为例,最简单粗暴的做法是在所有页面的
以及
- viewDidAppear:
中分别埋点,将自己对应的 pageID 上传给服务端。代码大概长酱紫:
- viewDidDisappear:
- @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
封装网络请求,将 ID 上传给服务器。上述方案有以下弊端:
- +[WUserStatistics sendEventToServer:]
1、复用性差。这部分埋点代码很难给其他项目复用 2、工作量大。尤其当页面较多时,需要修改的代码较多 3、引入 "脏代码",不易维护
第 3 点提到的 "脏代码" 意思是用户行为分析这种业务其实跟主业务没太大关系,
,因为这些代码会干扰我们对项目主业务的维护。这个我个人看法。
- 不应该保持如此高的耦合度
常规做法一般在交互事件的 selector 中获取该事件的 ID 并上传给服务端,代码大概长酱紫:
- - (IBAction)onFavBtnPressed:(id)sender
- {
- [WUserStatistiCSSendEventToServer:@"CTRL_EVENT_HOME_FAV"];//...do other things
- }
稍微大一点的 APP 如果采用这种方式,那诸如此类的埋点代码将遍地都是。它的缺点参考页面统计埋点部分,其复用性基本为零,也就是在新项目中根本无法复用埋点代码。
小总结一下,采用常规的做法虽然直观方便,但在可复用性、可维护性等方面有所欠缺。在我看来,借助运行时可以很好地避开这些缺点。
由于 Runtime 知识不属于本文的重点,这里只简单介绍。在 iOS 中,我们可以在运行时替换两个方法的实现,达到 "勾住" 某个方法并注入代码的目的。具体做法是:
重载类的 "+(void)load" 方法,在程序加载到内存时利用 Runtime 的
等接口将方法 (设为 M) 的实现互相交换。当方法 M 被调用时就会被勾住(Hook),执行我们的方法。
- method_exchangeImplementations
这种技术也称为
,属于面向切面编程 (Aspect-Oriented Programming) 的一种实现。
- Method Swizzling
替换两个方法的实现,代码一般长酱紫:
- @interfaceWHookUtility :NSObject
- + (void)swizzlingInClass:(Class)clsoriginalSelector:(SEL)originalSelectorswizzledSelector:(SEL)swizzledSelector;
- @end
- @implementation WHookUtility
- + (void)swizzlingInClass:(Class)clsoriginalSelector:(SEL)originalSelectorswizzledSelector:(SEL)swizzledSelector
- {Classclass =cls;MethodoriginalMethod = class_getInstanceMethod(class,originalSelector);MethodswizzledMethod = 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:
- @implementationUIViewController (userStastistics)
- + (void)load {staticdispatch_once_t onceToken;dispatch_once(&onceToken, ^{
- SEL originalSelector =@selector(viewWillAppear:);
- SEL swizzledSelector =@selector(swiz_viewWillAppear:);
- [WHookUtility swizzlingInClass:[selfclass] 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.gif
需求是
这部分应该比较直观了,摒弃掉在每个 controller 中埋点的方式,我们对 UIViewController 添加 category 从而 Hook 到
与
- viewWillAppear:
。在这两个方法中注入埋点代码:
- viewWillDisappear:
埋点代码注入. jpg
这时候问题来了,项目中每个页面都会有自己的页面事件编号 (pageEventID),此处的埋点代码如何知道要发送什么 pageEventID 给服务端呢?轻松祭出
神器:
- if-else
- - (NSString *)pageEventID:(BOOL)bEnterPage
- {NSString *selfClassName =NSStringFromClass([selfclass]);NSString *pageEventID =nil;if ([selfClassName isEqualToString:@"HomeViewController"]) {
- pageEventID = bEnterPage@"EVENT_HOME_ENTER_PAGE" :@"EVENT_HOME_LEAVE_PAGE";
- }elseif ([selfClassName isEqualToString:@"DetailViewController"]) {
- pageEventID = bEnterPage@"EVENT_DETAIL_ENTER_PAGE" :@"EVENT_DETAIL_LEAVE_PAGE";
- }//else if (<#expression#>)...
- }
当然,我们可以有更优雅的方式,比如用一个配置表替代上面一长串的
判断,这样无论页面数怎么增加,代码始终是那么一小段。我们新建一个
- if
的配置表来存放每个页面在进入以及离开时的 pageEventID,结构如下:
- WGlobalUserStatisticsConfig.plist
配置表结构. png
因此,页面进出统计中获取 pageEventID 的代码始终是以下这几句:
- - (NSString *)pageEventID:(BOOL)bEnterPage
- {NSDictionary *configDict = [self dictionaryFromUserStatisticsConfigPlist];NSString *selfClassName =NSStringFromClass([selfclass]);return configDict[selfClassName][@"PageEventIDs"][bEnterPage@"Enter" :@"Leave"];
- }
- - (NSDictionary *)dictionaryFromUserStatisticsConfigPlist
- {NSString *filePath = [[NSBundle mainBundle] pathForResource:@"WGlobalUserStatisticsConfig" ofType:@"plist"];NSDictionary *dic = [NSDictionary dictionaryWithContentsOfFile:filePath];return dic;
- }
效果如下:
页面埋点. gif
以上就是完成了页面进出统计的埋点,并且达到了我们的第三点预期:对现有代码基本无影响。通过 Method Swizzling 的方式现有的工程甚至不需要
任何文件!后期代码变动时需要维护的仅仅是 plist 配置表。
- import
与上一节思路一致,要做到解耦显然需要通过 category+hook 来实现。本文 demo 中收藏跟分享都是 UIButton 类型,可以考虑添加 UIButton 的 catogory。但更好的方式是添加 UIControl 的 category,这样可以让埋点代码覆盖到所有 UIControl 的子类中去,比如 button、switch、segment 等,提高复用性。既然要 hook,那就要清楚到底要 hook
的哪 (几) 个方法,只有部分方法是满足埋点需求的,最好是所 hook 的方法能提供 target、actionName 等信息。这是个尝试的过程。
- UIControl
的方法列表有以下:
- UIControl
UIControl 方法列表. png
通过观察方法名和参数,我们有理由怀疑是倒数第二个,因其携带了不少貌似有价值的信息:
- - (void)sendAction:(SEL)action to:(nullableid)target forEvent:(nullableUIEvent *)event;
于是写出测试代码看看:
- @implementationUIControl (userStastistics)
- + (void)load {staticdispatch_once_t onceToken;dispatch_once(&onceToken, ^{
- SEL originalSelector =@selector(sendAction:to:forEvent:);
- SEL swizzledSelector =@selector(swiz_sendAction:to:forEvent:);
- [WHookUtility swizzlingInClass:[selfclass] 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 代码中我们知道了一个点击事件的
也就是 ViewController,也知道了点击事件的响应函数名,知道了点击的
- target
。这些信息已经能满足埋点需求了。与页面统计埋点类似,我们同样采用 plist 配置表的方式避免一大长串的
- TouchSet
判断:
- if-else
单击事件配置表结构. png
有了这张配置表就很容易得到某次单击事件的事件 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) 进行自动化后期维护。
埋点效果如图:
单击埋点效果. gif
到这里先做了阶段性的总结,本文提出的思路有以下优越性:
俗话说,创业难守业更难。前面的思路基本可以完成初步的埋点需求。但是在实际项目中代码重构是很频繁的。这意味着在多人协作开发、代码重构频繁的项目中响应事件方法甚至页面名称都可能被改掉,造成事件 ID 获取不到导致埋点失效。代码变动的情况无非以下几种 (这里只介绍响应事件发生改变的情况):
1、响应事件方法名称改变或者删除
比如收藏事件原先是
,之后被改成
- onFavBtnPressed:
。代码发生变动但是 plist 配置表中由于开发人员疏忽忘记同步修改了。这种疏忽在开发压力大进度赶的情况下是有很大概率发生的。由于代码与配置表不匹配将导致 eventID 为 nil。在这种情况下单元测试就很有必要了,使用完备的测试用例能在发版前检测到这种不匹配情况从而避免埋点失效。在单元测试中我们首先读取 plist 配置文件,遍历所有的页面。在一个页面内遍历所有的 ControlEventIDs,对每个响应函数名进行
- onFavouriteBtnPressed:
判断:
- respondsToSelector:
单元测试介绍. png
单测代码如下:
- - (void)testIfUserStatisticsConfigPlistValid
- {NSDictionary *configDict = [self dictionaryFromUserStatisticsConfigPlist];XCTAssertNotNil(configDict,@"WGlobalUserStatisticsConfig.plist加载失败");
- [configDict enumerateKeysAndObjectsUsingBlock:^(NSString * _Nonnull key,id _Nonnull obj,BOOL * _Nonnull stop) {XCTAssert([obj isKindOfClass:[NSDictionaryclass]],@"plist文件结构可能已经改变,请确认");NSString *targetPageName = key;
- Class pageClass =NSClassFromString(targetPageName);id pageInstance = [[pageClass alloc] init];//一个pageDict对应一个页面,存放pageID,所有的action及对应的eventIDNSDictionary *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:[NSStringclass]],@"plist文件结构可能已经改变,请确认");XCTAssertNotNil(value,@"EVENT_ID为空,请确认");
- }];
- [controlEventIDDict enumerateKeysAndObjectsUsingBlock:^(NSString * _Nonnull key,id _Nonnull value,BOOL * _Nonnull stop) {XCTAssert([value isKindOfClass:[NSStringclass]],@"plist文件结构可能已经改变,请确认");NSString *actionName = key;
- SEL actionSel =NSSelectorFromString(actionName);XCTAssert([pageInstance respondsToSelector:actionSel],@"代码与plist文件函数不匹配,请确认:-[%@ %@]", targetPageName, actionName);//EVENT_ID不能为空XCTAssertNotNil(value,@"EVENT_ID为空,请确认");
- }];
- }];
- }
我们来测试一下,如果把
的
- HomeViewController
改成
- onFavBtnPressed:
后单元测试的结果就是:
- onMyFavBtnPressed:
单元测试不通过. png
这种改变给单测轻松捕捉到了,
只要 XCTAssert 的 log 够详细,维护起来其实相当轻松的。
上图中的 log 已经明确指出
方法发生了改变。
- -[HomeViewController onFavBtnPressed:]
2、代码中新增了响应事件
这种情况常见于新版本中有新的埋点需求。如果代码中新增了响应事件并且该响应事件是在 PM 要求的埋点列表中,但是 plist 有可能会漏掉该事件。这种情况是比较棘手的。上一种情况是基于 plist 列表去校验代码,这里就要反过来,根据代码去校验 plist 是否有缺失。但问题来了,一个项目中响应函数往往是非常多的,并不是任何响应函数都需要埋点。需要埋点的响应函数与其他响应函数并没有区别。对于这种情况,一种方式是加强 code review 避免忘记往配置表中添加埋点 (这简直就是废话);一种是:要求埋点响应函数的方法名中包含约定的字符串,比如收藏事件的方法名为
表示这个事件是需要埋点的。然后在单元测试中使用运行时 API
- onFavBtnPressed_UA:
取出标记了
- class_copyMethodList
的所有函数,随后到 plist 中校验是否存在。不存在则表示测试用例不通过,提示开发人员校验。
- _UA
代码略。如果对单元测试不熟悉,可以参考单元测试
小总结:合理的单元测试可以为本文方案的后期维护减轻相当大的负担,测试用例的完备性很重要,需要用心设计考虑周全。
以上就是结合运行时所设计出的用户统计思路全部内容。应该说该方案的可复用性与解耦程度都是不错的,既适合于新建的工程,也适合于已经创建的工程。看起来内容多,其实总结起来无非几个步骤:plist 配置表 + Hook + 单元测试。利用 Method Swizzling 把埋点代码集中管理其实也是合理的,有利于专人开发、跟踪及维护。当然以上思路只考虑简单的情形,更复杂的情况就需要变通了,但总体思路就是如此。思路可能不完美,但作为一种尝试也未尝不可。路都是走出来的。
本文 demo 地址,记得 star 噢!
iOS 运行时 RunTime 使用场景一:打点统计用户行为,深度解耦
来源: http://www.bubuko.com/infodetail-2134410.html