2

记一次针对UIViewController的AOP尝试


前言

    最近在看casa大牛博客的架构系列其中的一章 iOS应用架构谈 view层的组织和调用方案。在“是否有必要让业务方统一派生ViewController”这一观点上,casa举了在阿里工作时的例子,他发现当在做Demo时搭建环境是一件很痛苦很麻烦的事情,另外当要把做好的Demo合到项目中去时需要修改各种继承关系,并且要提前去考虑接入后父类代码可能会造成的影响。casa认为统一派生没有必要,建议使用AOP的方式。
开始我表示认同,因为确实是遇到过想要搭建简单的环境但是出现各种代码耦合的问题,我不想在原来的庞大的项目上另拉分支去搞,因为每次编译都要好久;另外复制粘贴代码搭建环境的话各种黏连让人烦到死,所以我便根据casa大牛的思路进行了尝试,这篇文章就是记录了我这次尝试的过程,并且在尝试的过程也发现了一些问题并引发了一些思考。


目录

  • AOP思想介绍以及实现方案
  • 针对于UIViewController的AOP实现
  • 发现的一些问题
  • 总结与思考

AOP思想介绍以及实现方案

1.AOP思想

先贴上百度百科上AOP的解释说明

在软件业,AOP为Aspect Oriented Programming的缩写,意为:面向切面编程,通过预编译方式和运行期动态代理实现程序功能的统一维护的一种技术。
MMP,什么鬼?? 完全不理解。可能是理解能力比较差的缘故,起初不是很理解,对于AOP我总觉得是什么可望不可即的高深思想,直到看了casa大牛写的解释才明白这种思想其实很简单。2015-04-28 09:28补:关于AOP

我自己理解,AOP其实就是拦截一个流程中的某几个状态,并执行自己自定义操作的一种思想
在一个在程序执行1,2,3,4步的间隙跑到别处去执行你自定义的代码,这种做法就是面向切片编程。

使用AOP的好处:假如你要在某几个步骤中间做一些操作,比如做一些埋点或者监听、统计之类的事情,不用AOP也一样可以达到目的,直接往步骤之间塞代码。但是事实情况往往很复杂,直接塞进去的代码很有可能是跟原业务无关的代码,在同一份代码文件里面掺杂多种业务,这会带来业务间耦合,有时你可能觉得只是多了几句代码,但是随着后续需求的迭代,并且在团队开发中经常会存在多人修改同一份类文件,这种耦合会变得越来越粘合。那么为了降低这种耦合度,所以使用AOP的思想来剥离这部分代码。

其实我觉得代理模式中向外抛出的各种状态方法,这种做法很像AOP。比如UIScrollView的代理方法,使得在UIScrollView处在各个阶段时,将状态抛出给代理类去做自己想做的事,而代理类中的处理代码与UIScrollView自身的处理逻辑无关,不存在耦合,所以我个人觉得这些代理方法很像AOP中的切片。

2.实现方案

使用Method Swizzing

利用Objective-C的runtime特性,可以在运行时交换方法实现,使得你可以将自己自定义的代码注入指定的方法内。

// 工具方法
+ (void)swizzleMethod:(SEL)origSelector withMethod:(SEL)newSelector {
    Class class = self;
    
    Method originalMethod = class_getInstanceMethod(class, origSelector);
    Method swizzledMethod = class_getInstanceMethod(class, newSelector);
    
    BOOL didAddMethod = class_addMethod(class,
                                        origSelector,
                                        method_getImplementation(swizzledMethod),
                                        method_getTypeEncoding(swizzledMethod));
    if (didAddMethod) {
        class_replaceMethod(class,
                            newSelector,
                            method_getImplementation(originalMethod),
                            method_getTypeEncoding(originalMethod));
    } else {
        method_exchangeImplementations(originalMethod, swizzledMethod);
    }
}

// UIViewController+AOP类中:
+ (void)load {
    [UIViewController swizzleMethod:@selector(viewDidLoad) withMethod:@selector(aop_viewDidLoad)];
}

- (void)aop_viewDidLoad {
    [self aop_viewDidLoad];
    // 添加自定义的代码
    ...
}
使用Aspects

另外可以使用Aspects,一个现成关于的Method Swizzing的框架。
上面的代码只需要改写成如下代码:

// UIViewController+AOP类中:
+ (void)load {
    NSError *error;
    [UIViewController aspect_hookSelector:@selector(viewDidLoad) withOptions:AspectPositionAfter usingBlock:^(id<AspectInfo> aspectInfo) {
        UIViewController *vc = aspectInfo.instance;
        [vc aop_viewDidLoad];
    } error:&error];
    if (error) NSLog(@"%@", error);
}

