"我知道单例是不好的,但是......",这是开发人员在讨论代码时经常说的话。社区里似乎有一个共识,那就是单例是 "不好的",但同时苹果和第三方的Swift开发者都在应用内部和共享框架中不断使用它们。
本周,让我们来看看使用单例的问题到底是什么,并探讨一些可以用来避免这些问题的技巧。让我们直接开始吧!
为什么单例如此受欢迎?
首先,让我们先问一下,为什么单例一开始就这么受欢迎。如果大多数开发者都同意应该避免使用单例,为什么它们会不断出现?
我认为答案有两个部分:
首先,我认为在为苹果公司的平台编写应用程序时,单例模式被大量使用的一个主要原因是苹果公司自己经常使用它。作为第三方开发者,我们经常期望苹果为他们的平台定义 "最佳实践",通常他们使用的任何模式也会在社区中广泛传播。
我认为,难题的第二部分是方便。单例通常可以作为访问某些核心值或对象的捷径,因为它们基本上可以从任何地方访问。看看这个例子,我们想在ProfileViewController
中显示当前登录用户的名字,并在点击按钮时将用户退出登录:
class ProfileViewController: UIViewController {
private lazy var nameLabel = UILabel()
override func viewDidLoad() {
super.viewDidLoad()
nameLabel.text = UserManager.shared.currentUser?.name
}
private func handleLogOutButtonTap() {
UserManager.shared.logOut()
}
}
像上面那样做——将用户和账户处理功能封装在UserManager
单例中——确实非常方便(而且非常普遍!)。那么,使用这种模式到底有什么不好呢?🤔
单例有什么不好?
在讨论模式和架构等问题时,我们很容易陷入过于理论化的陷阱。虽然让我们的代码在理论上 "正确 "并遵循所有的最佳实践和原则是很好的,但现实往往是这样,我们需要找到某种中间地带。
那么,单例通常会造成哪些具体问题,为什么要避免它们?我倾向于避免使用单例的三个主要原因是:
它们是全局可变共享状态。它们的状态会自动在整个应用程序中共享,而当这种状态意外改变时,往往会开始出现bug。
单例和依赖它们的代码之间的关系通常不是很好定义。 由于单例是如此方便和容易访问——广泛地使用它们通常会导致非常难以维护的 "面条式代码",它在对象之间没有明确的分隔。
管理它们的生命周期是很棘手的。由于单例在应用程序的整个生命周期中都是存活的,管理它们可能真的很困难,而且它们通常必须依靠可选值来跟踪数值。这也使得依赖单例的代码很难测试,因为你不能轻易地从每个测试案例的 "白板 "上开始。
在我们之前的ProfileViewController
例子中,我们已经可以看到这三个问题的迹象。很明显,它依赖于UserManager
,而且它必须作为一个可选值访问currentUser
,因为我们没有办法在编译时保证数据在视图控制器被呈现时确实存在。
依赖注入
与其让ProfileViewController
使用单例访问它的依赖项,我们不如在它的初始化器中注入它们。在这里,我们将当前的User
作为一个非可选值注入,以及一个LogOutService
,可以用来执行注销操作:
class ProfileViewController: UIViewController {
private let user: User
private let logOutService: LogOutService
private lazy var nameLabel = UILabel()
init(user: User, logOutService: LogOutService) {
self.user = user
self.logOutService = logOutService
super.init(nibName: nil, bundle: nil)
}
override func viewDidLoad() {
super.viewDidLoad()
nameLabel.text = user.name
}
private func handleLogOutButtonTap() {
logOutService.logOut()
}
}
其结果是更加清晰和容易管理。我们的代码现在可以安全地依赖它的模型,而且它有一个清晰的API与之交互,以便注销。一般来说,将各种单例和管理器重构为清晰分离的服务,是在应用程序的核心对象之间建立更清晰关系的好方法。
服务
作为一个例子,让我们仔细看看LogOutService
可以如何实现。它也为其底层服务使用了依赖注入,并提供了一个很好的、定义清晰的API,只为做一件事——注销(logOut
)。
class LogOutService {
private let user: User
private let networkService: NetworkService
private let navigationService: NavigationService
init(user: User,
networkService: NetworkService,
navigationService: NavigationService) {
self.user = user
self.networkService = networkService
self.navigationService = navigationService
}
func logOut() {
networkService.request(.logout(user)) { [weak self] in
self?.navigationService.showLoginScreen()
}
}
}
改造
从一个大量使用单例的设计变成一个完全利用服务、依赖注入和本地状态的设计,可能真的很棘手,也很耗时。这也很难证明花费时间是合理的,有时甚至需要进行巨大的重构才能实现。
值得庆幸的是,我们可以应用一个类似于 "通过 3 个简单的步骤测试使用了系统单例的 Swift 代码"中的技术,这将使我们能够以更容易的方式开始摆脱单例。就像在许多其他情况下一样——协议将会来拯救我们!"。
我们可以简单地将我们的服务定义为协议,而不是一次性重构我们所有的单例并创建新的服务类,就像这样:
protocol LogOutService {
func logOut()
}
protocol NetworkService {
func request(_ endpoint: Endpoint, completionHandler: @escaping () -> Void)
}
protocol NavigationService {
func showLoginScreen()
func showProfile(for user: User)
...
}
然后,我们可以通过使它们符合我们的新服务协议来轻松地将我们的单例“改造”为服务。在许多情况下,我们甚至不需要对实现进行任何更改,并且可以简单地将它们的共享(share
)实例作为服务传递。
同样的技术也可以用来改造我们应用程序中的其他核心对象,我们可能一直在以 "类似单例 "的方式使用这些对象,例如使用AppDelegate
进行导航.
extension UserManager: LoginService, LogOutService {}
extension AppDelegate: NavigationService {
func showLoginScreen() {
navigationController.viewControllers = [
LoginViewController(
loginService: UserManager.shared,
navigationService: self
)
]
}
func showProfile(for user: User) {
let viewController = ProfileViewController(
user: user,
logOutService: UserManager.shared
)
navigationController.pushViewController(viewController, animated: true)
}
}
我们现在可以通过使用依赖注入和服务,使我们所有的视图控制器 "无单例",而不必在前期进行大量的重构和重写🎉!然后,我们可以开始用服务和其他类型的API逐一替换我们的单例,例如使用 "使用Swift协议替历史遗留代码 "的技术。
结论
单例并不普遍是坏事,但在许多情况下,它们会带来一系列的问题,这些问题可以通过在对象之间建立更明确的关系和使用依赖注入来避免。
如果你正在开发一个目前大量使用单例的应用程序,并且你一直在经历它们通常导致的一些bug,希望这篇文章能给你一些灵感,让你知道如何能以一种非破坏性的方式开始摆脱它们。
你怎么看,你会开始重构你的单例,还是你的应用程序已经“无单例”了?
译自 John Sundell 的 Avoiding singletons in Swift