iOS 如何实现 Aspect Oriented Programming (上)

111194012-ba39dff8c951dd0e

前言

在“Runtime病院”住院的后两天,分析了一下AOP的实现原理。“出院”后,发现Aspect库还没有详细分析,于是就有了这篇文章,今天就来说说iOS 是如何实现Aspect Oriented Programming。

目录

  • 1.Aspect Oriented Programming简介
  • 2.什么是Aspects
  • 3.Aspects 中4个基本类 解析
  • 4.Aspects hook前的准备工作
  • 5.Aspects hook过程详解
  • 6.关于Aspects的一些 “坑”

一.Aspect Oriented Programming简介

面向切面的程序设计(aspect-oriented programming,AOP,又译作面向方面的程序设计观点导向编程剖面导向程序设计)是计算机科学中的一个术语,指一种程序设计范型。该范型以一种称为侧面(aspect,又译作方面)的语言构造为基础,侧面是一种新的模块化机制,用来描述分散在对象函数中的横切关注点(crosscutting concern)。

侧面的概念源于对面向对象的程序设计的改进,但并不只限于此,它还可以用来改进传统的函数。与侧面相关的编程概念还包括元对象协议、主题(subject)、混入(mixin)和委托。

AOP通过预编译方式和运行期动态代理实现程序功能的统一维护的一种技术。

OOP(面向对象编程)针对业务处理过程的实体及其属性行为进行抽象封装,以获得更加清晰高效的逻辑单元划分。

AOP则是针对业务处理过程中的切面进行提取,它所面对的是处理过程中的某个步骤阶段,以获得逻辑过程中各部分之间低耦合性的隔离效果

OOP和AOP属于两个不同的“思考方式”。OOP专注于对象的属性和行为的封装,AOP专注于处理某个步骤和阶段的,从中进行切面的提取。

举个例子,如果有一个判断权限的需求,OOP的做法肯定是在每个操作前都加入权限判断。那日志记录怎么办?在每个方法的开始结束的地方都加上日志记录。AOP就是把这些重复的逻辑和操作,提取出来,运用动态代理,实现这些模块的解耦。OOP和AOP不是互斥,而是相互配合。

在iOS里面使用AOP进行编程,可以实现非侵入。不需要更改之前的代码逻辑,就能加入新的功能。主要用来处理一些具有横切性质的系统性服务,如日志记录、权限管理、缓存、对象池管理等。

二. 什么是Aspects

121194012-a31568e70017325f

Aspects是一个轻量级的面向切面编程的库。它能允许你在每一个类和每一个实例中存在的方法里面加入任何代码。可以在以下切入点插入代码:before(在原始的方法前执行) / instead(替换原始的方法执行) / after(在原始的方法后执行,默认)。通过Runtime消息转发实现Hook。Aspects会自动的调用super方法,使用method swizzling起来会更加方便。

这个库很稳定,目前用在数百款app上了。它也是PSPDFKit的一部分,PSPDFKit是一个iOS 看PDF的framework库。作者最终决定把它开源出来。

三.Aspects 中4个基本类 解析

我们从头文件开始看起。

1.Aspects.h

在头文件中定义了一个枚举。这个枚举里面是调用切片方法的时机。默认是AspectPositionAfter在原方法执行完之后调用。AspectPositionInstead是替换原方法。AspectPositionBefore是在原方法之前调用切片方法。AspectOptionAutomaticRemoval是在hook执行完自动移除。

定义了一个AspectToken的协议,这里的Aspect Token是隐式的,允许我们调用remove去撤销一个hook。remove方法返回YES代表撤销成功,返回NO就撤销失败。

又定义了一个AspectInfo协议。AspectInfo protocol是我们block语法里面的第一个参数。

instance方法返回当前被hook的实例。originalInvocation方法返回被hooked方法的原始的invocation。arguments方法返回所有方法的参数。它的实现是懒加载。

头文件中还特意给了一段注释来说明Aspects的用法和注意点,值得我们关注。

Aspects利用的OC的消息转发机制,hook消息。这样会有一些性能开销。不要把Aspects加到经常被使用的方法里面。Aspects是用来设计给view/controller 代码使用的,而不是用来hook每秒调用1000次的方法的。

添加Aspects之后,会返回一个隐式的token,这个token会被用来注销hook方法的。所有的调用都是线程安全的。

关于线程安全,下面会详细分析。现在至少我们知道Aspects不应该被用在for循环这些方法里面,会造成很大的性能损耗。

