/ iOS 开发

利用 Objective-C Runtime 结合 AOP 实现打点方案

遇到的问题

在产品开发中,我们经常需要记录用户的各种行为,所以打点是必不可少的。

首先,我们先来看下现有的打点代码:

- (void)btnLoginAction {
		//
		... 业务代码
		//
    [[ZM_Tracker sharedTracker] trackStructuredEvent:@"button" action:@"2" 		label:trackInfo property:nil value:0 context:nil timestamp:0];
}

缺点很明显:

  • 打点代码和业务代码耦合过于紧密,对现有代码侵入性比较严重,导致业务一旦发生变动,打点必须重新添加,而且需要移除旧的打点,后续的维护成本高
  • 打点代码散落赞各个类中,不便于集中管理

我们可以用 AOP + Runtime 来解决这个问题。

AOP(Aspect Oriented Programming)面向切面编程

面向切面编程是一种通过预编译方式和运行期动态代理实现在不修改源代码的情况下给程序动态统一添加功能的一种技术。

在 Objective-C 里,我们可以利用运行时的特性为切面增加以下行为:

  • 在类的特定方法调用前运行特定的代码
  • 在类的特定方法调用后运行特定的代码
  • 增加代码来替代原来的类的方法的实现

也就是说我们可以把打点逻辑从类里面剥离出来,利用 Objective-C 的运行时特性为切面增加打点逻辑。比如在某个方法执行完毕后自动调用打点逻辑。

Objective-C Runtime

因为 Objective-C 是一门动态语言,所以它总是想办法把一些决定工作从编译链接推迟到运行时。也就是说只有编译器是不够的,还需要一个运行时系统 (runtime system) 来执行编译后的代码。这就是 Runtime 存在的意义,它是整个Objective-C运行框架的一块基石。

Runtime 目前有两个版本,分别为 Objective-C 2.0 的 Modern 版和 Objective-C 1.0 的 Legacy 版。Modern 版只能运行在 iOS 和 OS X 10.5 之后的64位程序中,OS X 10.5 之前较老的32位程序仍采用 Legacy 版本的 Runtime 系统。

Runtime 基本是用 C 和汇编写的,Runtime 的代码是开源的,以下为 Runtime 代码开源地址:
http://www.opensource.apple.com/source/objc4/

具体实现方案

我们可以采用基于 AOP 和 使用 Runtime 实现的 Aspect 方案来进行打点工作,以便实现打点代码和业务代码的分离。

安装 Aspects

最简单的办法当然是:
pod "Aspects"

如何使用?

Aspects 只有两个API,通过封装了 swizzle method 替换或增加一些方法来实现。

所以打点我们可以这样做:

  • 针对页面的 page view,可以用下面这个方法打点:
[UIViewController aspect_hookSelector:@selector(viewDidAppear:)
                              withOptions:AspectPositionAfter
                               usingBlock:^(id<AspectInfo> aspectInfo) {
                               
                               //在此处可以根据 aspectInfo 获取当前页面类名
                               dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
                                       NSString *className = NSStringFromClass([[aspectInfo instance] class]);
                                       NSString *pageImp = configs[className][kLoggingPageImpression];
                                       if (pageImp) {
                                           NSLog(@"%@", pageImp);
                                       }
                                   });
                               
                               } error:NULL];
  • 针对页面内的 按钮点击方法 ,可以用下面这个方法打点:
//首先获取对象,再调用对象的打点方法
Class clazz = NSClassFromString(className);
[clazz aspect_hookSelector:@"selectName"
                               withOptions:AspectPositionBefore
                                usingBlock:^(id<AspectInfo> aspectInfo) {
                                    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
                                        block(aspectInfo);
                                    });
                                } error:NULL];

这样业务代码和打点代码就可以独立出来了,但是这样做依然有很大的问题,那就是我们需要对每个页面方法都写一遍这样的代码,而这仅仅是页面名称和方法名称不同而已。所以我们可以把页面名称和方法名称独立出来,写成一个配置文件,以后只需要维护这个配置文件就可以了。

