OCMock 源码学习笔记

背景

使用 XCTest + OCMock 写单元测试也有一段时间了. 一直没了解 OCMock 到底是怎么实现的, 所以就想找个时间读读源码, 揭开 OCMock 的神秘面纱. 在阅读源码时发现比较核心的机制就是 NSProxy + 消息转发, 所以在看源码之前, 先简单复习一下相关知识.

消息转发

先来看看消息转发, Objective-C 的消息机制就不赘述了, 在 objc_msgSend 时, 如果对象的和其父类一直到根类都没有在方法缓存和方法列表中找到对应的方法就会发生这样的错误: unrecognized selector sent to instance, 但是在崩溃前, 会有消息转发的机制来尝试挽救.

消息转发简化整理

第一步, 首先会调用 forwardingTargetForSelector: 方法获取一个可以处理该 Selector 的对象. 对该对象重新进行发送消息, 如果返回为 nil, 则走第二步.

第二步, 调用 methodSignatureForSelector: 方法来获得方法签名 NSMethodSignature, 包含 Selector 和 参数的信息, 用于生成 NSInvocation, 如果返回为 nil, 则抛出 doesNotRecognizeSelector 异常.

第三步, 调用 forwardInvocation: 对 NSInvocation 进行处理, 如果本身, 父类一直到根类都没有处理, 则还是会抛出 doesNotRecognizeSelector 异常.

简单整理消息转发到机制就是这样, 更深的原理推荐阅读杨萧玉大神的这篇文章: Objective-C 消息发送与转发机制原理.

NSProxy

An abstract superclass defining an API for objects that act as stand-ins for other objects or for objects that don’t exist yet.

在文档中的解释是这样的, NSProxy 是一个抽象的父类(说根类更为合适), 用于定义对象的 API, 可以充当其他对象或者已经不存在的对象的替身.

在 iOS 中的根类是 NSObject 和 NSProxy, NSObject 即是根类也是协议, NSProxy 也实现了该协议, 并且作为一个抽象类, 它并不提供初始化方法, 如果接收到它没有响应的消息时会抛出异常, 所以, 需要使用子类继承实现初始化方法, 然后通过重写 forwardInvocation: 和methodSignatureForSelector: 方法来处理它本身未实现的消息处理.

这里列出两个经常会使用到的小 Tips.

YYWeakProxy

YYWeakProxy 是 YYKit 中提供的工具, 用于持有一个 weak 对象, 通常用来解决 NSTimer 和 CADisplayLink 循环引用的问题. 比如我们经常会在对象内使用 NSTimer, 该对象强引用着 NSTimer, 而该对象在作为 target 时就又会被 NSTimer 强引用着, 就构成了循环引用, 导致都无法释放.

点这里查看全部源码

简单介绍一下 YYWeakProxy 是如何实现的, 首先使用初始化方法, 弱引用着 target 对象.

- (instancetype)initWithTarget:(id)target {
    _target = target;
    return self;
}

通过实现 forwardingTargetForSelector: 方法来将消息转发给 _taget, 充当了桥梁, 破除了如 NSTimer 对 target 的强引用.

- (id)forwardingTargetForSelector:(SEL)selector {
    return _target;
}

然后这里又另外实现了这两个方法, 这是为了什么呢?

- (void)forwardInvocation:(NSInvocation *)invocation {
    void *null = NULL;
    [invocation setReturnValue:&null];
}

- (NSMethodSignature *)methodSignatureForSelector:(SEL)selector {
    return [NSObject instanceMethodSignatureForSelector:@selector(init)];
}

因为 target 是弱引用的, 如果释放了, 就会被置为 nil, 转发方法 forwardingTargetForSelector: 就相当于返回了 nil, 那么没有办法处理消息, 则会导致发生崩溃.

所以这里就是随便返回了一个方法签名, 直接返回 NSObject 的 init 的方法签名, invocation 并未调用 invoke 只是返回 nil, 相当于此时发送什么消息都会返回 nil, 不会崩溃.

实现多继承

