iOS面向切面编程AOP实践

iOS面向切面编程AOP实践

Posted by xtcel on Wednesday, July 13, 2016

什么是AOP

AOP:Aspect Oriented Programming,译为面向切面编程。

在不修改源代码的情况下,通过运行时给程序添加统一功能的技术。

我觉得其中有两层涵义:

  • 第一:不修改源代码,即尽可能的解耦。
  • 第二:添加统一的功能,即我们能实现的是添加统一的单一的功能,在某处使用AOP,我们只能实现一项单一的功能。如:日志记录。当然你可以添加多个AOP的模块到项目中,每一个实现不同功能,但是每一个功能必须是单一的。

主要功能:日志记录,性能统计等。

iOS中如何实现AOP

有心的读者可能会发现,我在上面的AOP简介中并没有原话搬用百度百科的AOP简介,因为这是一篇iOS的AOP教程,在OC中我们就是用运行时来给实现AOP的。(我们基本不会使用预编译方式来实现AOP)

在iOS中实现AOP的核心技术是Runtime,使用Runtime的Method Swizzling黑魔法,我们可以移花接木,在运行时将方法的具体实现添油加醋、偷梁换柱。

点此移步了解Method Swizzling

AOP技术实现

越是底层的框架越是难用,任何语言皆是如此,同样Method Swizzling也不例外。那是否有一个第三库,可以让我们轻松驾驭Method Swizzling黑魔法呢?

当然有,而且不止一个,其中最著名的要数Aspects,Aspects的使用非常简单,整个库封装为两个方法:

+ (id<AspectToken>)aspect_hookSelector:(SEL)selector
                      withOptions:(AspectOptions)options
                       usingBlock:(id)block
                            error:(NSError **)error;
- (id<AspectToken>)aspect_hookSelector:(SEL)selector
                      withOptions:(AspectOptions)options
                       usingBlock:(id)block
                            error:(NSError **)error;

实际为同一个方法,这两个方法是同名不同类型的方法,一个是静态类方法,一个是成员方法。 使用这个方法可以给类的实例方法添加一个Block,并且对这个类的所有对象都会起作用。

所有的调用,都会是线程安全的。Aspects 使用了Objective-C 的消息转发机会,会有一定的性能消耗。所有对于过于频繁的调用,不建议使用 Aspects。Aspects更适用于视图/控制器相关的等每秒调用不超过1000次的代码。

代码示例

在调试应用时,使用Aspects动态添加日志记录功能。

[UIViewController aspect_hookSelector:@selector(viewWillAppear:) withOptions:AspectPositionAfter usingBlock:^(id<AspectInfo> aspectInfo) {
    //NSLog(@"😜😜😜Appear:--> %@", aspectInfo.instance);(为什么不使用此方式,请查看评论)
    NSLog(@"😜😜😜Appear:--> %@", NSStringFromClass([aspectInfo.instance class]));
} error:NULL];

通过这段代码,我们给UIViewController的viewWillAppear:方法添加了一个钩子,每当在调用viewWillAppear:后就会执行block中的代码。在此我们打印了一段Log(加上emoji表情就更好找log啦),通过log我们可以看到当前显示的页面的VC名称,从而快速定位到该类。还可以在ViewController的Dealloc时打印log:

[UIViewController aspect_hookSelector:NSSelectorFromString(@"dealloc") withOptions:AspectPositionBefore usingBlock:^(id<AspectInfo> aspectInfo) {
        //NSLog(@"😂😂😂Dealloc:---->: %@", aspectInfo.instance);(为什么不使用此方式,请查看评论)
        NSLog(@"😂😂😂Dealloc:---->: %@", NSStringFromClass([aspectInfo.instance class]));
    } error:NULL];

与上一段代码的微小差别是Selector换成了NSSelectorFromString(@“dealloc”),而不是@selector(dealloc),这是因为在ARC下面是不能直接手动调用Dealloc的,@selector(dealloc)会被编译器直接报错。

通过这个log,我们可以知道ViewController是否释放,如果没有释放很可能就是有循环引用,这时你务必仔细检查你的代码,这在性能调试和debug中非常有用。

AOP实战

在实际的项目开发中,事件统计是很多APP都会添加一项重要功能,它能统计用户的行为、商品的销售状况、商品查看数据等,今天的AOP实战是利用AOP实现APP事件统计。

这样统计?

假设产品有这么个需求:当用户在详情页点击添加到购物车按钮时,记录一下事件。我们实现起来大概会是这样

