Aspects框架的源码解读及问题解析

1,060 阅读11分钟

前言

在iOS日常开发中,对某些方法进行hook是很常见的操作。最常见的是使用Category在+load中进行方法swizzle,它是针对类的,会改变这个类所有实例的行为。但是有时候我们只想针对单个实例进行hook,这种方法就显得无力了。而Aspects框架可以搞定这个问题。 它的原理是通过Runtime动态的创建子类,把实例的isa指针指向新创建的子类,然后在子类中对hook的方法进行处理,这样就支持了对单个实例的hook。Aspects框架支持对类和实例的hook,API很易用,可以方便的让你在任何地方进行hook,是线程安全的。但是Aspects框架也有一些缺陷,一不小心就会掉坑里面,我会通过源码解析进行说明。

源码解析

我主要使用图示对Aspects的源码进行说明,建议参考源码一起查看。要看懂这些内容,需要对isa指针消息转发机制runtime有一定的了解,本文中不会对这些内容展开来讲,因为要把这些东西讲清楚,每一项都需要单独写一篇文章了。

主要流程解析

  1. 它第一个流程是使用关联对象添加Container,在这个过程中会进行一些前置条件的判断,例如这个方法是否支持被hook等,如果条件验证通过,就会把这次hook的信息保存起来,在方法调用的时候,查询出来使用。
  2. 第二个流程是动态创建子类,如果是针对类的hook,则不会走这一步。
  3. 第三步是替换这个类的forwardInvocation:方法为__ASPECTS_ARE_BEING_CALLED__,这个方法内部会查找到之前创建的Container,然后根据Container中的逻辑进行实际的调用。
  4. 第四步是将原有方法的IMP改为_objc_msgForward,改完后当调用原有方法时,就会调用_objc_msgForward,从而触发forwardInvocation:方法。

我对它的流程做了一个简化的图示,标有每个流程的序号,后面会对每个流程进行解析。流程如下:

图示中的取出对象类型,是指的调用hook的对象的类型,如果是实例对象,那么就走路径;如果是对象,则走元类路径;如果是kvo等实际类型不一致的情况,则走其它子类路径。

①添加Container流程

这个流程中,把hook的逻辑封装成Container,并使用关联对象进行保存。这个过程中会判断hook的方法是否被支持、判断被hook类的继承关系、验证回调block正确性等操作。具体图示如下:

关键代码如下:

static id aspect_add(id self, SEL selector, AspectOptions options, id block, NSError **error) {
    ...
    aspect_performLocked(^{ // 加锁
        // hook前置条件判断
        if (aspect_isSelectorAllowedAndTrack(self, selector, options, error)) {
            // 用selector作key,通过关联对象获得Container对象。
            AspectsContainer *aspectContainer = aspect_getContainerForObject(self, selector);
            // 内部会判断block与hook的selector是否匹配,不匹配返回nil。
            identifier = [AspectIdentifier identifierWithSelector:selector object:self options:options block:block error:error];
            if (identifier) {
                // 添加identifier,包含了hook的类型和回调。 
                [aspectContainer addAspect:identifier withOptions:options];

                // Modify the class to allow message interception.
                aspect_prepareClassAndHookSelector(self, selector, error);
            }
        }
    });
    return identifier;
}