在 objc 中是不能多继承的, 但是我们可以使用 NSProxy 来模拟多继承的效果, 其实将上面的例子的 target 变成一个数组来持有多个 target.

然后将方法按照 respondsToSelector: 谁能处理, 来转发给各个 target 就可以实现多继承了, 比较简单, 这里用最简单的方法实现如下:

- (id)forwardingTargetForSelector:(SEL)selector {
    __block id target = nil;
    [self.tagets enumerateObjectsUsingBlock:^(id  _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
        if ([obj respondsToSelector:selector]) {
            target = obj;
            *stop = YES;
        }
    }];
    return target;
}

接下来进入正题, 来开始看一下 OCMock 的核心源码实现.

例子

我准备从一个经常会用到的例子来一点一点阅读 OCMock 的源码实现.

在单元测试中, 经常需要屏蔽掉外界因素的干扰, 比如方法中依赖的外部方法的结果, 在我们的项目中, 大量的使用了下发的开关配置, 比如下面这行代码来判断是否开启某个功能.

BOOL enableXX = [[RemoteConfig sharedRemoteConfig] enableXXFeature];

使用 OCMock 来 Mock 该结果的方式如下:

// Setup
id configMock = OCMClassMock([RemoteConfig class]);
OCMStub([configMock sharedRemoteConfig]).andReturn(configMock);
OCMStub([configMock enableXXFeature]).andReturn(YES);
// Assert
...
// Teardown
[configMock stopMocking];

第一行创建一个 RemoteConfig 类的 mock 对象, 命名为 configMock;

第二行 mock 掉 [configMock sharedRemoteConfig] 的类方法, 并且 andReturn 添加返回值为该 mock 对象. 这样通过 [RemoteConfig sharedRemoteConfig] 就可以永远返回一个 mock 的对象, 接下来只要在对这个 mock 的对象的 enableXXFeature 方法添加一个返回值就可以实现 mock 开关了;

第三行, mock 掉 [configMock enableXXFeature] 的实例方法并且 andReturn 添加返回值恒定为 YES.

OCMock 使用了大量的宏定义, 那么就通过 Xcode 提供的 Preprocess 的功能来一步一步看看到底是怎么回事吧.

OCMClassMock

第一行, OCMClassMock 宏展开后如下:

id configMock = [OCMockObject niceMockForClass:[RemoteConfig class]];

这个 OCMockObject 就是我们刚刚说到的 NSProxy 的一个子类, 来实现消息转发, niceMockForClass 其实就是调用了

+ (id)mockForClass:(Class)aClass {
    return [[[OCClassMockObject alloc] initWithClass:aClass] autorelease];
}

只不过设置了一个 isNice 的实例变量, 并且标记为 YES, 这个不影响核心原理的理解, 简单说一下, OCMock 中使用 OCMStrictClassMock 可以进行一个严格的 mock, 如果调用没有 Stub 住的方法时, 就会崩溃, 而这个 OCMClassMock 就是 nice 的, 没有 Stub 的方法会进行一下保护, 不会产生崩溃, 比较 nice, 我们比较常用到的就是比较 nice 的 OCMClassMock.

OCMStub

整个 OCMStub 是最核心的点, 其他的 Expect 和 Reject 原理大都一致, 一点一点看.

enableXXFeature

展开

OCMStub([configMock enableXXFeature]).andReturn(YES);

先从这行代码来看起, 先看 OCMStub 的展开, 我稍微整理了一下, 代码如下:

({
    [OCMMacroState beginStubMacro];
    OCMStubRecorder *recorder = ((void *)0);
    @try{
        [configMock enableXXFeature];
    } @finally {
        recorder = [OCMMacroState endStubMacro];
    }
    recorder;
});

其中上下两个 begin 和 end 的方法就是为了增加一个 OCMStubRecorder 标记, 并且存放在当前线程的字典中. 代码如下:

+ (void)beginStubMacro {
    OCMStubRecorder *recorder = [[[OCMStubRecorder alloc] init] autorelease];
    OCMMacroState *macroState = [[OCMMacroState alloc] initWithRecorder:recorder];
    [NSThread currentThread].threadDictionary[OCMGlobalStateKey] = macroState;
    [macroState release];
}