使用配置文件

AppDelegate 添加 Category ,写入配置:

NSDictionary *config = @{
                             @"ViewController": @{
                                     kLoggingPageImpression: @"ViewController",
                                     kLoggingTrackedEvents: @[
                                             @{
                                                 kLoggingEventName: @"btnNextAction",
                                                 kLoggingEventSelectorName: @"btnNextAction",
                                                 kLoggingEventHandlerBlock: ^(id<AspectInfo> aspectInfo) {
                                                     NSLog(@"btnNextAction");
                                                 },
                                                 },
                                             ],
                                     },
                             
                             @"SecondViewController": @{
                                     kLoggingPageImpression: @"SecondViewController",
                                     kLoggingTrackedEvents: @[
                                             @{
                                                 kLoggingEventName: @"btnBackAction",
                                                 kLoggingEventSelectorName: @"btnBackAction",
                                                 kLoggingEventHandlerBlock: ^(id<AspectInfo> aspectInfo) {
                                                     NSLog(@"btnBackAction");
                                                 },
                                                 },
                                             
                                             ],
                                     }
                             };

也可以写成 json 文件,方便从服务器动态更新。不过,这样的需求应该比较少。

打点方法也可以独立出来:

Logging.h

#import <Foundation/Foundation.h>
#import <Aspects.h>


#define kLoggingPageImpression @"kLoggingPageImpression"
#define kLoggingTrackedEvents @"kLoggingTrackedEvents"
#define kLoggingEventName @"kLoggingEventName"
#define kLoggingEventSelectorName @"kLoggingEventSelectorName"
#define kLoggingEventHandlerBlock @"kLoggingEventHandlerBlock"

@interface Logging : NSObject

+ (void)setupWithConfiguration:(NSDictionary *)configs;
@end

Logging.m

@import UIKit;


@implementation Logging


typedef void (^AspectHandlerBlock)(id<AspectInfo> aspectInfo);


+ (void)setupWithConfiguration:(NSDictionary *)configs
{
    // Hook Page Impression
    [UIViewController aspect_hookSelector:@selector(viewDidAppear:)
                              withOptions:AspectPositionAfter
                               usingBlock:^(id<AspectInfo> aspectInfo) {
                                   dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
                                       NSString *className = NSStringFromClass([[aspectInfo instance] class]);
                                       NSString *pageImp = configs[className][kLoggingPageImpression];
                                       if (pageImp) {
                                           NSLog(@"%@", pageImp);
                                       }
                                   });
                               } error:NULL];
    
    // Hook Events
    for (NSString *className in configs) {
        Class clazz = NSClassFromString(className);
        NSDictionary *config = configs[className];
        
        if (config[kLoggingTrackedEvents]) {
            for (NSDictionary *event in config[kLoggingTrackedEvents]) {
                SEL selekor = NSSelectorFromString(event[kLoggingEventSelectorName]);
                AspectHandlerBlock block = event[kLoggingEventHandlerBlock];
                
                [clazz aspect_hookSelector:selekor
                               withOptions:AspectPositionBefore
                                usingBlock:^(id<AspectInfo> aspectInfo) {
                                    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
                                        block(aspectInfo);
                                    });
                                } error:NULL];
                
            }
        }
    }
}
@end
  • 页面打点只需要用 UIViewController 调用一次,以后所有页面中的 viewDidAppear 执行后都会被 Hook。
  • 同样的,所有页面的方法名不能有重复的,否则会 Hook 失败。

存在的问题

  • 打点时需要附加的参数使用 Runtime 无法获取;
  • UITableView 的 DidSelect 方法难以打点;

解决办法

  • 可以将 UITableView 的 DidSelect 方法逻辑使用独立的方法替代。

这种打点只能满足 80% 的打点需求,对于需要传参数的打点比较难满足需求。我觉得需要传参数的打点丢给服务器端更为合适,因为一般传递的参数服务器端都能拿到,而且很多情况下 iOS、Android、web 都公用一套接口,所以打点代码也只需要一套,打点统计出来的结果也更加精准。