static BOOL aspect_isSelectorAllowedAndTrack(NSObject *self, SEL selector, AspectOptions options, NSError **error) {
    static NSSet *disallowedSelectorList;
    static dispatch_once_t pred;
    dispatch_once(&pred, ^{
        disallowedSelectorList = [NSSet setWithObjects:@"retain", @"release", @"autorelease", @"forwardInvocation:", nil];
    });

    // 这里对不支持hook的方法进行过滤
    NSString *selectorName = NSStringFromSelector(selector);
    if ([disallowedSelectorList containsObject:selectorName]) {
        NSString *errorDescription = [NSString stringWithFormat:@"Selector %@ is blacklisted.", selectorName];
        AspectError(AspectErrorSelectorBlacklisted, errorDescription);
        return NO;
    }

    // dealloc只支持AspectPositionBefore类型下调用
    AspectOptions position = options&AspectPositionFilter;
    if ([selectorName isEqualToString:@"dealloc"] && position != AspectPositionBefore) {
        NSString *errorDesc = @"AspectPositionBefore is the only valid position when hooking dealloc.";
        AspectError(AspectErrorSelectorDeallocPosition, errorDesc);
        return NO;
    }

    // 判断是否存在这个方法
    if (![self respondsToSelector:selector] && ![self.class instancesRespondToSelector:selector]) {
        NSString *errorDesc = [NSString stringWithFormat:@"Unable to find selector -[%@ %@].", NSStringFromClass(self.class), selectorName];
        AspectError(AspectErrorDoesNotRespondToSelector, errorDesc);
        return NO;
    }

    // 这里禁止有继承关系的类hook同一个方法,代码量较多,不是关键内容,这里不贴出
    if (class_isMetaClass(object_getClass(self))) {
        ...
    }

    return YES;
}

/// AspectsContainer内部添加AspectIdentifier的实现。
/// 这里可以看出对同一个方法的多次hook都会被调用,不会出现后面hook的覆盖前面的情况。
- (void)addAspect:(AspectIdentifier *)aspect withOptions:(AspectOptions)options {
    NSParameterAssert(aspect);
    NSUInteger position = options&AspectPositionFilter;
    switch (position) {
        case AspectPositionBefore:  self.beforeAspects  = [(self.beforeAspects ?:@[]) arrayByAddingObject:aspect]; break;
        case AspectPositionInstead: self.insteadAspects = [(self.insteadAspects?:@[]) arrayByAddingObject:aspect]; break;
        case AspectPositionAfter:   self.afterAspects   = [(self.afterAspects  ?:@[]) arrayByAddingObject:aspect]; break;
    }
}

  1. 从源码中可以看到,不支持的hook方法有[NSSet setWithObjects:@"retain", @"release", @"autorelease", @"forwardInvocation:", nil];。其中retain, release, autorelease在arc下是被禁用的,框架本身是hookforwardInvocation:进行实现的,所以对它的hook也不支持。
  2. dealloc只支持AspectPositionBefore类型,使用AspectPositionInstead会导致系统默认的dealloc操作被替换无法执行而出现问题。 AspectPositionAfter类型,调用时对象可能已经已经被释放了,从而引发野指针错误。
  3. Aspects禁止有继承关系的类hook同一个方法,具体可以参见它的一个issue,它报告了这样操作会导致死循环,我会在文章后面再进行说明。
  4. Aspects使用block进行hook的调用,涉及到方法参数的传递和返回值问题,所以其中会对block进行校验。

②runtime创建子类

iOS中的KVO就是通过runtime动态创建子类,然后在子类中重写对应的setter方法来实现的,Aspects支持对单个实例的hook原理与此有一些类似。图示如下: 具体说明请查看源码中的注释

// 执行hook
static void aspect_prepareClassAndHookSelector(NSObject *self, SEL selector, NSError **error) {
    NSCParameterAssert(selector);
    // 针对实例类型,会通过runtime动态创建子类。类类型则直接hook。
    Class klass = aspect_hookClass(self, error);
    ...
}

