iOS 无埋点数据统计方案设计

先附上参考的文章:

iOS无埋点数据SDK实践之路
iOS无埋点数据统计实践

该方案的统计功能?

  • APP进入前台
  • 按钮点击 (点击次数、按钮名称、点击方法)
  • cell点击
  • 界面停留时长
  • APP进入后台

如何实现无埋点

利用运行时机制,将类原生方法替换成用户自定义的方法,相当于强行在原本调用栈中插入一个方法,我们在其中插入一段统计代码即可。

如何替换方法:Method Swizzling

函数的调用涉及到3个重要的点:Class、SEL、IMP,Calss作为类型,Method由SEL和IMP组成。我们通过交换Method的IMP达到替换被调用函数的目的。

1
2
3
4
5
6
7
8
9
10
11
核心方法:
- (void)sel_exchangeFirstSel:(SEL)sel1 secondSel:(SEL)sel2 {
[self sel_exchangeClass:[self class] FirstSel:sel1 secondSel:sel2];
}

- (void)sel_exchangeClass:(Class)Class FirstSel:(SEL)sel1 secondSel:(SEL)sel2 {
Method firstMethod = class_getInstanceMethod(Class, sel1);
Method secondMethod = class_getInstanceMethod(Class, sel2);
method_exchangeImplementations(firstMethod, secondMethod);

}

注: 这里主要交换的方法

系统类 类方法
UIControl sendAction:to:forEvent
UIGestureRecognizer addTarget:action:
initWithTarget:action:
UIView addGestureRecognizer
UITableView setDelegate
tableView:didSelectRowAtIndexPath:
NSNotificationCenter postNotification
postNotificationName:object:userInfo:
UIViewController viewDidAppear:
viewDidDisappear:
swizzling函数的时机

+(void)load函数在你动态加载或者静态引用了这个类的时候,该函数就会被执行,它并不需要你显示的去创建一个类后才会执行,同时它只会执行一次,几乎是完美的swizzling时机。

1
2
3
4
5
6
7
8
9
10
11
举例 UIControl

+ (void)load {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{

//创建新的sendAction:to:forEvent:方法
[self sel_exchangeFirstSel:@selector(sendAction:to:forEvent:) secondSel:@selector(ch_sendAction:to:forEvent:)];
});

}
加入数据统计代码

在swizzling成功后,我们在其中加入统计代码。为了保证响应链的完整,我们还需要调用替换过的方法,让事件传递下去,不去影响系统的处理。

  • 这里采用 内联函数 将该方法包起来使用 用于过滤黑名单(不参与统计的控制器)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/** 黑名单 不需要追踪的控制器 */
#import <UIKit/UIKit.h>
UIKIT_STATIC_INLINE BOOL kShouldTrackClass(Class aClass){
static NSSet *blacklistedClasses = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
// NSString *path = [[NSBundle mainBundle] pathForResource:@"BlackListed" ofType:@"plist"];
// NSArray *blacklistedClassNames = [NSArray arrayWithContentsOfFile:path];

NSArray *blacklistedClassNames = [LogDAO sharedInstance].blackListArray;
NSMutableSet *transformedClasses = [NSMutableSet setWithCapacity:blacklistedClassNames.count];
for (NSString *className in blacklistedClassNames) {
[transformedClasses addObject:NSClassFromString(className)];
}
blacklistedClasses = [transformedClasses copy];
});
return ![blacklistedClasses containsObject:aClass];
}
统计方式:操作数据库
  • 数据库地址:/var/mobile/Containers/Data/Application/······/Library/Caches/TheOnlineTax/Database/log.sqlite
  • 数据库表名为 info_log
  • 表中的参数 也是模型中的属性
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
typedef NS_ENUM(NSUInteger, LogType) {
LogTypeNone = 0, // 未知
LogTypeLaunch, // app 启动或前台
LogTypeButtonClick, // 按钮点击
LogTypeCellClick, // cell点击
LogTypeVCRemainTime,// 界面停留时间
LogTypeTerminated, // app 终止或后台
};

