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

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

作者: 微微笑的蜗牛 | 来源:发表于2020-06-07 22:37 被阅读0次
    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 单例初始化两次,你遇到过吗?

          本文链接:https://www.haomeiwen.com/subject/apohtktx.html