iOS动态性(二)可复用而且高度解耦的用户统计埋点实现(Method Swizzling(Hook))

本文主要是介绍iOS动态性(二)可复用而且高度解耦的用户统计埋点实现(Method Swizzling(Hook)),希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!



用户统计.jpeg


用户行为统计(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工具类下文会用到。比如现在我们要勾住UIViewControllerviewWillAppear:方法,可以这样做:

@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、面向切面编程的介绍请参考这里

三、基于运行时的埋点方案

为了便于下文叙述,先引入一个简单的项目,共有两个页面(HomeViewControllerDetailViewController),如下:


1.gif

需求是

  1. 统计两个页面的展示与离开次数
  2. 统计收藏、分享单击事件的次数
  3. 对现有工程代码影响越小越好
1)统计两个页面的展示与离开次数

这部分应该比较直观了,摒弃掉在每个controller中埋点的方式,我们对UIViewController添加category从而Hook到viewWillAppear:viewWillDisappear:。在这两个方法中注入埋点代码:


埋点代码注入.jpg

这时候问题来了,项目中每个页面都会有自己的页面事件编号(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"]) {pageEventID = bEnterPage ? @"EVENT_DETAIL_ENTER_PAGE" : @"EVENT_DETAIL_LEAVE_PAGE";}//else if (<#expression#>)...
}

当然,我们可以有更优雅的方式,比如用一个配置表替代上面一长串的if判断,这样无论页面数怎么增加,代码始终是那么一小段。我们新建一个WGlobalUserStatisticsConfig.plist的配置表来存放每个页面在进入以及离开时的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;
}

效果如下:


页面埋点.gif

以上就是完成了页面进出统计的埋点,并且达到了我们的第三点预期:对现有代码基本无影响。通过Method Swizzling的方式现有的工程甚至不需要import任何文件!后期代码变动时需要维护的仅仅是plist配置表。

2)统计收藏、分享单击事件的次数

与上一节思路一致,要做到解耦显然需要通过category+hook来实现。本文demo中收藏跟分享都是UIButton类型,可以考虑添加UIButton的catogory。但更好的方式是添加UIControl的category,这样可以让埋点代码覆盖到所有UIControl的子类中去,比如button、switch、segment等,提高复用性。
既然要hook,那就要清楚到底要hookUIControl的哪(几)个方法,只有部分方法是满足埋点需求的,最好是所hook的方法能提供target、actionName等信息。这是个尝试的过程。
UIControl的方法列表有以下:


UIControl方法列表.png


通过观察方法名和参数,我们有理由怀疑是倒数第二个,因其携带了不少貌似有价值的信息:

- (void)sendAction:(SEL)action to:(nullable id)target forEvent:(nullable UIEvent *)event;

于是写出测试代码看看:

@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判断:


单击事件配置表结构.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

到这里先做了阶段性的总结,本文提出的思路有以下优越性:

  • 与工程代码基本解耦,避免引入“脏代码”
  • 即使后期工程代码发生重构,需要修改的仅仅是plist配置表
  • 维护配置表比维护散落在工程各个角落的代码简单

四、基于单元测试的后期维护

俗话说,创业难守业更难。前面的思路基本可以完成初步的埋点需求。但是在实际项目中代码重构是很频繁的。这意味着在多人协作开发、代码重构频繁的项目中响应事件方法甚至页面名称都可能被改掉,造成事件ID获取不到导致埋点失效。
代码变动的情况无非以下几种(这里只介绍响应事件发生改变的情况):

1、响应事件方法名称改变或者删除

比如收藏事件原先是onFavBtnPressed:,之后被改成onFavouriteBtnPressed:。代码发生变动但是plist配置表中由于开发人员疏忽忘记同步修改了。这种疏忽在开发压力大进度赶的情况下是有很大概率发生的。由于代码与配置表不匹配将导致eventID为nil。在这种情况下单元测试就很有必要了,使用完备的测试用例能在发版前检测到这种不匹配情况从而避免埋点失效。
在单元测试中我们首先读取plist配置文件,遍历所有的页面。在一个页面内遍历所有的ControlEventIDs,对每个响应函数名进行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:[NSDictionary class]], @"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:[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为空,请确认");}];}];}

我们来测试一下,如果把HomeViewControlleronFavBtnPressed:改成onMyFavBtnPressed:后单元测试的结果就是:


单元测试不通过.png

这种改变给单测轻松捕捉到了,

只要XCTAssert的log够详细,维护起来其实相当轻松的。

上图中的log已经明确指出-[HomeViewController onFavBtnPressed:]方法发生了改变。

2、代码中新增了响应事件

这种情况常见于新版本中有新的埋点需求。如果代码中新增了响应事件并且该响应事件是在PM要求的埋点列表中,但是plist有可能会漏掉该事件。这种情况是比较棘手的。上一种情况是基于plist列表去校验代码,这里就要反过来,根据代码去校验plist是否有缺失。但问题来了,一个项目中响应函数往往是非常多的,并不是任何响应函数都需要埋点。需要埋点的响应函数与其他响应函数并没有区别。
对于这种情况,一种方式是加强code review避免忘记往配置表中添加埋点(这简直就是废话);一种是:要求埋点响应函数的方法名中包含约定的字符串,比如收藏事件的方法名为onFavBtnPressed_UA:表示这个事件是需要埋点的。然后在单元测试中使用运行时APIclass_copyMethodList取出标记了_UA的所有函数,随后到plist中校验是否存在。不存在则表示测试用例不通过,提示开发人员校验。

代码略。如果对单元测试不熟悉,可以参考单元测试

小总结:
合理的单元测试可以为本文方案的后期维护减轻相当大的负担,测试用例的完备性很重要,需要用心设计考虑周全。