static Class aspect_hookClass(NSObject *self, NSError **error) {
    NSCParameterAssert(self);
    Class statedClass = self.class;
	Class baseClass = object_getClass(self);
	NSString *className = NSStringFromClass(baseClass);

    // 已经被hook过的类,直接返回
	if ([className hasSuffix:AspectsSubclassSuffix]) {
		return baseClass;

    // 是元类(MetaClass),则代表是对类进行hook。(非单个实例)
	}else if (class_isMetaClass(baseClass)) {
        // 内部是将类的forwardInvocation:方法替换为__ASPECTS_ARE_BEING_CALLED__
        return aspect_swizzleClassInPlace((Class)self);
    // 可能是一个KVO对象等情况,传入实际的类型进行hook。
    }else if (statedClass != baseClass) {
        return aspect_swizzleClassInPlace(baseClass);
    }

    // 单个实例的情况,动态创建子类进行hook.
	const char *subclassName = [className stringByAppendingString:AspectsSubclassSuffix].UTF8String;
	Class subclass = objc_getClass(subclassName);

	if (subclass == nil) {
		subclass = objc_allocateClassPair(baseClass, subclassName, 0);
		if (subclass == nil) {
            NSString *errrorDesc = [NSString stringWithFormat:@"objc_allocateClassPair failed to allocate class %s.", subclassName];
            AspectError(AspectErrorFailedToAllocateClassPair, errrorDesc);
            return nil;
        }
        // 内部是将类的forwardInvocation:方法替换为__ASPECTS_ARE_BEING_CALLED__
		aspect_swizzleForwardInvocation(subclass);
        // 重写class方法,返回之前的类型,而不是新创建的子类。避免hook后,类型判断出现问题。
		aspect_hookedGetClass(subclass, statedClass);
		aspect_hookedGetClass(object_getClass(subclass), statedClass);
		objc_registerClassPair(subclass);
	}

	object_setClass(self, subclass);
	return subclass;
}

③替换forwardInvocation:

这部分就是把原有的forwardInvocation:替换为自定义的实现:__ASPECTS_ARE_BEING_CALLED__。源码如下:

static NSString *const AspectsForwardInvocationSelectorName = @"__aspects_forwardInvocation:";
static void aspect_swizzleForwardInvocation(Class klass) {
    NSCParameterAssert(klass);
    // If there is no method, replace will act like class_addMethod.
    IMP originalImplementation = class_replaceMethod(klass, @selector(forwardInvocation:), (IMP)__ASPECTS_ARE_BEING_CALLED__, "v@:@");
    if (originalImplementation) {
        class_addMethod(klass, NSSelectorFromString(AspectsForwardInvocationSelectorName), originalImplementation, "v@:@");
    }
    AspectLog(@"Aspects: %@ is now aspect aware.", NSStringFromClass(klass));
}

替换后的对应关系图示如下:

④hook方法交换IMP:

图示如下:

第③步和第④步可能有些同学会感到疑惑,为什么要替换forwardInvocation以及为什么要将hook的方法的IMP替换为_objc_msgForward,这个和iOS的消息转发机制有关,可以自行查找相关资料,这里就不做说明了。需要注意的是有些框架也是通过iOS的消息发送机制来做一些操作,例如JSPatch,使用的时候需要注意,避免发生冲突。

被hook方法的调用流程

当hook注入后,对hook方法进行调用时,调用流程就会发生变化。图示如下:

从上述解析过程中,我们可以看到Aspects这个框架是设计的很巧妙的,从中可以看到非常多runtime知识的应用。但是作者并不推荐在实际项目中进行使用:

因为Apsects对类的底层进行了修改,这种修改是基础方面的修改,需要考虑到各种场景和边界问题,一旦某方面考虑不周,就会引发出一些未知问题。另外这个框架是有缺陷的,很久没有进行更新了,我对它的已知问题点进行了总结,在下面进行说明。如果有未总结到位的,欢迎补充。

问题点

基于类的hooking,同一条继承链条上的所有类,一个方法只能被hook一次,后hook的无效。

之前这样会出现死循环,后面作者进行了修改,对这个行为进行了禁止并加了错误提示。详见这个issue

@interface A : NSObject
- (void)foo;
@end

@implementation A
- (void)foo {
    NSLog(@"%s", __PRETTY_FUNCTION__);
}
@end

@interface B : A @end

@implementation B
- (void)foo {
    NSLog(@"%s", __PRETTY_FUNCTION__);
    [super foo]; // 导致死循环的代码
}
@end