- (void)aop_viewDidLoad {
    [self aop_viewDidLoad];
    // 添加自定义的代码
    ...
}

关于Ascepts的使用比较简单,只有两个方法

clipboard.png

其中参数:

selector:将要hook的方法
options:block参数的调用时机,可以设置block在原方法执行前/后执行,或者完全取代原方法
block:注入的block代码
error:错误回调对象

关于block中对原方法入参的获取:(以UIViewController的presentViewController:animated:completion:方法为例)

[UIViewController aspect_hookSelector:@selector(presentViewController:animated:completion:) withOptions:AspectPositionBefore usingBlock:^(id<AspectInfo> aspectInfo) {
        UIViewController *vc    = aspectInfo.instance;
        NSArray *arguments      = aspectInfo.arguments;
        [vc aop_presentViewController:arguments[0] animated:[arguments[1] boolValue] completion:arguments[2]];
    } error:nil];

具体使用还可以参考该框架在github上的介绍:Aspects


针对UIViewController的AOP实现

1.思路

这篇文章的想法是起因于casa大牛的建议,将UIViewController派生改为AOP方式实现,所以我打算将之前写的ZCPViewController改写为UIViewController+AOP。

1.分析原来继承方式实现的ZCPViewController。一般派生会重写生命周期方法,并且会加一些自定义的方法和属性。
2.对于生命周期方法当然是直接hook,本来也就是想要做这个的;
3.对于新增的属性和方法,如果不使用继承的话,只能采用分类的形式附加到UIViewController上;
4.对于分类的使用一定要考虑好作用域,因为分类会在任何直接或间接引入头文件的地方生效。(请看作用域的相关补充)
5.做Demo测试效果

2.分析

我们先看下派生类的功能:设置控制器的view背景颜色为白色,监听了键盘事件用于当点击view的时候收起键盘。

ZCPViewController.h
clipboard.png

ZCPViewController.m
clipboard.png
clipboard.png
clipboard.png

3.将生命周期方法拆分出来

我们需要创建一个分类单独处理hook的生命周期方法。

UIViewController+AOP.m
clipboard.png

其中:
在load方法中使用Aspects去hook viewDidLoad和viewDidDisappear:方法;
在block中转而调用相应的aop_viewDidLoad和aop_DidDisappear:方法去处理相关逻辑。至于为什么不直接在block中写处理代码,是因为大量代码堆积在load方法内,代码的可读性太差。

4.将属性和方法拆分出来

将生命周期方法拆分后,会发现出现了好多编译错误,这是因为找不到方法和属性的原因,不要急,现在我们把属性和方法也拆到分类中。

UIViewController+Property.h
clipboard.png

UIViewController+Property.m
clipboard.png

此处使用了runtime动态添加属性的写法,更为详细的使用方法请搜索objc_setAssociatedObject函数的使用。

UIViewController+Method.h
clipboard.png

UIViewController+Method.m
clipboard.png

最后,我们在UIViewController+AOP头文件中引入方法分类和属性分类即可消除编译错误。

UIViewController+AOP.h
clipboard.png

5.分类作用域的考虑

由于使用ZCPViewController时,只需要导入ZCPViewController.h就可以使用其暴露出来的所有公有方法和属性,因此也应当在导入UIViewController+AOP的地方可以使用所有的分类方法和属性。所以UIViewController+AOP选择了在.h文件中引入UIViewController+Property和UIViewController+Method。

补充:
其实当你将分类引入项目的时候,会自动编译.m文件(也就是会自动将.m文件加入到TARGETS->Build Phases->Compile Sources中),此时在项目的任何地方,即使不导入该分类的头文件,也可以通过performSelector:等方式去调用该分类方法,且不会出错!
所以不必太纠结分类是在.h中导入还是.m中导入,因为其作用域在你引入项目时就是全局生效的!
另外如果你在分类中重写了该类的方法,则整个项目都会被影响。
这也是为什么apple官方不建议你在分类中重写方法的原因,因为它造成的破坏是全局的,而不是仅仅局限于你导入分类头文件的地方。

6.做Demo测试效果

通过上面的步骤,我们成功的将ZCPViewController拆分开来,之后我们创建控制器时可以直接继承UIViewController。

让我们做一个小Demo来试一试成果吧?。

创建一个项目,然后在自动生成的ViewController类中加一个UITextField,预计的效果是:

1.控制台打印aop_viewDidLoad;
2.点击输入框弹出键盘,然后点击视图空白位置键盘会收起。
ViewController.m代码如下:
clipboard.png

运行起来后控制台打印:
clipboard.png

看上去很完美?,我们再试试点击输入框:

clipboard.png

点击后:
clipboard.png

纳尼,当点击输入框的时候整个屏幕竟然变白了~变白了~白了~ ?

