进行 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,只看函数签名就无法知道它需要什么。
协作开发时这非常危险,因为必须查看所有代码才能看清依赖关系。
第三,全局可变状态带来的并发问题。
如果单例内部持有属性,而多个线程同时读写这些属性,就会发生数据竞争。
实例创建安全,并不意味着其中的状态修改也安全。混淆这两点就会遇到崩溃。
总结如下。
单例真正被诟病的原因,不是“只存在一个”,而是“可以从任何地方取用,也可以从任何地方修改”。
那么,单例就绝对不能使用吗?
不是。我现在仍会在特定情况下使用。
有些东西本来就自然地只应存在一个。例如UserDefaults.standard、FileManager.default和URLSession.shared,Apple 也会在标准库中使用单例。
可以按下面的标准来判断。
- 状态几乎不变,主要是读取 → 可以使用单例
- 整个应用确实只能有一个 → 考虑使用单例
- 测试时需要不同的行为 → 建议使用依赖注入
关键是:不要在代码中直接调用 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 条规则
最后分享一下我在工作中为自己制定的标准。
- 不要创建会在多个地方修改可变状态的单例。需要状态时,要明确它的所有者。
- 不要在函数内部直接调用
shared,而要通过构造器或参数注入。 - 存在并发访问的单例,要使用 actor,或通过串行队列保护。
只要遵守这三点,几乎不会出现“项目因为单例而腐烂”的情况。
单例不是错误的工具,只是因为太容易使用,所以很容易被滥用。
在因为方便而到处散播shared之前,先问自己一次:“以后还能测试这个吗?”这个问题会拯救 6 个月后的自己。