int main(int argc, char *argv[]) {
    [B aspect_hookSelector:@selector(foo) atPosition:AspectPositionBefore withBlock:^(id object, NSArray *arguments) {
        NSLog(@"before -[B foo]");
    }];
    [A aspect_hookSelector:@selector(foo) atPosition:AspectPositionBefore withBlock:^(id object, NSArray *arguments) {
        NSLog(@"before -[A foo]");
    }];

    B *b = [[B alloc] init];
    [b foo]; // 调用后死循环
}

我们都知道,super是从它的父类开始查找方法,然后传入self进行调用。 根据我们之前对源码的解析,在这里调用[super foo]后会从父类查找fooIMP,查到后发现父类的IMP已经被替换为_objc_msgForward,然后传入self调用。 因为是传入的self,所以实际会调用到它自身的forwardInvocation:,这样就导致了死循环。

针对单个实例的hook,hook后使用kvo没问题,使用kvo后hook会出现问题。

这里通过代码进行说明,以Animal对象为例:

@interface Animal : NSObject
@property(strong, nonatomic) NSString * name;
@end

@implementation Animal
- (void)testKVO {
    [self addObserver:self forKeyPath:@"name" options:NSKeyValueObservingOptionNew context:nil];
    self.name = @"Animal";
}

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
    NSLog(@"observeValueForKeyPath keypath:%@ name:%@", keyPath, self.name);
}

- (void)dealloc {
    [self removeObserver:self forKeyPath:@"name"];
}
@end

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        Animal *animal = [[Animal alloc] init];
        [animal testKVO];
        // 这里如果改为针对类进行hook,则不会存在问题,因为类hook修改的是Animal类,而实例hook修改的是NSKVONotifying_Animal类
        [animal aspect_hookSelector:@selector(setName:) 
                        withOptions:AspectPositionAfter 
                         usingBlock:^(id<AspectInfo> aspectInfo, NSString *name){
            NSLog(@"aspects hook setName");
        } error:nil];
        // 这里会crash
        animal.name = @"ChangedAnimalName";
    }
}

异常原因分析图示如下:

上面是继承链和方法调用流程的图示,可以看出,_NSSetObjectValueAndNotify是被aspects__setName:调用的,_NSSetObjectValueAndNotify的内部实现逻辑是取调用它的selector,去父类查找方法,即aspects__setName:方法,而Animal对象并没有这个方法的实现,这就导致了crash。

与category的共存问题

先用aspects进行hook,再使用category进行hook,会导致crash。反之则没有问题。样例代码如下:

@interface Animal : NSObject
@property(strong, nonatomic) NSString * name;
@end

@implementation Animal
- (void)setName:(NSString *)name {
    NSLog(@"%s", __func__);
    _name = name;
}
@end

@interface Animal(hook)
+ (void)categoryHook;
@end

@implementation Animal(hook)
+ (void)categoryHook {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        Class class = [super class];
        SEL originalSelector = @selector(setName:);
        SEL swizzledSelector = @selector(lx_setName:);
        Method originalMethod = class_getInstanceMethod(class, originalSelector);
        Method swizzledMethod = class_getInstanceMethod(class, swizzledSelector);
        method_exchangeImplementations(originalMethod, swizzledMethod);
    });
}

- (void)lx_setName:(NSString *)name {
    NSLog(@"%s", __func__);
    [self lx_setName:name];
}
@end


int main(int argc, const char * argv[]) {
    @autoreleasepool {
        Animal *animal = [[Animal alloc] init];
        [Animal aspect_hookSelector:@selector(setName:) withOptions:AspectPositionBefore usingBlock:^(id<AspectInfo> aspectInfo, NSString *name){
            NSLog(@"aspects hook setName");
        } error:nil];
        
        [Animal categoryHook];
        // 调用后crash:[Animal lx_setName:]: unrecognized selector sent to instance 0x100608dc0
        animal.name = @"ChangedAnimalName";
    }
}

