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

用户头像
liu_liu
关注
发布于: 2020 年 06 月 07 日
史上最诡异问题,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!");
}



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

  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 代指工程名称),则会出现这个问题。如下图所示:





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



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



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



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





以上图示说明单例确实是初始化了两次。而将单例类从 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 中测试却是好的。现在看来,也是同一个问题。



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



发布于: 2020 年 06 月 07 日 阅读数: 34
用户头像

liu_liu

关注

不要相信自己的记忆力 2017.11.13 加入

还未添加个人简介

评论

发布
暂无评论
史上最诡异问题,iOS 单例初始化两次,你遇到过吗?