最后在打断点找了很久之后,发现了问题出在这个地方:
clipboard.png

原因是当键盘弹出来的时候,管理键盘的控制器也是一个UIViewController的子类,所以当在aop_viewDidLoad方法中设置view的背景颜色时,同样也会改变键盘控制器的view背景颜色。

问题已经搞明白了,解决的话需要把aop_viewDidLoad方法中设置背景颜色的代码放到ViewController的viewDidLoad中去实现,

UIViewController+AOP.m
clipboard.png

ViewController.m
clipboard.png

验证一下,发现没有问题了。
clipboard.png


发现的一些问题

问题

1.hook UIViewController的viewDidLoad方法会影响到其所有的派生类。

继承UIViewController的不仅仅是自己自定义的类,UIKit框架中也有很多类继承UIViewController,此外可能还会有一些第三方框架(很少),那么在hook UIViewController的viewDidLoad方法后会统统影响到这些派生类。

2.Category影响范围较大

Xcode在将Category预编译后会影响整个项目,而不是只是在引入头文件的地方。

这个问题是在做Demo的时候无意中发现的。

针对问题2的Demo验证

Demo1

有三个控制器,继承关系为:UIViewController -> ZCPViewController -> ViewController。最后装载到window上的视图为ViewController。

下面为各个类的代码:

// ZCPViewController.h
@interface ZCPViewController : UIViewController

@end

// ZCPViewController.m
@implementation ZCPViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    NSLog(@"ZCPViewController viewDidLoad");
}

@end
// ViewController.h
@interface ViewController : ZCPViewController

@end

// ViewController.m
@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    NSLog(@"ViewController viewDidLoad");
}

@end

另外有两个分类,分别hook了ViewController和ZCPViewController的viewDidLoad方法

// ZCPViewController+AOP.h
@implementation ZCPViewController (AOP)

+ (void)load {
    NSError *error;
    [ZCPViewController aspect_hookSelector:@selector(viewDidLoad) withOptions:AspectPositionAfter usingBlock:^(id<AspectInfo> aspectInfo) {
        ZCPViewController *vc = aspectInfo.instance;
        [vc aop_viewDidLoad];
    } error:&error];
    if (error) NSLog(@"%@", error);
}

- (void)aop_viewDidLoad {
    NSLog(@"ZCPViewController+AOP aop_viewDidLoad");
}

@end
// ViewController+AOP.m

@implementation ViewController (AOP)

+ (void)load {
    NSError *error;
    [ViewController aspect_hookSelector:@selector(viewDidLoad) withOptions:AspectPositionAfter usingBlock:^(id<AspectInfo> aspectInfo) {
        ViewController *vc = aspectInfo.instance;
        [vc aop_viewDidLoad];
    } error:&error];
    if (error) NSLog(@"%@", error);
}

- (void)aop_viewDidLoad {
    NSLog(@"ViewController+AOP aop_viewDidLoad");
}

@end

(注意,我在ViewController.h和.m文件中没有导入任何Category的头文件。!!)
在运行起来之后会发现控制台打印:
clipboard.png

就是说我们不能在一个继承树上hook两个相同的方法。

然后我们把ViewController+AOP中的hook删掉,只保留ZCPViewController+AOP的hook

// ViewController+AOP.m
@implementation ViewController (AOP)

- (void)aop_viewDidLoad {
    NSLog(@"ViewController+AOP aop_viewDidLoad");
}

@end

然后我们跑起来,看控制台打印的信息:
clipboard.png

有没有觉得很诡异,我们明明hook了ZCPViewController的viewDidLoad方法,也没有去导入ViewController+AOP.h这个头文件,怎么会打印“ViewController+AOP aop_viewDidLoad”,按理说不是应该打印“ZCPViewController+AOP aop_viewDidLoad”吗?闹鬼了???

现在我们来捋一下代码执行的顺序:

-> run
-> ViewController: viewDidLoad
-> ViewController: [super viewDidLoad]
-> ZCPViewController: viewDidLoad
-> ZCPViewController: NSLog(@"ZCPViewController viewDidLoad")
-> ZCPViewController+AOP: hookBlock
-> ZCPViewController+AOP: [vc aop_viewDidLoad]
-> ??
-> ViewController: NSLog(@"ViewController viewDidLoad")

现在有没有发现问题,在??上面[vc aop_viewDidLoad]这句。vc对象是ViewController实例,当在执行aop_viewDidLoad方法的时候,根据Objective-C语言继承类的方法执行顺序和其动态特性,这句代码执行后会先判断ViewController类是否能响应aop_viewDidLoad方法,如果可以响应则执行,如果不行则判断父类能否响应该方法。
这就说明了一个结果:ViewController类可以响应ViewController+AOP中写的aop_viewDidLoad方法!!