Aspects整个库里面就只有这两个方法。这里可以看到,Aspects是NSobject的一个extension,只要是NSObject,都可以使用这两个方法。这两个方法名字都是同一个,入参和返回值也一样,唯一不同的是一个是加号方法一个是减号方法。一个是用来hook类方法,一个是用来hook实例方法。

方法里面有4个入参。第一个selector是要给它增加切面的原方法。第二个参数是AspectOptions类型,是代表这个切片增加在原方法的before / instead / after。第4个参数是返回的错误。

重点的就是第三个入参block。这个block复制了正在被hook的方法的签名signature类型。block遵循AspectInfo协议。我们甚至可以使用一个空的block。AspectInfo协议里面的参数是可选的,主要是用来匹配block签名的。

返回值是一个token,可以被用来注销这个Aspects。

注意,Aspects是不支持hook 静态static方法的

这里定义了错误码的类型。出错的时候方便我们调试。

2.Aspects.m

#import 导入这个头文件是为了下面用到的自旋锁。#import 和 #import 是使用Runtime的必备头文件。

定义了AspectBlockFlags,这是一个flag,用来标记两种情况,是否需要Copy和Dispose的Helpers,是否需要方法签名Signature 。

在Aspects中定义的4个类,分别是AspectInfo,AspectIdentifier,AspectsContainer,AspectTracker。接下来就分别看看这4个类是怎么定义的。

3. AspectInfo

AspectInfo对应的实现

AspectInfo是继承于NSObject,并且遵循了AspectInfo协议。在其 – (id)initWithInstance: invocation:方法中,把外面传进来的实例instance,和原始的invocation保存到AspectInfo类对应的成员变量中。- (NSArray *)arguments方法是一个懒加载,返回的是原始的invocation里面的aspects参数数组。

aspects_arguments这个getter方法是怎么实现的呢?作者是通过一个为NSInvocation添加一个分类来实现的。

为原始的NSInvocation类添加一个Aspects分类,这个分类中只增加一个方法,aspects_arguments,返回值是一个数组,数组里面包含了当前invocation的所有参数。

对应的实现

– (NSArray *)aspects_arguments实现很简单,就是一层for循环,把methodSignature方法签名里面的参数,都加入到数组里,最后把数组返回。

关于获取方法所有参数的这个- (NSArray *)aspects_arguments方法的实现,有2个地方需要详细说明。一是为什么循环从2开始,二是[self aspect_argumentAtIndex:idx]内部是怎么实现的。

131194012-970e6eab65c407ae

先来说说为啥循环从2开始。

Type Encodings作为对Runtime的补充,编译器将每个方法的返回值和参数类型编码为一个字符串,并将其与方法的selector关联在一起。这种编码方案在其它情况下也是非常有用的,因此我们可以使用@encode编译器指令来获取它。当给定一个类型时,@encode返回这个类型的字符串编码。这些类型可以是诸如int、指针这样的基本类型,也可以是结构体、类等类型。事实上,任何可以作为sizeof()操作参数的类型都可以用于@encode()。

在Objective-C Runtime Programming Guide中的Type Encoding一节中,列出了Objective-C中所有的类型编码。需要注意的是这些类型很多是与我们用于存档和分发的编码类型是相同的。但有一些不能在存档时使用。

注:Objective-C不支持long double类型。@encode(long double)返回d,与double是一样的。

141194012-5db676475c03c0be

OC为支持消息的转发和动态调用,Objective-C Method 的 Type 信息以 “返回值 Type + 参数 Types” 的形式组合编码,还需要考虑到 self
和 _cmd 这两个隐含参数:

按照上面的表,我们可以知道,编码出来的字符串,前3位分别是返回值Type,self隐含参数Type @,_cmd隐含参数Type :。

所以从第3位开始,是入参。

假设我们以- (void)tapView:(UIView *)view atIndex:(NSInteger)index为例,打印一下methodSignature

number of arguments = 4,因为有2个隐含参数self和_cmd,加上入参view和index。

ARGUMENT RETURN VALUE 0 1 2 3
methodSignature v @ : @ q

第一个argument的frame {offset = 0, offset adjust = 0, size = 0, size adjust = 0}memory {offset = 0, size = 0},返回值在这里不占size。第二个argument是self,frame {offset = 0, offset adjust = 0, size = 8, size adjust = 0}memory {offset = 0, size = 8}。由于size = 8,下一个frame的offset就是8,之后是16,以此类推。

至于为何这里要传递2,还跟aspect_argumentAtIndex具体实现有关系。

再来看看aspect_argumentAtIndex的具体实现。这个方法还要感谢ReactiveCocoa团队,为获取方法签名的参数提供了一种优雅的实现方式。