+ (OCMStubRecorder *)endStubMacro {
    NSMutableDictionary *threadDictionary = [NSThread currentThread].threadDictionary;
    OCMMacroState *globalState = threadDictionary[OCMGlobalStateKey];
    OCMStubRecorder *recorder = [(OCMStubRecorder *)[globalState recorder] retain];
    [threadDictionary removeObjectForKey:OCMGlobalStateKey];
    return [recorder autorelease];
}

Stub

关键在中间一行 [configMock enableXXFeature] 的调用, 存在这个 OCMStubRecorder 标记时, 会在消息转发的 forwardingTargetForSelector: 这个方法中进行处理, 记录 configMock 对象的同时, 返回这个 recorder 对象进行处理.

- (id)forwardingTargetForSelector:(SEL)aSelector {
    if([OCMMacroState globalState] != nil) {
        OCMRecorder *recorder = [[OCMMacroState globalState] recorder];
        [recorder setMockObject:self];
        return recorder;
    }
    return nil;
}

所以便理解了上面为什么要将 recorder 对象放入当前线程的字典中, 是为了同样是这样一行代码 [configMock enableXXFeature], 在是否有 recorder 时, 可以有两种截然不同的处理路线, 很是巧妙. 即在定义 Stub 时, 可以交给 recorder 去处理, 而在真正调用该方法时, 可以由这个 mock 的对象按照消息转发接下来的流程处理.

这个 recorder 对象是 OCMStubRecorder 类型, 继承自 OCMRecorder, 而 OCMRecorder 又继承自 NSProxy. 所以这个 recorder 也需要处理消息转发机制.

recorder 在 methodSignatureForSelector: 中, 先按照实例方法去获取 mock 对象的方法签名, 如果没有的话再按照类方法去获取方法签名, 如果获取到则在 invocationMatcher 记录标记一下, 是类方法, 还是没获取到就会返回 nil 了, 按照消息转发机制, 则会抛出 doesNotRecognizeSelector 异常.

- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector {
    if([invocationMatcher recordedAsClassMethod])
        return [mockedClass methodSignatureForSelector:aSelector];

    NSMethodSignature *signature = [mockObject methodSignatureForSelector:aSelector];
    if(signature == nil) {
       if([mockedClass respondsToSelector:aSelector]) {
            // 标记一下证明该 Selector 是类方法, 标记到 invocationMatcher 上
            [self classMethod];
            // 重新调用这个方法取方法前面, 这样就会被前两行返回
            signature = [self methodSignatureForSelector:aSelector];
        }
    }
    return signature;
}

前面两行的意思是如果已经被标记为类方法了, 则直接返回类方法的方法签名.

再来看 forwardInvocation: 处理的方法, 我按照继承关系整理了一下方便阅读:

- (void)forwardInvocation:(NSInvocation *)anInvocation {
    [anInvocation setTarget:nil];
    [invocationMatcher setInvocation:anInvocation];
    [mockObject addStub:invocationMatcher];
}

其目的就是通过 setTarget:nil 来禁止这个 invocation 调用, 用 invocationMatcher 来记录并且管理一下这个 invocation, 然后把这个 invocationMatcher 传递给 mockObject 就是我们上面记录过的 configMock 对象.

在 addStub: 方法中, 如果是实例方法只是将这个 invocationMatcher 保存到了一个数组中, 如果是类方法等下再看 Stub sharedRemoteConfig 这个类方法时再看.

这样整个 OCMStub 的过程就理解了. 在简单整理一下对象间的关系, 方便理解.

对象间关系-1

mock 对象持有一个 invocationMatcher 对象的数组, 每一个 invocationMatcher 对象表示一次的 Stub(或者是 Expect 等), 还记录着该方法是个类方法还是实例方法.

每一个 invocationMatcher 持有 invocation 对象, 用于进行在调用的时候, 和调用的 invocation 进行匹配, 以及参数校验等逻辑.

在 Stub 流程中, 这个 recorder 对象相当于一个流程管理者, 记录了该流程的信息, 再 Stub 语句完整结束后, 其实就被释放了, 后面在看.

