单例之罪

504 查看

单例不是一种反模式,它们只是被滥用的模式。它们因方便,而大受初学者欢迎。但它们也有可能增加复杂性并引起致命bug。

让我们先从优点说起。经常会遇到这样的情况,你需要用一个实例表示某个对象,有且只能有一个。想想 UIApplication:

那比下面这个要更清楚明白:

firstInstance == secondInstance 是否应该总是返回 true?当我们更新一个,是否更新另一个?

举个恰当的比喻,单例代表了一个无所不在的对象,一个永远不会随你改变的对象,像目前的应用,设备的加速度计,或你的上帝。

很少有需要在应用程序中共享一个单一服务的情况。NSNotificationCenter 只在一种情况下起作用,即整个组件层只有一个单一的广播,因此它的名字中含有“中心(Center)”。1 在除此以外的其他地方,不要使用单例。

不当的模型

有时我看到用 User.currentUser 或 Account.sharedAccount 表示当前登录用户。我不怪你。因为这样很方便。

但帐户不是单例。用户会注销帐户。许多应用程序通过一种特殊类型的帐户表示一个登出的状态。服务逐渐支持多账户同时登录。帐户是可变的,这样单例就是一个谎言。

如果帐户是一个真正的单例,这将不是一个问题:

如果头像在一个缓慢的网络中需要几分钟才能上传,如果在这期间用户切换了帐户呢?那么得到头像的帐户就不对了。

单例共享状态

“单例”可以看作是“全局变量”。有时,全局变量几乎不是必要的,你应该尽力避免它们。保存状态很难,共享状态就更难了。

自定义容器的视图控制器使 viewWillAppear 没那么难以预料。即使是普通的导航也存在边缘情况:如果你尝试滑动返回,你会改变想法,并取消它,因为你会得到一个错误的 viewWillappear,从而在错误的视图控制器中记录事件。

这样会更好

但现实世界往往会是不一样的情况。也许你的分析团队会发现存在重复点击,因而他们要求你只记录一次,即每个视图控制器实例只调用 Tappedreply 一次。

在真实情况中,你可能会想把你所有的分析请求都保留在一个队列中,这样你就可以将它们进行节流。也许一个单例是比较好的选择。至少缩小了范围:

跨界单例

这里有一个很有趣大bug,是我重构的时候遇到的:

这段代码连初始化都完成不了,因为 Timeline 对象在初始化的过程中访问 Account 单例,而 Account 单例在初始化中也调用了 Timeline 初始化方法。想象这种错误代码在对象图中埋藏更深的隐患。​

如果我们想对 Timeline 进行单元测试该怎么办呢?我们必须模拟 Account 对象,并返回一个模拟 preference 对象。啊,真是够了。

如果任何人都可以持有一个对象,应用程序中的任何对象都可以具有隐藏的依赖关系是一件很纠结的事情。

还是重构一下吧:

这样所有权就很清晰了。Account 配置它的子类,Timeline。它恰好从 preference 中获取配置,但是我们可以在测试中指定任何值。​

没有必要将视图控制器与 Account 单例分离。和重构以前相比:​

重构以后:

最好的实践

对于非常简单的应用程序,你将永远不会遇到单例问题。当你第一次建立一个应用程序,这个最好的做法似乎是多余的。“你不需要它,”有人说,“任何时候你都可以重构!“​

根据我以往的经验,填以前的坑显然比第一次就把事情做好要难的多。当你把自己逼入困境的时候,并且你的产品经理又要求你做一个快速的改变,只尝试一次就成功还是会有很多压力的。​

这不是关于假想功能的设计,如支持多个帐户。而是关于如何应对未知的明天。为了保留灵活性适当的额外工作也是值得的。​