getArgumentTypeAtIndex:这个方法是用来获取到methodSignature方法签名指定index的type encoding的字符串。这个方法传出来的字符串直接就是我们传进去的index值。比如我们传进去的是2,其实传出来的字符串是methodSignature对应的字符串的第3位。

由于第0位是函数返回值return value对应的type encoding,所以传进来的2,对应的是argument2。所以我们这里传递index = 2进来,就是过滤掉了前3个type encoding的字符串,从argument2开始比较。这就是为何循环从2开始的原因。

151194012-fd4ebb88f6917c23

_C_CONST是一个常量,用来判断encoding的字符串是不是CONST常量。

这里的Type和OC的Type 是完全一样的,只不过这里是一个C的char类型。

WRAP_AND_RETURN是一个宏定义。这个宏定义里面调用的getArgument:atIndex:方法是用来在NSInvocation中根据index得到对应的Argument,最后return的时候把val包装成对象,返回出去。

在下面大段的if – else判断中,有很多字符串比较的函数strcmp。

比如说strcmp(argType, @encode(id)) == 0,argType是一个char,内容是methodSignature取出来对应的type encoding,和@encode(id)是一样的type encoding。通过strcmp比较之后,如果是0,代表类型是相同的。

下面的大段的判断就是把入参都返回的过程,依次判断了id,class,SEL,接着是一大推基本类型,char,int,short,long,long long,unsigned char,unsigned int,unsigned short,unsigned long,unsigned long long,float,double,BOOL,bool,char *这些基本类型都会利用WRAP_AND_RETURN打包成对象返回。最后判断block和struct结构体,也会返回对应的对象。

这样入参就都返回到数组里面被接收了。假设还是上面- (void)tapView:(UIView *)view atIndex:(NSInteger)index为例子,执行完aspects_arguments,数组里面装的的是:

总结,AspectInfo里面主要是 NSInvocation 信息。将NSInvocation包装一层,比如参数信息等。

4. AspectIdentifier

161194012-06c0591a32e26b52

对应实现

在instancetype方法中调用了aspect_blockMethodSignature方法。

这个aspect_blockMethodSignature的目的是把传递进来的AspectBlock转换成NSMethodSignature的方法签名。

AspectBlock的结构如下

这里定义了一个Aspects内部使用的block类型。对系统的Block很熟悉的同学一眼就会感觉两者很像。不熟悉的可以看看我之前分析Block的文章。文章里,用Clang把Block转换成结构体,结构和这里定义的block很相似。

171194012-0a6448a6c928dded

了解了AspectBlock的结构之后,再看aspect_blockMethodSignature函数就比较清楚了。

AspectBlockRef layout = (__bridge void *)block,由于两者block实现类似,所以这里先把入参block强制转换成AspectBlockRef类型,然后判断是否有AspectBlockFlagsHasSignature的标志位,如果没有,报不包含方法签名的error。

注意,传入的block是全局类型的

desc就是原来block里面对应的descriptor指针。descriptor指针往下偏移2个unsigned long int的位置就指向了copy函数的地址,如果包含Copy和Dispose函数,那么继续往下偏移2个(void )的大小。这时指针肯定移动到了const char signature的位置。如果desc不存在,那么也会报错,该block不包含方法签名。

到了这里,就保证有方法签名,且存在。最后调用NSMethodSignature的signatureWithObjCTypes方法,返回方法签名。

举例说明aspect_blockMethodSignature最终生成的方法签名是什么样子的。

const char *signature最终获得的字符串是这样

v32@?0@””8@”UIView”16q24是Block

对应的Type。void返回值的Type是v,32是offset,@?是block对应的Type,@“”是第一个参数,@”UIView”是第二个参数,NSInteger对应的Type就是q了。

每个Type后面跟的数字都是它们各自对应的offset。把最终转换好的NSMethodSignature打印出来。

回到AspectIdentifier中继续看instancetype方法,获取到了传入的block的方法签名之后,又调用了aspect_isCompatibleBlockSignature方法。

这个函数的作用是把我们要替换的方法block和要替换的原方法,进行对比。如何对比呢?对比两者的方法签名。

入参selector是原方法。

先比较方法签名的参数个数是否相等,不等肯定是不匹配,signaturesMatch = NO。如果参数个数相等,再比较我们要替换的方法里面第一个参数是不是_cmd,对应的Type就是@,如果不是,也是不匹配,所以signaturesMatch = NO。如果上面两条都满足,signaturesMatch = YES,那么就进入下面更加严格的对比。

这里循环也是从2开始的。举个例子来说明为什么从第二位开始比较。还是用之前的例子。

