iOS埋点技术方案总结

一、可视化埋点

  1. 可视化埋点的出现,是为解决代码埋点流程复杂、成本高、新开发的页面(H5、或者服务端下发的 json 去生成相应页面)不能及时拥有埋点能力。
  2. 前端在「埋点编辑模式」下,以“可视化”的方式去配置、绑定关键业务模块的路径到前端可以唯一确定到view的xpath过程。用户每次操作的控件,都生成一个 xpath 字符串,然后通过接口将 xpath 字符串(Cell.2. TableView. ViewController.HXFuter)到真正的业务模块(期货App-首页-热点资讯-第2条热点被点击)的映射关系上传到服务端。

优点:数据量相对准确、后期前端开发成本低
缺点:前期控件的唯一识别、定位都需要额外开发;可视化平台的前期开发成本较高;对于额外需求的分析可能会比较困难

二、代码手动埋点

该方案,需要在工程代码中,写埋点相关代码。因为侵入了业务代码,对业务代码产生了污染,缺点是埋点的成本较高、且违背了单一原则。

例1:假如你需要知道用户在点击“购买按钮”时的相关信息(手机型号、App版本、页面路径、停留时间、动作等等),那么就需要在按钮的点击事件里面去写埋点统计的代码。这样明显的弊端就是在之前业务逻辑的代码上面又多出了埋点的代码。由于埋点代码分散、埋点的工作量很大、代码维护成本较高、后期重构很头痛。

例2:假如 App 采用了 Hybrid 架构,当 App 的第一版本发布的时候 H5 的关键业务逻辑统计是由 Native 定义好关键逻辑(比如H5调起了Native的分享功能,那么存在一个分享的埋点事件)的桥接。假如某天增加了一个扫一扫功能,未定义扫一扫的埋点桥接,那么 H5 页面变动的时候,Native 埋点代码不去更新的话,变动的 H5 的业务就未被精确统计。

优点:产品、运营工作量少,对照业务映射表就可以还原出相关业务场景、数据精细无须大量的加工和处理
缺点:开发工作量大、前期需要和运营、产品指定的好业务标识,以便产品和运营进行数据统计分析

三、无痕埋点

通过技术手段无差别地记录用户在前端页面上的行为。可以正确的获取 PV、UV、IP、Action、Time 等信息。
缺点:开发需要维护一套业务文件,增加开发成本
优点:代码层面,和业务代码不耦合,可以剥离出来

针对目前App中采用代码手动埋点的现状,现在考虑使用无痕埋点的方案,对埋点代码进行剥离:
主要采用了 https://github.com/RylanJIN/RJEventTracking
实现原理可以参看源码,使用详情可参看博客:阅读原文

轻量级非侵入式埋点方案

在发展日新月异的移动互联网时代,数据扮演着极其重要的角色。埋点作为一种最简单最直接的用户行为统计方式,能够全面精确的采集用户的使用习惯以及各功能点的迭代反馈等等,有了这些数据才能更好的驱动产品的决策设计和新业务场景的规划。本文旨在提出一种轻量级非侵入式的埋点方案,其主要有以下三方面优势

• 支持动态下发埋点配置
• 物理隔离埋点代码和业务代码
• 插件式的埋点功能实现

该方案通过维护一个JSON文件来指定埋点所在的类和方法,继而利用AOP的方式在对应的类和方法执行时动态嵌入埋点代码。对于需要逻辑判断来确定埋点值的场景,提供hook方法的入参,以及所在类的属性值读取,根据相应的状态值设置不同的埋点

埋点配置

埋点配置JSON表中包含需要hook的类名class和具体的事件event信息,event中包括hook的方法和对应的埋点值。如下所示

{
    "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

{
    "class": "RJTripViewController",
    "event": {
        "rj_trip_tracking": "callServiceEvent"
    }
},

对于场景化埋点,则需要提供一个impl类来提供相应的逻辑判断。比如上述配置表中的rj_trip_tracking为场景埋点的实现类,在该类中根据状态量返回对应的埋点值,即当callServiceEvent方法执行时会去找rj_trip_tracking这个埋点impl同名类,取该类返回的埋点值记录埋点。需要注意到是event中的key值既可以作为埋点值也可以作为impl的类名,埋点库会首先判断是否存在对应的类,存在即认为是impl实现类,从该类中取具体的埋点值。反之,则认为是固定埋点值

配置表中的类名和方法名需要对应,在hook的时候会去匹配,如果发现类中不存在对应的方法,则会自动触发断言

固定埋点

对于固定的埋点,只需要在对应的方法执行时直接记录埋点,利用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的类和方法进行匹配校验,若类中没有对应的方法,则抛出断言

+ (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];
        }];
    }];
}

最后附上源码地址: https://github.com/RylanJIN/RJEventTracking


最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,185评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,445评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,684评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,564评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,681评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,874评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,025评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,761评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,217评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,545评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,694评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,351评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,988评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,778评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,007评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,427评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,580评论 2 349

推荐阅读更多精彩内容