在发展突飞猛进的移动互联网时代,数据扮演着极其重要的角色。埋点做为一种最简单最直接的用户行为统计方式,可以全面精确的采集用户的使用习惯以及各功能点的迭代反馈等等,有了这些数据才能更好的驱动产品的决策设计和新业务场景的规划。本文旨在提出一种轻量级非侵入式的埋点方案,其主要有如下三方面优点git
该方案经过维护一个JSON
文件来指定埋点所在的类和方法,继而利用AOP
的方式在对应的类和方法执行时动态嵌入埋点代码。对于须要逻辑判断来肯定埋点值的场景,提供hook
方法的入参,以及所在类的属性值读取,根据相应的状态值设置不一样的埋点github
埋点配置JSON
表中包含须要hook
的类名class
和具体的事件event
信息,event
中包括hook
的方法和对应的埋点值。以下所示json
{ "version": "0.1.0", "tracking": [ { "class": "RJMainViewController", "event": { "rj_main_tracking": [ "tripTypeViewChangedWithIndex:", "tripLabClickWithLabKey:" ], "user_fp_slide_click": "clickNavLeftBtn", "user_fp_reflocate_click": "clickLocationBtn" } }, { "class": "RJTripHistoryViewModel", "event": { "user_mytrip_show": "tableView:didSelectRowAtIndexPath:" } }, { "class": "RJTripViewController", "event": { "rj_trip_tracking": "callServiceEvent" } } ] } 复制代码
简单来讲就是原本埋点须要手动在该方法写入埋点代码来记录埋点值,如今经过AOP
的方式物理隔离埋点代码和业务代码,避免埋点的逻辑侵入污染业务逻辑。埋点包括固定埋点和须要逻辑判断的场景化埋点,固定埋点以下所示数组
{ "class": "RJTripHistoryViewModel", "event": { "user_mytrip_show": "tableView:didSelectRowAtIndexPath:" } } 复制代码
RJTripHistoryViewModel
为类名,tableView:didSelectRowAtIndexPath:
为须要hook
的该类中的方法,而user_mytrip_show
则是具体的埋点值,也就是当RJTripHistoryViewModel
中的tableView:didSelectRowAtIndexPath:
方法执行的时候记录埋点值user_mytrip_show
bash
{ "class": "RJTripViewController", "event": { "rj_trip_tracking": "callServiceEvent" } }, 复制代码
对于场景化埋点,则须要提供一个impl
类来提供相应的逻辑判断。好比上述配置表中的rj_trip_tracking
为场景埋点的实现类,在该类中根据状态量返回对应的埋点值,即当callServiceEvent
方法执行时会去找rj_trip_tracking
这个埋点impl
同名类,取该类返回的埋点值记录埋点。须要注意到是event
中的key
值既能够做为埋点值也能够做为impl
的类名,埋点库会首先判断是否存在对应的类,存在即认为是impl
实现类,从该类中取具体的埋点值。反之,则认为是固定埋点值服务器
配置表中的类名和方法名须要对应,在
hook
的时候会去匹配,若是发现类中不存在对应的方法,则会自动触发断言markdown
对于固定的埋点,只须要在对应的方法执行时直接记录埋点,利用Aspects来hook
指定的类和方法,代码以下所示网络
[class aspect_hookSelector:sel withOptions:AspectPositionBefore usingBlock:^(id<AspectInfo> info) { [events enumerateObjectsUsingBlock:^(NSString *name, NSUInteger idx, BOOL *stop) { NSLog(@"<RJEventTracking> - %@", ename); }]; } error:&error]; 复制代码
为了便于检测无效的埋点,还需对hook
的类和方法进行匹配校验,若类中没有对应的方法,则抛出断言ide
+ (void)checkValidWithClass:(NSString *)class method:(NSString *)method { SEL sel = NSSelectorFromString(method); Class c = NSClassFromString(class); BOOL respond = [c respondsToSelector:sel] || [c instancesRespondToSelector:sel]; NSString *err = [NSString stringWithFormat:@"<RJEventTracking> - no specified method: %@ found on class: %@, please check", method, class]; NSAssert(respond, err); } 复制代码
场景化埋点主要为同一事件可是在多种状态或逻辑下不一样埋点的状况,好比同是联系客服的操做,在各类订单类型以及订单状态下所设置的埋点是不一样的。这个状况下,埋点库经过提供一个protocol
由埋点impl
类来实现,根据不一样的逻辑判断,返回对应的埋点值函数
@protocol RJEventTracking <NSObject>
- (NSString *)trackingMethod:(NSString *)method instance:(id)instance arguments:(NSArray *)arguments;
@end
复制代码
好比上文的rj_trip_tracking
类须要遵循RJEventTracking
协议,并根据相关逻辑判断返回对应的埋点值
埋点实现类的类名须要与埋点配置
JSON
中的event
里的key
保持一致,由于埋点库会经过检测是否有同名的类来实现插件式的埋点规则。另外,一个impl
能够对应多个method
方法
根据状态量来肯定埋点值。仍是联系客服埋点的例子,根据订单种类和订单状态来返回对应的埋点值,首先定义JSON
表中同名的impl
类,并遵循RJEventTracking
协议
#import "RJEventTracking.h" NS_ASSUME_NONNULL_BEGIN @interface rj_trip_tracking : NSObject <RJEventTracking> @end NS_ASSUME_NONNULL_END 复制代码
在.m文件中实现自定义埋点的协议方法trackingMethod:instance:arguments:
#import "rj_trip_tracking.h" @implementation rj_trip_tracking - (NSString *)trackingMethod:(NSString *)method instance:(id)instance arguments:(NSArray *)arguments { id dataManager = [instance property:@"dataManager"]; NSInteger orderStatus = [[dataManager property:@"orderStatus"] integerValue]; NSInteger orderType = [[dataManager property:@"orderType"] integerValue]; if ([method isEqualToString:@"callServiceEvent"]) { if (orderType == 1) { if (orderStatus == 1) { return @"user_inbook_psgservice_click"; } else if (orderStatus == 2) { return @"user_finishbook_psgservice_click"; } } else { return @"user_psgservice_click"; } } return nil; } @end 复制代码
在协议方法中,能够获取当前的实例(在这个示例下为RJTripViewController
)和入参数组。订单的类型和状态是存储在RJTripViewController
中的dataManager
属性中的,因此能够经过埋点库封装好的property:
方法来获取属性值,并根据属性值返回对应的埋点名称
@interface NSObject (RJEventTracking)
- (id)property:(NSString *)property;
@end
复制代码
属性值读取的实现为
- (id)property:(NSString *)property { return [NSObject runMethodWithObject:self selector:property arguments:nil]; } 复制代码
其中的原理很简单,就是将getter
方法封装到NSInvocation
中并invoke
读取返回值便可
+ (id)runMethodWithObject:(id)object selector:(NSString *)selector arguments:(NSArray *)arguments { if (!object) return nil; if (arguments && [arguments isKindOfClass:NSArray.class] == NO) { arguments = @[arguments]; } SEL sel = NSSelectorFromString(selector); NSMethodSignature *signature = [object methodSignatureForSelector:sel]; if (!signature) { return nil; } NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:signature]; invocation.selector = sel; invocation.arguments = arguments; [invocation invokeWithTarget:object]; return invocation.returnValue_obj; } 复制代码
须要根据JSON
中设置的所hook
方法的入参来肯定埋点名称的状况。好比在订单列表中点击所有,进行中,待支付,待评价,已完成等菜单项时分别埋点。被hook
的方法为tripLabClickWithLabKey:
其参数为UILabel
,原先代码中经过Label
的tag
判断是点击的哪一个子项,一样,咱们也能够获取到Label
的入参而后据此判断。因为参数只有一个,因此能够直接取arguments
第一个值
#import "rj_main_tracking.h" #import <UIKit/UIKit.h> static NSString *order_types[5] = { @"user_order_all_click", @"user_order_ongoing_click", @"user_order_unpay_click", @"user_order_unmark_click", @"user_order_finish_click" }; @implementation rj_main_tracking - (NSString *)trackingMethod:(NSString *)method instance:(id)instance arguments:(NSArray *)arguments { if ([method isEqualToString:@"tripLabClickWithLabKey:"]) { UILabel *label = arguments[0]; if (!label || label.tag > 4) { return nil; } return order_types[label.tag]; } else if ([method isEqualToString:@"tripTypeViewChangedWithIndex:"]) { return @"xx_ryan_jin"; } } @end 复制代码
经过AOP
来hook
方法时,能够获取到当前hook
方法所对应的实例对象和入参,在调用协议方法时,直接传给协议实现类
和读取属性值相似,也是在不一样场景下同一事件不一样埋点名称的状况,但获取的状态量不是当前实例对象的,而是某个方法的返回值,这种状况下能够经过埋点库提供的方法调用函数来实现
@interface NSObject (RJEventTracking)
- (id)performSelector:(NSString *)selector arguments:(nullable NSArray *)arguments;
@end
复制代码
好比获取某个页面的视图类型,而这个视图类型存储于单例对象中
[RJViewTypeModel sharedInstance].viewType
复制代码
该场景下则根据viewType的类型,来返回相应的埋点名称
- (NSString *)trackingMethod:(NSString *)method instance:(id)instance arguments:(NSArray *)arguments { NSString *labKey = [instance property:@"labKey"]; id viewTypeModel = [NSClassFromString(@"RJViewTypeModel") performSelector:@"sharedInstance" arguments:nil]; NSInteger viewType = [[viewTypeModel property:@"viewType"] integerValue]; if (viewType == 0) { if ([labKey isEqualToString:@"rj_view_begin_add"]) { return @"user_fp_book_on_click"; } if ([labKey isEqualToString:@"rj_view_end_add"]) { return @"user_fp_book_off_click"; } } if (viewType == 1) { if ([labKey isEqualToString:@"rj_view_begin_add"]) { return @"user_fr_on_click"; } if ([labKey isEqualToString:@"rj_view_end_add"]) { return @"user_fr_off_click"; } } return nil; } 复制代码
须要额外添加逻辑判断的场景,好比在订单详情页须要统计用户进入页面的查看行为,可是详情页的类型须要在网络请求后才能获取,并且该网络请求会定时触发,因此埋点hook
的方法会走屡次,该状况下,须要添加一个属性用来标记是否已记录埋点 。故而埋点库须要提供动态添加属性的功能
@interface NSObject (RJEventTracking)
- (id)extraProperty:(NSString *)property;
- (void)addExtraProperty:(NSString *)property defaultValue:(id)value;
@end
复制代码
在埋点实现impl
类里面,添加额外的属性来标记是否已记录过埋点
@implementation user_orderdetail_show - (NSString *)trackingMethod:(NSString *)method instance:(id)instance arguments:(NSArray *)arguments { if ([instance extraProperty:@"isRecorded"]) { return nil; } [instance addExtraProperty:@"isRecorded" defaultValue:@(YES)]; return @"user_orderdetail_show"; } @end 复制代码
使用addExtraProperty:defaultValue:
来给当前实例动态添加属性,而extraProperty:
方法则用来获取实例的某个额外属性。若是isRecorded
返回YES
表明已经记录过该埋点,返回nil
值来忽略该次埋点
上面示例中添加的isRecorded属性是由于埋点的需求,和业务逻辑无关,因此比较合理的方式是在埋点的插件
impl
类中添加,避免影响业务代码
埋点库动态添加属性的原理也很简单,利用runtime
的objc_setAssociatedObject
和objc_getAssociatedObject
方法来绑定属性到实例对象
- (id)extraProperty:(NSString *)property { return objc_getAssociatedObject(self, NSSelectorFromString(property)); } - (void)addExtraProperty:(NSString *)property defaultValue:(id)value { objc_setAssociatedObject(self, NSSelectorFromString(property), value, OBJC_ASSOCIATION_RETAIN_NONATOMIC); } 复制代码
埋点JSON
配置表能够由服务器提供接口,客户端在每次启动时经过接口获取最新埋点配置表,从而达到动态下发的目的,客户端拿到JSON
后,读取埋点信息并生效
[RJEventTracking loadConfiguration:[[NSBundle mainBundle] pathForResource:@"RJUserTracking" ofType:@"json"]]; 复制代码
读取的代码以下所示,主要逻辑为遍历埋点中的类和hook
的方法,并检测是固定埋点仍是场景化埋点,对于场景化埋点的状况查询是否有对应的埋点impl
实现类。固然,还需检测JSON
配置表的合法性,每一个类和其中的方法是否匹配
+ (void)loadConfiguration:(NSString *)path { NSData *data = [NSData dataWithContentsOfFile:path]; if (!data) { return; } NSDictionary *dict = [NSJSONSerialization JSONObjectWithData:data options:NSJSONReadingMutableLeaves error:nil]; NSString *version = dict[@"version"]; NSArray *ts = dict[@"tracking"]; [ts enumerateObjectsUsingBlock:^(NSDictionary *obj, NSUInteger idx, BOOL *stop) { Class class = NSClassFromString(obj[@"class"]); NSDictionary *ed = obj[@"event"]; NSMutableDictionary *td = [NSMutableDictionary dictionaryWithCapacity:0]; [ed enumerateKeysAndObjectsUsingBlock:^(NSString *key, id obj, BOOL *stop) { NSMutableArray *tArr = [NSMutableArray arrayWithCapacity:0]; [tArr addObjectsFromArray:[obj isKindOfClass:[NSArray class]] ? obj : @[obj]]; [tArr enumerateObjectsUsingBlock:^(NSString *m, NSUInteger idx, BOOL *stop) { if ([td.allKeys containsObject:m]) { NSMutableArray *ms = [td[m] mutableCopy]; if (![ms containsObject:key]) [ms addObject:key]; td[m] = ms; } else { td[m] = @[key]; } }]; }]; [td enumerateKeysAndObjectsUsingBlock:^(NSString *kmethod, NSArray <NSString *> *tArr, BOOL *stop) { SEL sel = NSSelectorFromString(kmethod); NSError *error = nil; [self checkValidWithClass:obj[@"class"] method:kmethod]; [class aspect_hookSelector:sel withOptions:AspectPositionBefore usingBlock:^(id<AspectInfo> info) { [tArr enumerateObjectsUsingBlock:^(NSString *name, NSUInteger idx, BOOL *stop) { NSString *ename = name; id<RJEventTracking> t = [NSClassFromString(name) new]; if (t && [t respondsToSelector:@selector(trackingMethod:instance:arguments:)]) { ename = [t trackingMethod:kmethod instance:info.instance arguments:info.arguments]; } if ([ename length]) { NSLog(@"<RJEventTracking> - %@", ename); } }]; } error:&error]; [self checkHookStatusWithClass:obj[@"class"] method:kmethod error:error]; }]; }]; } 复制代码
最后附上源码地址: github.com/rjinxx/RJEv…
pod 'RJEventTracking' 复制代码
在使用RJEventTracking的过程当中有遇到什么问题或者优化建议欢迎留言PR,谢谢。