史上最诡异问题,iOS 单例初始化两次,你遇到过吗?

Fearless Motivational Quote Desktop Wallpaper.jpg

什么?单例初还能始化两次?听起来就很诡异,对吧。但实际上我还真遇到过,是在写单元测试的时候发生的。当时直接懵圈,百思不得其解。

问题背景

先介绍一下问题出现的背景。

我们的工程做了组件化,以 Pod 方式进行组件管理。组件之间的通信是基于 Mediator 那一套方式。组件之间不可避免的会有交互,因此有一些组件需要对外提供 API。而我的工作就是对这些 API 进行单元测试,保证对外输出质量。

举个栗子。 比如组件 A 对外提供了一些接口,定义在 ComponentMediator+xx.h 中,内容如下:

// 任务是否完成
- (BOOL)taskIsDone:(NSString *)taskId;

具体的实现,在组件 Targetxx.m 中:

- (NSNumber *)action_NativeTaskIsDone:(NSDictionary *)params {
    
  // 取出参数 taskId
  NSString *taskId = params[@"taskId"];
  
  // 具体实现,调用单例对象
  BOOL result = [[TaskManager sharedManager] taskIsDone:taskId];
  return @(result);
}

现在,我需要测试 taskIsDone 方法,它最终会调用到组件内部的 action_NativeTaskIsDone 方法。

虽然在进行代码设计时,应避免使用单例,因为可测性不好。但由于历史原因,还得使用单例测试,暂时先忽略这一点。

编写单测

测试前,第一步得准备数据。

因此,我首先调用了 [TaskManager sharedManager] 来设置一些初始数据。毫无疑问,这里单例会进行初始化。

- (void)testTaskIsDone {
    NSString *taskId = @"12";
    [TaskManager sharedManager].taskStatusDict = @{taskId: @(0)};
    
    // 调用组件的 api
    BOOL result = [ComponentMediator taskIsDone:taskId];
    XCTAssert(result, @"task should be done!");
}

接下来,单测中的方法调用链路如下:

  1. 调用组件的接口: [ComponentMediator taskIsDone:taskId]
  2. 调用组件具体实现: action_NativeTaskIsDone
  3. 调用单例对象方法: [[TaskManager sharedManager] taskIsDone:taskId]

从上可以看到,这里又一次调用了 TaskManager 的单例对象。注意,就是在这里,单例进行了第二次初始化!简直不可思议 。

问题排查

起初怀疑单例实现写得有问题,可仔细看看,哪哪都正常得很,就是很常规的 dispatch_once

后来在网上搜到了一些相关的信息,iOS Testing: dispatch_once get called twice。

答案中指出,如果把一个类同时添加在 xxxxTestsTarget membership 中(xx 代指工程名称),则会出现这个问题。如下图所示:

image.png

因为 target 是各自独立的,即使相同的类在不同的 target 中也是不一样的。因此单例的初始化状态在不同的 target 中并不共享。

这种解释听着还是有点道理的。于是,立马写了个 UnitTestDemo 来验证是否正确。果不其然,妥妥的 right。请看下面两张图。

  • UnitTestDemo 中的 [ViewController ViewDidLoad],调用单例,初始化一次。

    image.png
  • UnitTestDemoTests 写单元测试,调用单例,再次初始化一次。

    image.png

以上图示说明单例确实是初始化了两次。而将单例类从 UnitTestDemoTestsTarget membership 去除后,恢复正常。

解决方案

回到工程中遇到的问题,由于我们的组件是以 Pod 方式管理,并不能直接使用去除 Target membership 的方式,不过根因是一样的。

而其中有一个回答,恰好讲到了 PodFile 相关的设置,exclusive => true。不过不凑巧的是,这个属性已经被移除掉了。

于是再仔细看了看组件中的PodFile,发现了一点点端倪。PodFile 内容如下:

target 'xx_Example' do
  pod 'xx', :path => '../'
end

target 'xx_Tests' do
  pod 'xx', :path => '../'
  pod 'OCMock'
end

每个 target 都各自引入了 xx 组件 ,也就是将相同的类同时添加到了两个 target 中,与上述问题描述是一致的。那么基本可以确定问题所在了,将 xx_Tests 中的 pod 'xx' 去除后,一切正常。

不过更推荐 cocoapods 官方的写法:

target 'xx_Example' do
  pod 'xx', :path => '../'
  
  target 'xx_Tests' do
    inherit! :search_paths
    pod 'OCMock'
  end
end

注意要添加 inherit! :search_paths,否则问题依然存在。

inherit! :search_paths 的官方解释如下:

The only new thing is inherit! :search_paths which means "don't link Pods into here, but let this target know they exist."

它表示不会将 Pods 链接到 xx_Tests 中,只是让 xx_Tests 知道它们的存在。

另一个诡异问题

这个问题的解决,也随之让另一个诡异事件的真相浮出了水面。

同样是测试一个 API 接口。这个接口功能很简单,即传入一个对象,在接口实现中使用 isKindOfClass 来判断这个对象是否属于 A 类型 。大致逻辑如下:

- (id)action_Nativexxx:(NSDictionary *)params {
    // 取出对象
    id obj = params[@"obj"];
    
    // 传入的 obj 是 A 类型。但诡异的是,这里始终返回 NO
    if ([obj isKindOfClass:[A class]]) {
        
    }
    
    //...
}

虽然在单测调用时,原本就是将 A 类型的对象传入,但死活返回 NO,弄得我都有点怀疑人生。但单独在 tests target 中测试却是好的。现在看来,也是同一个问题。

终于,两处都云雾散开,往日的光明也渐渐恢复。

你可能感兴趣的:(史上最诡异问题,iOS 单例初始化两次,你遇到过吗?)