避免在 Swift 中使用单例

"我知道单例是不好的,但是......",这是开发人员在讨论代码时经常说的话。社区里似乎有一个共识,那就是单例是 "不好的",但同时苹果和第三方的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,希望这篇文章能给你一些灵感,让你知道如何能以一种非破坏性的方式开始摆脱它们。

你怎么看,你会开始重构你的单例,还是你的应用程序已经“无单例”了?

\color{orange}{\Large \mathtt{避免在 Swift 中使用单例}}

译自 John Sundell 的 Avoiding singletons in Swift

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,839评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,543评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,116评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,371评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,384评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,111评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,416评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,053评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,558评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,007评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,117评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,756评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,324评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,315评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,539评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,578评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,877评论 2 345

推荐阅读更多精彩内容