这个与__ASPECTS_ARE_BEING_CALLED__的内部逻辑有关,里面会对调用的方法添加前缀aspect__进行调用,以调用到原始的IMP,但是category hook后破坏了这个流程。图示如下:

根据上述图示,实际只有aspects__setName,没有aspects__lx_setName,导致找不到方法而crash

基于类的hook,如果对同一个类同时hook类方法和实例方法,那么后hook的方法调用时会crash。样例代码如下:

@interface Animal : NSObject
- (void)testInstanceMethod;
+ (void)testClassMethod;
@end

@implementation Animal
- (void)testInstanceMethod {
    NSLog(@"%s", __func__);
}
+ (void)testClassMethod {
    NSLog(@"%s", __func__);
}
@end

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        Animal *animal = [[Animal alloc] init];
        [Animal aspect_hookSelector:@selector(testInstanceMethod) withOptions:AspectPositionBefore usingBlock:^(id<AspectInfo> aspectInfo){
            NSLog(@"aspects hook testInstanceMethod");
        } error:nil];
        
        [object_getClass([Animal class]) aspect_hookSelector:@selector(testClassMethod) withOptions:AspectPositionBefore usingBlock:^(id<AspectInfo> aspectInfo){
            NSLog(@"aspects hook testClassMethod");
        } error:nil];
        
        [animal testInstanceMethod];
        // crash: "+[Animal testClassMethod]: unrecognized selector sent to class 0x1000114a0"
        [Animal testClassMethod];
    }
}

这样的调用在日常开发中非常正常,但是它会导致crash。它是由于aspect_swizzleClassInPlace方法中的逻辑缺陷导致的。

static Class aspect_swizzleClassInPlace(Class klass) {
    NSCParameterAssert(klass);
    // Animal类对象与Animal元类对象会得到同一个字符串。
    NSString *className = NSStringFromClass(klass);
    NSLog(@"aspect_swizzleClassInPlace %@ %p", klass, object_getClass(klass));
    _aspect_modifySwizzledClasses(^(NSMutableSet *swizzledClasses) {
        // 类对象和元类对象得到同一个className,这里后加入的会被错误的过滤掉。
        if (![swizzledClasses containsObject:className]) {
            aspect_swizzleForwardInvocation(klass);
            [swizzledClasses addObject:className];
        }
    });
    return klass;
}

从上述代码可以看到,它的去重逻辑只是简单的字符串判断,取Animal的元类名得到同一个字符串Animal,导致后添加的被过滤,当调用后被hook的方法后,执行_objc_msgForward,因为后hook的aspect_swizzleForwardInvocation被过滤了没有执行,所以找不到forwardInvocation:IMP,导致了crash。

_objc_msgForward会出现冲突的问题

内部是通过消息转发机制来实现的,使用时要注意,避免与其它使用_objc_msgForward或相关逻辑的框架发生冲突。

性能问题

hook后的方法,通过原有消息机制找到IMP后,并不会直接调用。而是会进行消息转发进入到__ASPECTS_ARE_BEING_CALLED__方法,内部再通过key取出相应的Coantiner进行调用,相对于未hook之前,额外增加了调用成本。所以不建议对频繁调用的方法和在项目中大量使用。

线程问题

框架内部为了保证线程安全,有进行加锁,但是使用的是自旋锁OSSpinLock,存在线程反转的问题,在iOS10已经被标记为弃用。

对类方法的hook,需要使用object_getClass来获取元类对象进行hook

这个不是框架问题,而是有些同学不知道如何对类方法进行hook,这里进行说明。

@interface Animal : NSObject
+ (void)testClassMethod;
@end

// 需要通过object_getClass来获取元类对象进行hook
[object_getClass(Animal) aspect_hookSelector:@selector(testClassMethod)     
                                 withOptions:AspectPositionAfter 
                                  usingBlock:^(id<AspectInfo> aspectInfo){
    NSLog(@"aspects hook setName");
} error:null];