史上最诡异问题,iOS 单例初始化两次,你遇到过吗?
什么?单例初还能始化两次?听起来就很诡异,对吧。但实际上我还真遇到过,是在写单元测试的时候发生的。当时直接懵圈,百思不得其解。
问题背景
先介绍一下问题出现的背景。
我们的工程做了组件化,以 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!");
}
接下来,单测中的方法调用链路如下:
- 调用组件的接口:
[ComponentMediator taskIsDone:taskId]
。 - 调用组件具体实现:
action_NativeTaskIsDone
。 - 调用单例对象方法:
[[TaskManager sharedManager] taskIsDone:taskId]
。
从上可以看到,这里又一次调用了 TaskManager
的单例对象。注意,就是在这里,单例进行了第二次初始化!简直不可思议 🤩。
问题排查
起初怀疑单例实现写得有问题,可仔细看看,哪哪都正常得很,就是很常规的 dispatch_once
。
后来在网上搜到了一些相关的信息,iOS Testing: dispatch_once get called twice。
答案中指出,如果把一个类同时添加在 xx
和 xxTests
的 Target membership
中(xx
代指工程名称),则会出现这个问题。如下图所示:
因为 target
是各自独立的,即使相同的类在不同的 target
中也是不一样的。因此单例的初始化状态在不同的 target
中并不共享。
这种解释听着还是有点道理的。于是,立马写了个 UnitTestDemo
来验证是否正确。果不其然,妥妥的 right
。请看下面两张图。
-
在
image.pngUnitTestDemo
中的[ViewController ViewDidLoad]
,调用单例,初始化一次。 -
在
image.pngUnitTestDemoTests
写单元测试,调用单例,再次初始化一次。
以上图示说明单例确实是初始化了两次。而将单例类从 UnitTestDemoTests
的 Target 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
中测试却是好的。现在看来,也是同一个问题。
终于,两处都云雾散开,往日的光明也渐渐恢复。