根据控制台打印的结果,猜想应该是正确的,Category中写的方法都会被响应到。

Demo2

你可能会觉得不相信,那让我们再来做一个尝试,删掉前面写的关于ZCPViewController+AOP的相关代码和NSLog代码,只保留下面的代码

// ViewController.m
@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    if ([self respondsToSelector:@selector(aop_viewDidLoad)]) {
        [self performSelector:@selector(aop_viewDidLoad)];
    }
}

@end
// ViewController+AOP
@implementation ViewController (AOP)

- (void)aop_viewDidLoad {
    NSLog(@"ViewController+AOP aop_viewDidLoad");
}

@end

话不多说,看控制台打印结果吧:
clipboard.png

动态语言就是这么牛。猜想再次得到了验证。


总结与思考

1.还是有必要统一派生
2.hook框架类的方法不确定性太大
3.要理解Category的设计初衷,规范使用
4.AOP的应用场景

1.还是有必要统一派生

先说一下对于本篇文章主要做的事情的思考吧,通过了上面的一番尝试对于casa大牛的用AOP方式完全取代统一派生的想法不敢苟同。原因有如下几点。

1.统一派生的做法是封装UIViewController,在创建新控制器的时候去继承它,将公共的任务和配置交给这个统一的父类去处理。
以ZCPViewController为例,统一派生的方式你在ZCPViewController中写的公共代码的作用范围是其所有的自定义子类。而采用AOP方式hook UIViewController的话,这些公共代码的作用范围是所有UIViewController的子类。
其实你写这些公共代码的初衷只是想要针对自己自定义的这些子类。
针对作用范围比较之下我觉得使用继承更为合理一些。

ps: 后面我又想到一种解决办法,创建一个空的ZCPViewController,然后把hook UIViewController的代码都改为hook ZCPViewController。这样改写后,虽然使用了AOP方式,但是作用范围被局限在ZCPViewController和其子类中。这种想法与使用继承方式的初衷一致。

2.其实即使是使用了AOP,搭建环境仍然很麻烦,因为如果你想做一个Demo需要一部分项目环境,你仍旧需要将这些分类拷出来导入Demo中,而之前继承方式写代码的这部分耦合现在转移到分类中的代码里了。剥离起来还是很烦。
举个例子:假设继承方式ZCPViewController的viewDidLoad方法中写了关于处理本地缓存的代码,用到了ZCPCache类,而ZCPCache又用到了其他的xxx类。现在使用AOP后,这部分代码跑到了aop_viewDidLoad中。当我们要搭建环境时,都需要将ZCPCache相关的一系列类拷出来,还是拔出萝卜带出泥。
其次在将Demo合并到原本的项目中去时仍旧需要考虑hook的处理代码和Demo代码之间的相互影响。

3.使用Category处理原本派生类添加的属性需要使用runtime,写起来麻烦而且比较怪异。

综上所述,我觉的还是有必要统一派生。但是针对于这些痛点,我觉得更应该从代码的结构上下手,将代码分块剥离,尽量降低块与块之间的耦合。

2.hook框架类的方法不确定性太大

我们已经在上面的Demo中发现了hook框架类的问题,你在aop_viewDidLoad中设置背景颜色时无论如何也想不到会影响键盘控制器。这种地毯式轰炸的方式还是很有可能会误伤友军的,由于UIKit并未开源,所以无法确定对现在框架造成的影响,另外或许以后apple会更新UIKit,对以后框架的影响也很难预测。且用且小心。

3.要理解Category的设计初衷,规范使用

Category的设计初衷是对原有类扩充一组方法,比如MGBox框架里面的UIView+MGEasyFrame类中有一组处理frame的方法,top、left、bottom、right等等。
此外尽量不要去添加属性和重写方法。原因在上面已经说了很多次了。

4.AOP的应用场景

那么说了这么多,AOP思想有什么应用场景呢?
其实我们想一下,做Demo时遇问题是由于hook方法注入的代码与UIViewController自身有密切的关系(这也是为了尝试不使用派生,结果却不太好)。AOP的应用场景主要用在注入与源类无关的代码。比如像统计、埋点,或者本地存储之类的,只要是与源类无耦合或者不影响到源类即可。我们注入的代码既然确定与UIViewController无关不会影响到它,那么为什么不开心的抽取出来呢。

总结

所以我觉得在使用时,与UIViewController有关的代码写到统一派生类中,无关的代码可以使用AOP抽取出来。这也是本篇文章尝试下来最后的结论。


参考文章:

iOS应用架构谈 view层的组织和调用方案
漫谈iOS AOP编程之路


花飞蝶舞剑
123 声望72 粉丝

Zcp大官人的iOS小站