这里我要替换的原方法是UIView:atIndex:,那么对应的Type是v@:@q。根据上面的分析,这里的blockSignature是之前调用转换出来的Type,应该是v@?@””@”UIView”q。

ARGUMENT RETURN VALUE 0 1 2 3
methodSignature v @ : @ q
blockSignature v @? @”” @”UIView” q

methodSignature 和 blockSignature 的return value都是void,所以对应的都是v。methodSignature的argument 0 是隐含参数 self,所以对应的是@。blockSignature的argument 0 是block,所以对应的是@?。methodSignature的argument 1 是隐含参数 _cmd,所以对应的是:。blockSignature的argument 1 是,所以对应的是@””。从argument 2开始才是方法签名后面的对应可能出现差异,需要比较的参数列表。

最后

如果经过上面的比较signaturesMatch都为NO,那么就抛出error,Block无法匹配方法签名。

如果这里匹配成功了,就会blockSignature全部都赋值给AspectIdentifier。这也就是为何AspectIdentifier里面有一个单独的属性NSMethodSignature的原因。

AspectIdentifier还有另外一个方法invokeWithInfo。

注释也写清楚了,这个判断是强迫症患者写的,到了这里block里面的参数是不会大于原始方法的方法签名里面参数的个数的。

把AspectInfo存入到blockInvocation中。

这一段是循环把originalInvocation中取出参数,赋值到argBuf中,然后再赋值到blockInvocation里面。循环从2开始的原因上面已经说过了,这里不再赘述。最后把self.block赋值给blockInvocation的Target。

181194012-8df87d2f18b96857

总结,AspectIdentifier是一个切片Aspect的具体内容。里面会包含了单个的 Aspect 的具体信息,包括执行时机,要执行 block 所需要用到的具体信息:包括方法签名、参数等等。初始化AspectIdentifier的过程实质是把我们传入的block打包成AspectIdentifier。

5. AspectsContainer

18-51194012-82f31215a595a89f

对应实现

AspectsContainer比较好理解。addAspect会按照切面的时机分别把切片Aspects放到对应的数组里面。removeAspects会循环移除所有的Aspects。hasAspects判断是否有Aspects。

AspectsContainer是一个对象或者类的所有的 Aspects 的容器。所有会有两种容器。

值得我们注意的是这里数组是通过Atomic修饰的。关于Atomic需要注意在默认情况下,由编译器所合成的方法会通过锁定机制确保其原子性(Atomicity)。如果属性具备nonatomic特质,则不需要同步锁。

6. AspectTracker
191194012-b942a638eef4b741

对应实现

AspectTracker这个类是用来跟踪要被hook的类。trackedClass是被追踪的类。trackedClassName是被追踪类的类名。selectorNames是一个NSMutableSet,这里会记录要被hook替换的方法名,用NSMutableSet是为了防止重复替换方法。selectorNamesToSubclassTrackers是一个字典,key是hookingSelectorName,value是装满AspectTracker的NSMutableSet。

addSubclassTracker方法是把AspectTracker加入到对应selectorName的集合中。removeSubclassTracker方法是把AspectTracker从对应的selectorName的集合中移除。subclassTrackersHookingSelectorName方法是一个并查集,传入一个selectorName,通过递归查找,找到所有包含这个selectorName的set,最后把这些set合并在一起作为返回值返回。

四. Aspects hook前的准备工作

201194012-2795423fae552b19

Aspects 库中就两个函数,一个是针对类的,一个是针对实例的。

两个方法的实现都是调用同一个方法aspect_add,只是传入的参数不同罢了。所以我们只要从aspect_add开始研究即可。

这是函数调用栈。从aspect_add开始研究。

aspect_add函数一共5个入参,第一个参数是self,selector是外面传进来需要hook的SEL,options是切片的时间,block是切片的执行方法,最后的error是错误。

aspect_performLocked是一个自旋锁。自旋锁是效率比较高的一种锁,相比@synchronized来说效率高得多。

如果对iOS中8大锁不了解的,可以看以下两篇文章

iOS 常见知识点(三):Lock
深入理解 iOS 开发中的锁

211194012-01132072ff194854

但是自旋锁也是有可能出现问题的:
如果一个低优先级的线程获得锁并访问共享资源,这时一个高优先级的线程也尝试获得这个锁,它会处于 spin lock 的忙等(busy-wait)状态从而占用大量 CPU。此时低优先级线程无法与高优先级线程争夺 CPU 时间,从而导致任务迟迟完不成、无法释放 lock。不再安全的 OSSpinLock

OSSpinLock的问题在于,如果访问这个所的线程不是同一优先级的话,会有死锁的潜在风险。

