iOS 面向切面编程AOP实践

本文参考了网上的文章,结合自己理解,配合友盟统计。

什么是AOP

AOP:Aspect Oriented Programming,译为面向切面编程.
在不修改源代码的情况下,通过运行时给程序添加统一功能的技术。
我觉得其中有两层涵义:

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

iOS中如何实现AOP

有心的读者可能会发现,我在上面的AOP简介中并没有原话搬用百度百科的AOP简介,因为这是一篇iOS的AOP教程,在OC中我们就是用运行时来给实现AOP的。(我们基本不会使用预编译方式来实现AOP)
在iOS中实现AOP的核心技术是Runtime,使用Runtime的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文件,其文件结构如图:


plist.png

使用类名作为字典的键,值为一个数组,数组内存放该类下的事件列表,每个事件包含事件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];
            }
        }
    });
}

事件需要传递参数

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

- (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。
查看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/zbwbb/AOPDemo

Aspects的坑

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

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

推荐阅读更多精彩内容