andReturn

OCMStub 实际上是返回了 OCMStubRecorder 这个对象. 在这个对象中记录需要的方法返回值. 展开后如下:

recorder._andReturn(({
    __typeof__((YES)) _val = ((YES));
    NSValue *_nsval = [NSValue value:&_val withObjCType:@encode(__typeof__(_val))];
    if (OCMIsObjectType(@encode(__typeof(_val)))) {
        objc_setAssociatedObject(_nsval, "OCMAssociatedBoxedValue", *(__unsafe_unretained id *) (void *) &_val, OBJC_ASSOCIATION_RETAIN);
    }
    _nsval;
}));

补充说明一下, @encode 是一个编译器指令, 返回一个类型内部进行表示的字符串, 比如这里使用的 YES 是 BOOL 类型, 内部字符串表示就是 "B", 更深入的, 更方便对类型进行判断和处理, 关于 @encode 推荐阅读这篇文章

Type Encodings

所以, 整体逻辑简单来说实际上就是将这个返回值通过 NSValue 进行包装, 可以理解为

recorder._addReturn(_nsval);

这个 _addReturn() 是一个 block, 传入一个 NSValue, 返回自身方便链式编写. 本质上就是根据返回值的类型, 是基本类型还是对象使用不同的 ValueProvider 进行包装. 基本类型使用 OCMBoxedReturnValueProvider, 对象则使用 OCMReturnValueProvider.

在来看刚刚的对象间关系:

对象间关系-2

这时就增加了 ValueProviders 的逻辑, 每一个 invocationMatcher 持有多个. 因为不仅仅可以 andReturn 指定返回值, 例如还可以 andDo 指定一个 block
, 在方法被调用后执行等等. 不过感觉 OCMock 此处的处理还可以再完善一下, 这些类似于的 ValueProviders 都遵从一个 ValueProviders 的协议, 然后协议要求实现 handleInvocation:, 不过既然是人家内部的逻辑, 也无所谓啦.

调用过程

调用过程中实际上是没有 recorder 的, 在 OCMStub 整行代码结束后就被释放啦. 对象关系就变成这样了:

对象间关系-3

真正调用的是对 configMock 即 OCClassMockObject 进行调用如 enableXXFeature 方法的过程就像前面说过的, 由于没有了 recorder, forwardingTargetForSelector: 会返回 nil 接下来的消息转发流程回去获取方法签名, 然后在 forwardInvocation: 中处理.

- (void)forwardInvocation:(NSInvocation *)anInvocation {
    @try
    {
        if([self handleInvocation:anInvocation] == NO)
            [self handleUnRecordedInvocation:anInvocation];
    } @catch(NSException *e) {
        ...
    }
}

核心步骤在 handleInvocation: 中, 整理如下

- (BOOL)handleInvocation:(NSInvocation *)anInvocation {

// 1. 记录 `invocation` 用于实现 `Expect` 的校验逻辑
    @synchronized(invocations) {
        [anInvocation retainObjectArgumentsExcludingObject:self];
        [invocations addObject:anInvocation];
    }

// 2. 取刚刚 `addStub:` 中记录的 `invocationMatcher` 进行匹配
    OCMInvocationStub *stub = nil;
    @synchronized(stubs) {
        for(stub in stubs) {
            if([stub matchesInvocation:anInvocation])
                break;
        }
        [stub retain];
    }
    if(stub == nil)
        return NO;

// ...expectaion 相关逻辑省略

// 3. 这个 stub 就是 `invocationMatcher`, 交由它处理.
    @try {
        [stub handleInvocation:anInvocation];
    } @finally {
        [stub release];
    }

    return YES;
}

invocationMatcher 的处理逻辑如下:

- (void)handleInvocation:(NSInvocation *)anInvocation {
    NSMethodSignature *signature = [recordedInvocation methodSignature];
    NSUInteger n = [signature numberOfArguments];
    for(NSUInteger i = 2; i < n; i++) {
        id recordedArg = [recordedInvocation getArgumentAtIndexAsObject:i];
        id passedArg = [anInvocation getArgumentAtIndexAsObject:i];

        if([recordedArg isProxy])
            continue;

        if([recordedArg isKindOfClass:[NSValue class]])
            recordedArg = [OCMArg resolveSpecialValues:recordedArg];

        if(![recordedArg isKindOfClass:[OCMArgAction class]])
            continue;

        [recordedArg handleArgument:passedArg];
    }

// 4. 通过记录的 `ValueProvider` 交给它去处理
    [invocationActions makeObjectsPerformSelector:@selector(handleInvocation:) withObject:anInvocation];
}

以 OCMBoxedReturnValueProvider 为例子, 处理逻辑如下

- (void)handleInvocation:(NSInvocation *)anInvocation {
    const char *returnType = [[anInvocation methodSignature] methodReturnType];
    NSUInteger returnTypeSize = [[anInvocation methodSignature] methodReturnLength];
    char valueBuffer[returnTypeSize];
    NSValue *returnValueAsNSValue = (NSValue *)returnValue;

// 5. 将返回值设置到 `invocation` 中 `[anInvocation setReturnValue:valueBuffer]`
    if([self isMethodReturnType:returnType compatibleWithValueType:[returnValueAsNSValue objCType]]) {
        [returnValueAsNSValue getValue:valueBuffer];
        [anInvocation setReturnValue:valueBuffer];
    } else if([returnValueAsNSValue getBytes:valueBuffer objCType:returnType]) {
        [anInvocation setReturnValue:valueBuffer];
    } else {
        [NSException raise:NSInvalidArgumentException
                    format:@"Return value cannot be used for method; method signature declares '%s' but value is '%s'.", returnType, [returnValueAsNSValue objCType]];
    }
}

这样就完成了整个调用过程, 其中如果没有找到匹配的方法等等原因则会判断如果不是 isNice 则会抛出异常.

- (void)handleUnRecordedInvocation:(NSInvocation *)anInvocation {
    if(isNice == NO) {
        [NSException raise:NSInternalInconsistencyException format:@"%@: unexpected method invoked: %@ %@", [self description], [anInvocation invocationDescription], [self _stubDescriptions:NO]];
    }
}

sharedRemoteConfig

看明白了实例方法实际上是通过 mock 对象进行消息转发进行处理, 然后获取期望的结果并返回的, 那类方法又是如何实现 mock 的呢?

关键就在初始化时做了一个准备工作 prepareClassForClassMethodMocking 和刚刚 addStub: 的处理上, 一个一个看.

prepareClassForClassMethodMocking 用注释总结整理如下:

