软件设计

Swift 单例模式:shared 被称为反模式的真正原因

进行 Swift 开发时,经常会遇到只用一行 static let shared 创建的单例。

4 分钟阅读
Swift 单例模式:shared 被称为反模式的真正原因 封面图

进行 Swift 开发时,经常会遇到只用一行 static let shared 创建的单例。

刚开始开发 iOS 应用时,很容易把网络管理器、用户会话,甚至缓存管理全部做成单例。

因为很方便。从任何地方调用Manager.shared就行了。

但项目变大后,奇怪的事情开始发生。测试无法进行。明明只修复了一个页面,错误却在完全无关的地方爆发。

单例被称为反模式,当然有它的理由。

本文将从实践角度梳理 Swift 单例模式究竟是什么、为什么滥用后会被视为反模式,以及哪些情况下仍然可以使用。

先说结论,单例本身并不坏,问题在于“让状态可以从全局任意位置被修改”的方式。因此,毫无节制地滥用 shared 会阻碍测试、隐藏依赖,甚至引发并发问题。

Swift 单例模式,到底是什么?

单例是一种设计模式,用来保证整个应用中只存在一个实例。

在 Swift 中可以非常简短地实现它。这既是它的魅力,也是陷阱。

final class NetworkManager {
    static let shared = NetworkManager()  // 应用中只有一个
    private init() {}                     // 阻止外部创建
    func request(_ url: URL) { /* ... */ }
}

static let由 Swift 以线程安全的方式只初始化一次。因此,即使没有单独的 lock,实例创建本身也是安全的。

使用private init()阻止外部创建同样很关键。缺少它,就只是全局对象,而不是单例。

看到这里,一切似乎都很简洁。问题从它可以在任何地方被调用开始。


为什么 shared 实例会被称为反模式?

下面按照我的实际经历顺序,说明最常被指出的三个原因。

第一,测试会变成地狱。

如果在代码中直接调用NetworkManager.shared,测试时就无法把它替换成 mock 对象。

请求可能会发送到真实服务器,或者测试之间共享状态,因此只要顺序改变,结果也会不同。

第二,依赖会被隐藏。

如果某个函数在内部偷偷使用UserSession.shared,只看函数签名就无法知道它需要什么。

协作开发时这非常危险,因为必须查看所有代码才能看清依赖关系。

第三,全局可变状态带来的并发问题。

如果单例内部持有属性,而多个线程同时读写这些属性,就会发生数据竞争。

实例创建安全,并不意味着其中的状态修改也安全。混淆这两点就会遇到崩溃。

以前把 shared 到处铺开的代码,现在回头看真让人后怕
以前把 shared 到处铺开的代码,现在回头看真让人后怕

总结如下。

单例真正被诟病的原因,不是“只存在一个”,而是“可以从任何地方取用,也可以从任何地方修改”。


那么,单例就绝对不能使用吗?

不是。我现在仍会在特定情况下使用。

有些东西本来就自然地只应存在一个。例如UserDefaults.standardFileManager.defaultURLSession.shared,Apple 也会在标准库中使用单例。

可以按下面的标准来判断。

  • 状态几乎不变,主要是读取 → 可以使用单例
  • 整个应用确实只能有一个 → 考虑使用单例
  • 测试时需要不同的行为 → 建议使用依赖注入

关键是:不要在代码中直接调用 shared,而要从外部注入。

以 shared 为默认值,测试时注入伪对象的结构
以 shared 为默认值,测试时注入伪对象的结构

即使使用同一个单例,这样修改后也能大幅提升可测试性。

protocol Networking { func request(_ url: URL) }
extension NetworkManager: Networking {}

final class FeedViewModel {
    private let network: Networking
    init(network: Networking = .shared) {  // 默认值是单例,测试时注入 mock 
        self.network = network
    }
}

平时使用默认单例很方便,测试时又可以放入伪对象,因此更加灵活。

不是放弃单例,而是改变了调用方式。


我在实践中遵守的 3 条规则

最后分享一下我在工作中为自己制定的标准。

  1. 不要创建会在多个地方修改可变状态的单例。需要状态时,要明确它的所有者。
  2. 不要在函数内部直接调用shared,而要通过构造器或参数注入。
  3. 存在并发访问的单例,要使用 actor,或通过串行队列保护。

只要遵守这三点,几乎不会出现“项目因为单例而腐烂”的情况。

单例不是错误的工具,只是因为太容易使用,所以很容易被滥用。

在因为方便而到处散播shared之前,先问自己一次:“以后还能测试这个吗?”这个问题会拯救 6 个月后的自己。

延伸阅读