Lmsgsendnilself

Uninhibited Soul, Free Craziness

AOP解耦APP统计

| Comments

在我们开发过程中,日志统计能够帮助我们更加详细的了解app功能使用情况,而日志数据条目多且零散,我们经常可以在已有的项目中看到四处出现的统计,经常在某个方法内部最后一行加上该条日志的统计。 这样不仅导致代码松散,与业务无关,对业务代码带来侵染,增加维护成本,更糟糕的是,如果其他项目复用该功能,这些与功能无关的代码还要一行行删掉,这种强耦合是完全不必要的,那通过什么方式可以统计到想获取的数据并且不影响业务逻辑呢。

我们很直接就想到利用method swizzling。思路是对不同类添加相应的类目,利用+(void)load方法的特点,在该方法里面对需要统计的事件方法添加钩子。切片式的统计,不仅能够避免对业务代码带来干扰,同时集中放在一起,也方便添加、移除或者修改统计参数等信息。

方法已经有,那我们来分析需要统计日志的场景。
不难想到,常用的场景包括:点击事件、手势,进入的controller及停留时间、类方法、实例方法、代理方法

1 对于点击事件、手势 ,我们直接hook其相应的方法即可,我们第一时间想到的是所有点击事件都会走

1
2
// send the action. the first method is called for the event and is a point at which you can observe or override behavior. it is called repeately by the second.
- (void)sendAction:(SEL)action to:(nullable id)target forEvent:(nullable UIEvent *)event;


方法。

因此,我们可以hook该方法,根据action,target等信息,定位该点击事件。这看起来很美好,但真正事件起来,才会发现,在不同场景下的按钮,日志统计的内容,方式,参数都会有差别,而通过此方法,确实能达到统计效果,但会使得该方法无限制的增长,逻辑复杂,虽然节约了代码,但牺牲了清晰明了。因此,我放弃此统一管理的方式,而是采用对同一类button添加钩子,进行批量处理。而对不同场景的点击事件分别处理。这样更简单。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
+(void)load {
    [self swizzlingInstanceSelector:NSSelectorFromString(@"clickTestNormal") withMethod:NSSelectorFromString(@"hk_clickTestNormal")];
}
. . .
/**
 click method
 */
-(void)hk_clickTestNormal{
    NSLog(@"method:%s", __func__);

    [self hk_clickTestNormal];

    //statistic;
}

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
25
26
+(void)load {
    [self swizzlingClassSelector:NSSelectorFromString(@"privateClassMethod") withMethod:NSSelectorFromString(@"hk_privateClassMethod")];
    [self swizzlingInstanceSelector:NSSelectorFromString(@"privateInstanceMethod") withMethod:NSSelectorFromString(@"hk_privateInstanceMethod")];
}
. . .
/**
 class method
 */
+(void)hk_privateClassMethod {
    NSLog(@"method:%s", __func__);

    [self hk_privateClassMethod];

    //statistic;
}

/**
 instance method
 */
-(void)hk_privateInstanceMethod {
    NSLog(@"method:%s", __func__);

    [self hk_privateInstanceMethod];

    //statistic;
}

当然,这里有一点需要注意,为什么不直接用selector而是采用NSSelectorFromString(@“methodName”)的方式呢。原因有两点,第一就是对于私有方法,我们是无法直接获得方法名。而第二点,更重要的是,直接使用selector的方法,编译器会提示警告 ‘undeclared selector some_delegate_method',而且会导致delegate的方法有时候hook不到。清楚这一点,那么delegate的实现就同理可得了。

3 delegate 的hook

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
+(void)load {
    [self swizzlingInstanceSelector:NSSelectorFromString(@"someDelegateTriggerWithArg:") withMethod:NSSelectorFromString(@"hk_someDelegateTriggerWithArg:")];
 }
 . . .
 /**
 protocol method

 @param color arg
 */
-(void)hk_someDelegateTriggerWithArg:(UIColor *)color{

    NSLog(@"delegate method:%s", __func__);

    [self hk_someDelegateTriggerWithArg:color];

    //statistic;
}

4 最后,还有一种比较难处理的情况,即对于block内添加日志统计,这种情况比较特别了。
我们可以第一步,hook该方法并且获得该方法的实现指针。