- (void)prepareClassForClassMethodMocking
{
// 1. 排除一些会引起错误的类 `NSString` / `NSArray` / `NSManagedObject`
    if([[mockedClass class] isSubclassOfClass:[NSString class]] || [[mockedClass class] isSubclassOfClass:[NSArray class]])
        return;

    if([mockedClass isSubclassOfClass:objc_getClass("NSManagedObject")])
        return;

// 2. 如果之前有对该类进行的 mock 未停止则停止
    id otherMock = OCMGetAssociatedMockForClass(mockedClass, NO);
    if(otherMock != nil)
        [otherMock stopMockingClassMethods];

    OCMSetAssociatedMockForClass(self, mockedClass);

// 3. 动态创建一个 mock 的类(例子里是 `RemoteConfig` )的子类.
    classCreatedForNewMetaClass = OCMCreateSubclass(mockedClass, mockedClass);
    originalMetaClass = object_getClass(mockedClass);
    id newMetaClass = object_getClass(classCreatedForNewMetaClass);

// 4. 创建一个空方法 `initializeForClassObject`, 作为子类的 `initialize` 方法, 以便排除 mock 类 `initialize` 中特殊逻辑的影响.
    Method myDummyInitializeMethod = class_getInstanceMethod([self mockObjectClass], @selector(initializeForClassObject));
    const char *initializeTypes = method_getTypeEncoding(myDummyInitializeMethod);
    IMP myDummyInitializeIMP = method_getImplementation(myDummyInitializeMethod);
    class_addMethod(newMetaClass, @selector(initialize), myDummyInitializeIMP, initializeTypes);

// 5. `object_setClass(mockedClass, newMetaClass)` 设置 mock 的类的 Class 为新创建的子类的元类.
    object_setClass(mockedClass, newMetaClass);

// 6. 为其元类添加一个 `+ (void)forwardInvocation:` 的实现 `forwardInvocationForClassObject:` 以便可以对类方法进行消息转发.
    Method myForwardMethod = class_getInstanceMethod([self mockObjectClass], @selector(forwardInvocationForClassObject:));
    IMP myForwardIMP = method_getImplementation(myForwardMethod);
    class_addMethod(newMetaClass, @selector(forwardInvocation:), myForwardIMP, method_getTypeEncoding(myForwardMethod));

// 7. 遍历该元类的方法列表, 对其自身的方法(非 `NSObject` 继承来的) 方法执行 `setupForwarderForClassMethodSelector:`
    NSArray *methodBlackList = @[@"class", @"forwardingTargetForSelector:", @"methodSignatureForSelector:", @"forwardInvocation:", @"isBlock",
            @"instanceMethodForwarderForSelector:", @"instanceMethodSignatureForSelector:"];
    [NSObject enumerateMethodsInClass:originalMetaClass usingBlock:^(Class cls, SEL sel) {
        if((cls == object_getClass([NSObject class])) || (cls == [NSObject class]) || (cls == object_getClass(cls)))
            return;
        NSString *className = NSStringFromClass(cls);
        NSString *selName = NSStringFromSelector(sel);
        if(([className hasPrefix:@"NS"] || [className hasPrefix:@"UI"]) &&
           ([selName hasPrefix:@"_"] || [selName hasSuffix:@"_"]))
            return;
        if([methodBlackList containsObject:selName])
            return;
        @try
        {
            [self setupForwarderForClassMethodSelector:sel];
        }
        @catch(NSException *e)
        {
            // ignore for now
        }
    }];
}

addStub: 的特殊逻辑实际上也是执行了 setupForwarderForClassMethodSelector:, 该方法进行了排重. 实现如下:

- (void)setupForwarderForClassMethodSelector:(SEL)selector {
    SEL aliasSelector = OCMAliasForOriginalSelector(selector);
    if(class_getClassMethod(mockedClass, aliasSelector) != NULL)
        return;

    Method originalMethod = class_getClassMethod(mockedClass, selector);
    IMP originalIMP = method_getImplementation(originalMethod);
    const char *types = method_getTypeEncoding(originalMethod);

    Class metaClass = object_getClass(mockedClass);
    IMP forwarderIMP = [originalMetaClass instanceMethodForwarderForSelector:selector];
    class_addMethod(metaClass, aliasSelector, originalIMP, types);
    class_replaceMethod(metaClass, selector, forwarderIMP, types);
}

添加一个 ocmock_replaced_原方法名, 将该方法指向原来方法的方法指针, 并且将原来方法指向到一个不存在的方法上, 以便可以走消息转发, 也就是刚刚添加的 forwardInvocationForClassObject:

在 forwardInvocationForClassObject: 方法真正调用时, 也是调用了 handleInvocation:, 便统一了消息转发的流程, 实现了对类方法的 mock, 不同的是对于没有匹配到的方法直接执行了 invocation,

StopMocking

对于 stopMocking 方法的调用不是必须的, 在 mock 对象释放掉的时候 dealloc 中会先调用 stopMocking, 其中干的事就是打扫战场, 由于设置了 mock 对象的元类为动态创建的子类的元类, 所以需要还原

object_setClass(mockedClass, originalMetaClass);

然后删除掉动态创建的子类, 选择使用动态创建的子类作为元类并且添加方法, 而不是直接修改元类中的方法, 也是为了最后还原比较容易, 直接释放掉即可.

最后

对于其他的 OCMPartialMock 和 OCMProtocolMock 等, 基本原理也都相似, 就不再记录了, 关于 OCMock 大体的原理基本弄清楚了, 其实还有很多细节还得随着继续学习再加深理解, 欢迎交流👏 .

References