- (void)onBuyButtonClicked:(id)sender
{
    [XXXAnalytics track:eventName properties:properties];
}

这个需求就这样轻松搞定了,但细细想想还是有不少问题的:

  • 页面上会有其他的 Button,可能每个 Button 都要放上这么一段代码。
  • 这些统计其实跟具体的业务无关,没必要跟业务代码混杂在一起,不优雅。
  • 当改版或者重构时,有可能忘了把相应的事件统计代码迁移过去。
使用AOP实现统计

基于上面的问题,需要将事件统计这段代码抽离,与具体点击事件逻辑代码解耦。通过AOP在运行时将事件统计的代码加入到方法中正是这个问题的最佳解。代码大概如下:

[PBAGoodsDetailViewController aspect_hookSelector:@selector(onBuyButtonClicked:) withOptions:AspectPositionAfter usingBlock:^(id<AspectInfo> aspectInfo) {
        [XXXAnalytics track:eventName properties:properties];
    } error:NULL];
多个事件?

当然事件统计往往需要统计多个事件,这时我们只要对该方法稍微抽象一下就可以了,代码如下:

- (void)setupAnalytics
{
    [self trackEventWithClass:aViewController selector:@seletor(onBuyButtonTapped:) event:kSomeEventYouDefined];
    [self trackEventWithClass:bViewController selector:@seletor(followButtonTapped:) event:kAnotherEventYouDefined];
    // ...
}
- (void)trackEventWithClass:(Class)klass selector:(SEL)selector event:(NSString *)event
{
[klass aspect_hookSelector:@selector(selector) withOptions:AspectPositionAfter usingBlock:^(id<AspectInfo> aspectInfo) {
    [XXXAnalytics track:eventName properties:properties];
    } error:NULL];
}
使用plist文件配置事件统计

当事件非常多时,你的setupAnalytics方法将会变得越来越长,而且不好维护。如果我们可以利用一张表格来配置事件统计,看起来会更加直观简洁。 使用Xcode创建一个plist文件,其文件结构如图: EventList.plish 使用类名作为字典的键,值为一个数组,数组内存放该类下的事件列表,每个事件包含事件ID(EventId)和触发事件的方法名称(MethodName)。 在AppDelegate.m中,添加事件统计的代码如下:

- (void)setupAnalytics
{
    //设置事件统计
    //放到异步线程去执行
    __weak typeof(self) ws = self;
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
        //读取配置文件,获取需要统计的事件列表
        NSString *path = [[NSBundle mainBundle] pathForResource:@"EventList" ofType:@"plist"];
        NSDictionary *eventStatisticsDict = [[NSDictionary alloc] initWithContentsOfFile:path];
        for (NSString *classNameString in eventStatisticsDict.allKeys) {
            //使用运行时创建类对象
            const char * className = [classNameString UTF8String];
            //从一个字串返回一个类
            Class newClass = objc_getClass(className);
            NSArray *pageEventList = [eventStatisticsDict objectForKey:classNameString];
            for (NSDictionary *eventDict in pageEventList) {
                //事件方法名称
                NSString *eventMethodName = eventDict[@"MethodName"];
                SEL seletor = NSSelectorFromString(eventMethodName);

                NSString *eventId = eventDict[@"EventId"];

                [self trackEventWithClass:newClass selector:seletor event:eventId];
            }
        }
    });
}

至此,一切好像都好像完美了,但人生总是充满了变数。

事件需要传递参数

一个阳光明媚的上午,产品跑过来和我说事件统计需要传递一些参数,比如点击查看商品详情事件需要传递商品ID和商品名称。我当时心中就一万只草泥马在奔腾,但是没办法呀!我们只是搬砖的程序猿,只能低头默默的改。好不容易设计好的架构,眼看就要打回原形。后来仔细研究一番发现,其实Aspects是可以通过Block获取到方法传递的参数的,马上心情好了许多,修改思路马上再脑海形成。

首先,将Block改为^(id<AspectInfo> aspectInfo, NSDictionary *dict),第一个参数一定要为id<AspectInfo> aspectInfo,后面接方法传递的对应类型的参数,这样便可以接收到方法调用传递的参数。但是每一个事件需要传递的参数都各不相同,那我们要如何配置呢? 我的方案是:在plist的事件字典中加入一个键为Params,值为数组的键值对。修改后配置文件如下:

EventListV2 统计代码:

- (void)setupAnalytics
{
    //设置事件统计
    //放到异步线程去执行
    __weak typeof(self) ws = self;
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
        //读取配置文件,获取需要统计的事件列表
        NSString *path = [[NSBundle mainBundle] pathForResource:@"EventList" ofType:@"plist"];
        NSDictionary *eventStatisticsDict = [[NSDictionary alloc] initWithContentsOfFile:path];
        for (NSString *classNameString in eventStatisticsDict.allKeys) {
            //使用运行时创建类对象
            const char * className = [classNameString UTF8String];
            //从一个字串返回一个类
            Class newClass = objc_getClass(className);
            NSArray *pageEventList = [eventStatisticsDict objectForKey:classNameString];
            for (NSDictionary *eventDict in pageEventList) {
                //事件方法名称
                NSString *eventMethodName = eventDict[@"MethodName"];
                SEL seletor = NSSelectorFromString(eventMethodName);
                NSString *eventId = eventDict[@"EventId"];
                NSArray *params = eventDict[@"Params"];
                [self trackEventWithClass:newClass selector:seletor event:eventId params:params];
            }
        }
    });
}

统计方法:

- (void)trackEventWithClass:(Class)klass selector:(SEL)selector event:(NSString *)event params:(NSArray *)paramNames
{
    [klass aspect_hookSelector:@selector(selector) withOptions:AspectPositionAfter usingBlock:^(id<AspectInfo> aspectInfo, NSDictionary *dict) {
        //定义与事件相关的属性信息
        NSMutableDictionary *properties = [NSMutableDictionary dictionary];
        //如果有参数,那么把参数名和参数值拼接在eventID之后
        if (paramNames.count > 0) {
            if ([dict isKindOfClass:[NSDictionary class]]) {
                //获取dict
                for (NSString *paramName in paramNames) {
                    //添加所需参数
                    NSString *paramValue = [dict objectForKey:paramName];
                    properties[paramName] = paramValue;
                }
            }
        }

        [XXXAnalytics track:eventName properties:properties];
    } error:NULL];
}

将需要传递的参数以字典格式作为方法的第一个参数,Params中配置事件统计需要传递的参数的Key,通过此方法可以传递任何我们需要传递的参数,使用plist快速、灵活配置需要传递的参数。实战内容到此基本结束,我们使用AOP已经实现了一个低耦合、可灵活配置的事件统计。

还有一些挑战

在使用Aspects中我发现,如果方法为类方法时,并不会回调block。在调用aspect_hookSelector:withOptions:usingBlock:时,报Aspects: Block signature <NSMethodSignature: 0x7fa13345ce60> doesn't match (null).错误提示,意思是block不匹配,其根本原因在于无法使用Class获取该Class的类方法,通过runtime只能获取到成员方法,而类方法需要使用该Class的MetaClass获取,MateClass可以使用object_getClass(newClass)得到。代码如下:

[ws trackEventWithClass:object_getClass(newClass) selector:seletor event:eventId params:params];

修改后虽然不会报错,但是依然不会触发block。查看Aspects的github介绍发现,Aspects压根就不支持类方法,这让我很是苦恼。不过按道理应该是可以的,于是和同事讨论了一下,就使用Method Swizzling做了交换两个类方法的试验,结果是成功了。

查看Aspects的源代码发现,Aspects交换的是成员方法。无奈最后只能修改Aspects的源代码,我在其中一方法中加入了Class类型判断,如果是MetaClass,那么就初始化为类方法,而非成员方法。代码如下:

static void aspect_prepareClassAndHookSelector(NSObject *self, SEL selector, NSError **error) {
    NSCParameterAssert(selector);
    Class klass = aspect_hookClass(self, error);
    //TODO:Edit bu JackYong
    Method targetMethod;
    IMP targetMethodIMP;
    if (class_isMetaClass(klass)) {
        targetMethod = class_getClassMethod(klass, selector);
        targetMethodIMP = method_getImplementation(targetMethod);
    } else {
        targetMethod = class_getInstanceMethod(klass, selector);
        targetMethodIMP = method_getImplementation(targetMethod);
    }

修改后block和往常一样被调用了。暂时使用没有遇到什么问题,不过目测应该是有bug的,不然Aspects的开发者早就加了这判断。 Demo:https://github.com/yongca887/AOPDemo

Aspects的坑
  • 1.无法为类方法添加hooking(通过上面的方法暂时可以解决,不过还是不太建议使用)
  • 2.Block无法自动判断参数个数,自动匹配。如果你添加一个无参的方法,而Block中有跟一个参数,那么你会收到Block不匹配的错误。

参考 iOS 统计打点那些事