@property (nonatomic, copy) NSString *vc_id;// 控制器名称
@property (nonatomic) NSTimeInterval remainTime; //停留时间
@property (nonatomic, copy) NSString *btn_id;// 按钮名称
@property (nonatomic, copy) NSString *functionName;// 方法名称
@property (nonatomic, assign) NSInteger num;// 点击次数
@property (nonatomic) NSTimeInterval lastTime; //最后执行时间
@property (nonatomic, assign) LogType logType; //记录类型
  • 插入之前会判断是否存在 若存在只更新表
  • 按钮次数 是通过 vc_id 和 btn_id 进行查表 累加所得
  • 具体逻辑可看LogDAO文件
数据上传服务器

因没有后台存储这些统计出来的数据,暂时使用后端云Bmob存储

  • 触发上传的时机
1
2
3
目前设计 (上传成功数据库的表里数据清空)
1.是APP进入后台自动触发触发
2.用户退出登录操作触发
  • 上传的拼接数据
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
NSString *userID = @"0";//必须默认0
if ([AppDelegate trackGetUserID].length>0) {
userID = [AppDelegate trackGetUserID];
}
NSDictionary *dic = @{
//模型数组转成字典数组
@"list" : [LogDTO mj_keyValuesArrayWithObjectArray:[[LogDAO sharedInstance] getAllData]],
//应用名称
@"productName" : kDisplayName,
//bundleId
@"productID" : [[NSBundle mainBundle] bundleIdentifier],
//应用版本号
@"version" : kVersion,
//设备版本
@"osVersion" : [NSString stringWithFormat:@"%.1f",[DeviceAndSystemTool systemVersion]],
//设备机型
@"osDeviceType" : KStringIsEmpty([DeviceAndSystemTool getDeviceName]),
//网络类型
@"networkType" : KStringIsEmpty([DeviceAndSystemTool networkTypeName]),
//运营商
@"isp" : KStringIsEmpty([DeviceAndSystemTool wsd_telephonyNetworkInfo]),
//定位(省市区)
@"gps" : KStringIsEmpty(self.strLocationInfo)
};
  • APP启动会先获取服务器的数据 若有只在原有基础上更新不会新增一条数据(改动是TrackData和updateAT字段)会根据UserID字段区分数据
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/** 新增一条数据 */

BmobObject *trackBmob = [BmobObject objectWithClassName:@"TrackAction"];
[trackBmob setObject:dic forKey:@"TrackData"];
[trackBmob setObject:[AppDelegate trackGetUserID] forKey:@"UserID"];
[trackBmob saveInBackgroundWithResultBlock:^(BOOL isSuccessful, NSError *error) {
//进行操作
if (isSuccessful) {
if (handler) {
handler(YES,nil);
}
}else{
if (handler) {
handler(NO,error);
}
}
}];
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
/** 更新一条数据 */

//创建查表对象
BmobQuery *bquery = [BmobQuery queryWithClassName:@"TrackAction"];
//设置查询中该字段是有值的结果
[bquery whereKeyExists:@"UserID"];
//设置查询中该字段 值是否相等的结果
[bquery whereKey:@"UserID" equalTo:[AppDelegate trackGetUserID]];
//查询
[bquery findObjectsInBackgroundWithBlock:^(NSArray *array, NSError *error) {
if (error){
//进行错误处理
}else{
if (array) {
BmobObject *obj = array[0];

NSString *userID = @"0";//必须默认0
if ([AppDelegate trackGetUserID].length>0) {
userID = [AppDelegate trackGetUserID];
}
NSDictionary *dic = @{
//模型数组转成字典数组
@"list" : [LogDTO mj_keyValuesArrayWithObjectArray:[[LogDAO sharedInstance] getAllData]],
//userid
@"userid" : KStringIsEmpty(userID),
//应用名称
@"productName" : kDisplayName,
//bundleId
@"productID" : [[NSBundle mainBundle] bundleIdentifier],
//应用版本号
@"version" : kVersion,
//设备版本
@"osVersion" : [NSString stringWithFormat:@"%.1f",[DeviceAndSystemTool systemVersion]],
//设备机型
@"osDeviceType" : KStringIsEmpty([DeviceAndSystemTool getDeviceName]),
//网络类型
@"networkType" : KStringIsEmpty([DeviceAndSystemTool networkTypeName]),
//运营商
@"isp" : KStringIsEmpty([DeviceAndSystemTool wsd_telephonyNetworkInfo]),
//定位(省市区)
@"gps" : KStringIsEmpty(self.strLocationInfo)
};

BmobObject *obj1 = [BmobObject objectWithoutDataWithClassName:obj.className objectId:obj.objectId];
//设置cheatMode为YES
[obj1 setObject:dic forKey:@"TrackData"];
//异步更新数据
[obj1 updateInBackground];
}
}
}];