这里暂时认为是相同优先级的线程,所以OSSpinLock保证了线程安全。也就是说aspect_performLocked是保护了block的线程安全。

现在就剩下aspect_isSelectorAllowedAndTrack函数和aspect_prepareClassAndHookSelector函数了。

接下来先看看aspect_isSelectorAllowedAndTrack函数实现过程。

先定义了一个NSSet,这里面是一个“黑名单”,是不允许hook的函数名。retain, release, autorelease, forwardInvocation:是不允许被hook的。

当检测到selector的函数名是黑名单里面的函数名,立即报错。

再次检查如果要切片dealloc,切片时间只能在dealloc之前,如果不是AspectPositionBefore,也要报错。

当selector不在黑名单里面了,如果切片是dealloc,且selector在其之前了。这时候就该判断该方法是否存在。如果self和self.class里面都找不到该selector,会报错找不到该方法。

class_isMetaClass 先判断是不是元类。接下来的判断都是判断元类里面能否允许被替换方法。

subclassHasHookedSelectorName会判断当前tracker的subclass里面是否包含selectorName。因为一个方法在一个类的层级里面只能被hook一次。如果已经tracker里面已经包含了一次,那么会报错。

在这个do-while循环中,currentClass = class_getSuperclass(currentClass)这个判断会从currentClass的superclass开始,一直往上找,直到这个类为根类NSObject。

经过上面合法性hook判断和类方法不允许重复替换的检查后,到此,就可以把要hook的信息记录下来,用AspectTracker标记。在标记过程中,一旦子类被更改,父类也需要跟着一起被标记。do-while的终止条件还是currentClass = class_getSuperclass(currentClass)。

以上是元类的类方法hook判断合法性的代码。

如果不是元类,只要不是hook这”retain”, “release”, “autorelease”, “forwardInvocation:”4种方法,而且hook “dealloc”方法的时机必须是before,并且selector能被找到,那么方法就可以被hook。

通过了selector是否能被hook合法性的检查之后,就要获取或者创建AspectsContainer容器了。

在读取或者创建AspectsContainer之前,第一步是先标记一下selector。

在全局代码里面定义了一个常量字符串

用这个字符串标记所有的selector,都加上前缀”aspects_”。然后获得其对应的AssociatedObject关联对象,如果获取不到,就创建一个关联对象。最终得到selector有”aspects_”前缀,对应的aspectContainer。

得到了aspectContainer之后,就可以开始准备我们要hook方法的一些信息。这些信息都装在AspectIdentifier中,所以我们需要新建一个AspectIdentifier。

调用AspectIdentifier的instancetype方法,创建一个新的AspectIdentifier

这个instancetype方法,只有一种情况会创建失败,那就是aspect_isCompatibleBlockSignature方法返回NO。返回NO就意味着,我们要替换的方法block和要替换的原方法,两者的方法签名是不相符的。(这个函数在上面详解过了,这里不再赘述)。方法签名匹配成功之后,就会创建好一个AspectIdentifier。

aspectContainer容器会把它加入到容器中。完成了容器和AspectIdentifier初始化之后,就可以开始准备进行hook了。通过options选项分别添加到容器中的beforeAspects,insteadAspects,afterAspects这三个数组

小结一下,aspect_add干了一些什么准备工作:

  1. 首先调用aspect_performLocked ,利用自旋锁,保证整个操作的线程安全
  2. 接着调用aspect_isSelectorAllowedAndTrack对传进来的参数进行强校验,保证参数合法性。
  3. 接着创建AspectsContainer容器,利用AssociatedObject关联对象动态添加到NSObject分类中作为属性的。
  4. 再由入参selector,option,创建AspectIdentifier实例。AspectIdentifier主要包含了单个的 Aspect的具体信息,包括执行时机,要执行block 所需要用到的具体信息。
  5. 再将单个的 AspectIdentifier 的具体信息加到属性AspectsContainer容器中。通过options选项分别添加到容器中的beforeAspects,insteadAspects,afterAspects这三个数组。
  6. 最后调用prepareClassAndHookSelector准备hook。

221194012-c4741aae2eb19e72

下部分见下篇。

打赏支持我写出更多好文章,谢谢!

打赏作者

打赏支持我写出更多好文章,谢谢!

任选一种支付方式

1 1 收藏 评论

关于作者:一缕殇流化隐半边冰霜

已退役ACMer,现役iOS工程师。博观而约取,厚积而薄发。 个人主页 · 我的文章 · 5 ·   

相关文章

可能感兴趣的话题



直接登录
跳到底部
返回顶部