五、结语

以上就是结合运行时所设计出的用户统计思路全部内容。应该说该方案的可复用性与解耦程度都是不错的,既适合于新建的工程,也适合于已经创建的工程。看起来内容多,其实总结起来无非几个步骤:plist配置表+Hook+单元测试。利用Method Swizzling把埋点代码集中管理其实也是合理的,有利于专人开发、跟踪及维护。当然以上思路只考虑简单的情形,更复杂的情况就需要变通了,但总体思路就是如此。
思路可能不完美,但作为一种尝试也未尝不可。路都是走出来的。

本文demo地址,记得star噢!


转自:http://www.jianshu.com/p/0497afdad36d

这篇关于iOS动态性(二)可复用而且高度解耦的用户统计埋点实现(Method Swizzling(Hook))的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/681381

相关文章

关于数据埋点,你需要了解这些基本知识

产品汪每天都在和数据打交道,你知道数据来自哪里吗? 移动app端内的用户行为数据大多来自埋点,了解一些埋点知识,能和数据分析师、技术侃大山,参与到前期的数据采集,更重要是让最终的埋点数据能为我所用,否则可怜巴巴等上几个月是常有的事。   埋点类型 根据埋点方式,可以区分为: 手动埋点半自动埋点全自动埋点 秉承“任何事物都有两面性”的道理:自动程度高的,能解决通用统计,便于统一化管理,但个性化定

hdu1043(八数码问题,广搜 + hash(实现状态压缩) )

利用康拓展开将一个排列映射成一个自然数,然后就变成了普通的广搜题。 #include<iostream>#include<algorithm>#include<string>#include<stack>#include<queue>#include<map>#include<stdio.h>#include<stdlib.h>#include<ctype.h>#inclu

hdu1496(用hash思想统计数目)

作为一个刚学hash的孩子,感觉这道题目很不错,灵活的运用的数组的下标。 解题步骤:如果用常规方法解,那么时间复杂度为O(n^4),肯定会超时,然后参考了网上的解题方法,将等式分成两个部分,a*x1^2+b*x2^2和c*x3^2+d*x4^2, 各自作为数组的下标,如果两部分相加为0,则满足等式; 代码如下: #include<iostream>#include<algorithm

【C++】_list常用方法解析及模拟实现

相信自己的力量,只要对自己始终保持信心,尽自己最大努力去完成任何事,就算事情最终结果是失败了,努力了也不留遗憾。💓💓💓 目录   ✨说在前面 🍋知识点一:什么是list? •🌰1.list的定义 •🌰2.list的基本特性 •🌰3.常用接口介绍 🍋知识点二:list常用接口 •🌰1.默认成员函数 🔥构造函数(⭐) 🔥析构函数 •🌰2.list对象

【Prometheus】PromQL向量匹配实现不同标签的向量数据进行运算

✨✨ 欢迎大家来到景天科技苑✨✨ 🎈🎈 养成好习惯,先赞后看哦~🎈🎈 🏆 作者简介:景天科技苑 🏆《头衔》:大厂架构师,华为云开发者社区专家博主,阿里云开发者社区专家博主,CSDN全栈领域优质创作者,掘金优秀博主,51CTO博客专家等。 🏆《博客》:Python全栈,前后端开发,小程序开发,人工智能,js逆向,App逆向,网络系统安全,数据分析,Django,fastapi

让树莓派智能语音助手实现定时提醒功能

最初的时候是想直接在rasa 的chatbot上实现,因为rasa本身是带有remindschedule模块的。不过经过一番折腾后,忽然发现,chatbot上实现的定时,语音助手不一定会有响应。因为,我目前语音助手的代码设置了长时间无应答会结束对话,这样一来,chatbot定时提醒的触发就不会被语音助手获悉。那怎么让语音助手也具有定时提醒功能呢? 我最后选择的方法是用threading.Time

Android实现任意版本设置默认的锁屏壁纸和桌面壁纸(两张壁纸可不一致)

客户有些需求需要设置默认壁纸和锁屏壁纸  在默认情况下 这两个壁纸是相同的  如果需要默认的锁屏壁纸和桌面壁纸不一样 需要额外修改 Android13实现 替换默认桌面壁纸: 将图片文件替换frameworks/base/core/res/res/drawable-nodpi/default_wallpaper.*  (注意不能是bmp格式) 替换默认锁屏壁纸: 将图片资源放入vendo

安卓链接正常显示,ios#符被转义%23导致链接访问404

原因分析: url中含有特殊字符 中文未编码 都有可能导致URL转换失败,所以需要对url编码处理  如下: guard let allowUrl = webUrl.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else {return} 后面发现当url中有#号时,会被误伤转义为%23,导致链接无法访问

C#实战|大乐透选号器[6]:实现实时显示已选择的红蓝球数量

哈喽,你好啊,我是雷工。 关于大乐透选号器在前面已经记录了5篇笔记,这是第6篇; 接下来实现实时显示当前选中红球数量,蓝球数量; 以下为练习笔记。 01 效果演示 当选择和取消选择红球或蓝球时,在对应的位置显示实时已选择的红球、蓝球的数量; 02 标签名称 分别设置Label标签名称为:lblRedCount、lblBlueCount

Kubernetes PodSecurityPolicy:PSP能实现的5种主要安全策略

Kubernetes PodSecurityPolicy:PSP能实现的5种主要安全策略 1. 特权模式限制2. 宿主机资源隔离3. 用户和组管理4. 权限提升控制5. SELinux配置 💖The Begin💖点点关注,收藏不迷路💖 Kubernetes的PodSecurityPolicy(PSP)是一个关键的安全特性,它在Pod创建之前实施安全策略,确保P