1
2
3
4
5
6
static IMP originBlockMethodImp = NULL;
. . .
+(void)load {
  originBlockMethodImp = [self getInstanceMethodImpletionWith:NSSelectorFromString(@"clickTestBlockCompletion:")];
  [self swizzlingInstanceSelector:NSSelectorFromString(@"clickTestBlockCompletion:") withMethod:NSSelectorFromString(@"hk_clickTestBlockCompletion:")];
}

第二步,在hook的新方法中,构造一个新的block,并在objc_msgsend时发送参数中用新的block代替。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
  statistic arg which is in block scope

 @param block ClickCompletion
 */
-(void)hk_clickTestBlockCompletion:(ClickCompletion)block{
    NSLog(@"block arg method:%s", __func__);

    //replacement
    ClickCompletion hookBlock = ^(NSString *text){

        block(text);

        //statistic...;
    };

    //method-imp
    ((void (*)(id, SEL, void *))originBlockMethodImp)(self, NSSelectorFromString(@"clickTestBlockCompletion:"),(__bridge void *)hookBlock);
}

另外,需要注意的时,对controller的相关统计,由于controller都继承与UIViewcontroller,因此,直接可以hook viewWillAppear:viewWillDisappear:即可。并且可以管理一个map,存储对应的时间,在此就不赘述。
以上,便完成了通常情况下的统计。

当然,有一个更完善的method swzzling开源库Aspect,其支持方法前、后或者替换三种类型的处理,更加灵活,因此底层自己实现的method swizzling库可以用这个替换,最好不要对同一个方法两种hook方式都用,这样会有冲突,如果不可避免,那么需要专门解决此冲突。

进一步:
既然我们已经完成了日志统计的解耦,那么我们可以进一步思考。是否可以更加优化代码,类似于自动化统计,而非一条条自己实现?也就是说,将需要统计的日志添加到一个配置文件,然后系统批量自动进行hook部署。

要想实现这个,我们设计了配置信息的格式。
class :类名
hookType:类方法还是实例方法
methodName:方法名(一定要注意后面有几个参数)
methodArgs:需要统计的参数,元素arg类型为ArgModel:包括arg的index和arg的类型
classArgs:需要统计的类的属性或者变量,元素arg类型为ArgModel:包括arg的index和arg的类型
options:点击事件、手势,进入的controller及停留时间、类方法、实例方法、代理方法等(额外参数)

配置好每一个统计的统一格式,我们便可自动化对统计进行处理了,这里需要注意的是,对于参数为block的情况,我们是无法用这种方式实现的。因此,自动化hook只能处理非block情况。block情况下,用上面说的第一种手动配置的方式实现。

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
+ (void)load {

    Weakself();
    __block __weak id token = [[NSNotificationCenter defaultCenter]
                               addObserverForName:UIApplicationDidFinishLaunchingNotification
                                           object:nil
                                            queue:nil
                                       usingBlock:^(NSNotification *note) {
                                           Strongself();
                                           [strongself addTracker];
                                           [[NSNotificationCenter defaultCenter] removeObserver:token];
                                       }];
}

+ (void)addTracker {
    //配置统计日志服务器地址及参数
    [[YDStats sharedInstance]setup:^(NSObject<YDStatsConfig> *config) {
        config.category = @"iphone";
        config.keyfrom  = [[NSString alloc] initWithFormat: @"calculator.%@.iPhone", App_Version];
        config.deviceID = [[UIDevice currentDevice]uniqueDeviceIdentifier];

#ifdef DEBUG
        config.logServer = kStatsUrlLog;
#else
        config.logServer = kStatsUrlLog;
#endif
  //批量部署hook
  [self setupStatisticsByHookObjects:hookObjects];
    }];
}
. . .
/**
 @description hooks by datasource:hookObjects
 */
- (void)setupStatisticsByHookObjects:(NSDictionary *)hookObjects {

    [hookObjects enumerateKeysAndObjectsUsingBlock:^(NSString *className, NSArray *selectors,
                                                          BOOL *_Nonnull stop) {
        [self setupStatisticsClass:className selectors:selectors];
    }];
}

当然,还能进一步优化,就是服务端开一个接口,可以在服务端填写统计所有的配置信息,客户端便可以动态更新打点。这对于webview这种非原生页面载体的内容统计是非常好的。例如京东、天猫等经常更新活动,而同一个位置的内容统计就会随着活动的不同而发生变化。当然,对于原生页面的统计,个人认为用服务端动态更新意义不大。

Comments