引用方法:

目前没有集成到pod上 只能手动导入 这是相关文件:GitHub

1
2
3
4
5
6
7
8
9
【 使用说明 】
1.需要依赖 pod 'MJExtension' 和 pod 'AFNetworking' 请确保项目中已有
2.将文件拖入项目中
3.需在pch文件中引用 #import "TrackAction.h"
4.需在AppDelegate.m文件中
a.调用 createTablesNeeded 创建数据库
b.重写 trackGetUserID 配置userID
c.重写 trackServiceURL 配置上传服务器的地址
5.可添加不需要统计的控制器在BlackListed.plist

更新部分【重要】

引用方法
  • 手动导入 这是相关文件:GitHub
  • 引用svn私有库 pod 'ASTrackAction','1.0.0'
使用说明
1
2
3
4
【 使用说明 】
1.依赖 pod 'MJExtension' 和 pod 'AFNetworking'
2.需在pch文件中引用 #import "TrackAction.h"
3.需在AppDelegate里的方法实现 配置track信息的方法 在LogDAO类中 (不开启埋点功能 注释即可)
上传的拼接数据

修改为
1.先判断是开启埋点功能
2.再判断是否自定义服务器地址 若有则AFN请求 若无则上传至Bmob后端云

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
    if (!self.isOpenTracker) {
return;
}

NSString *userID = @"0";//必须默认0
if (self.userId.length>0) {
userID = self.userId;
}
NSDictionary *dic = @{
//模型数组转成字典数组
@"list" : [LogDTO mj_keyValuesArrayWithObjectArray:[[LogDAO sharedInstance] getAllData]],
//userid
@"userid" : KStringIsEmpty(userID),
//应用名称
@"productName" : kDisplayName,
//bundleId
@"productID" : [[NSBundle mainBundle] bundleIdentifier],
//应用版本号
@"version" : kVersion,
//设备版本
@"osVersion" : [NSString stringWithFormat:@"%.1f",[DeviceAndSystemTool systemVersion]],
//设备机型
@"osDeviceType" : KStringIsEmpty([DeviceAndSystemTool getDeviceName]),
//网络类型
@"networkType" : KStringIsEmpty([DeviceAndSystemTool networkTypeName]),
//运营商
@"isp" : KStringIsEmpty([DeviceAndSystemTool wsd_telephonyNetworkInfo]),
//定位(省市区)
@"gps" : KStringIsEmpty(self.strLocationInfo)
};

if (!ISEMPTY(self.serviceURL)) {
#pragma mark - AFN

[TrackActionServiceManager postParameter:dic Success:^(NSURLSessionDataTask *operation, id responseObject) {

if (handler) {
handler(YES,nil);
}

} Failure:^(NSURLSessionDataTask *operation, NSError *error) {

if (handler) {
handler(NO,error);
}
}];
}else{

#pragma mark - 后端云SDK

BmobObject *trackBmob = [BmobObject objectWithClassName:@"TrackAction"];
[trackBmob setObject:dic forKey:@"TrackData"];
[trackBmob setObject:self.userId forKey:@"UserID"];
[trackBmob saveInBackgroundWithResultBlock:^(BOOL isSuccessful, NSError *error) {
//进行操作
if (isSuccessful) {
if (handler) {
handler(YES,nil);
}
}else{
if (handler) {
handler(NO,error);
}
}
}];
}