避免滥用单例模式

[翻译]本文翻译自objc.io官网的博客, 原文可查看Avoiding Singleton Abuse

——————————————我是分割线——————————————

单例是整个Cocoa的核心设计模式之一, 事实上,苹果的开发者图书馆还认为单例是Cocoa的核心能力。作为iOS开发者,我们都熟悉与单例的交互,例如UIApplication和NSFileManager。 在Apple代码示例和StackOverflow的开源项目中我们能看到无数单例的使用。 Xcode甚至有一个默认的API(Dispatch Once)使得创建单例非常容易:

+ (instancetype)sharedInstance
{
    static dispatch_once_t once;
    static id sharedInstance;
    dispatch_once(&once, ^{
        sharedInstance = [[self alloc] init];
    });
    return sharedInstance;
}

基于以上原因,单例在iOS编程中很常见。但问题是他们很容易被滥用。
虽然有些人把单例描述为“反模式”、“邪恶”和“病态骗子”,但我不会完全无视单例的优点。 相反,我想演示单例的几个问题,以便下次你要使用dispatch_once创建单例时考虑再三。

全局状态

大多数开发者认为全局可变状态是一件坏事。 这些状态使得程序难以理解并且难以调试。 就最小化代码中的状态而言,我们面向对象的程序员可以从函数式编程中学到很多东西。

@implementation SPMath 
{
    NSUInteger _a;
    NSUInteger _b;
}

- (NSUInteger)computeSum
{
    return _a + _b;
}

在上面的简单数学库实现中,程序员需要在调用computeSum之前将实例变量_a和_b设置为适当的值。 这里有几个问题:

  1. computeSum不会通过将值作为参数来明确依赖于状态_a和_b的事实。 读取该代码的另一位开发人员必须检查实现以了解依赖关系,而不是检查接口并理解哪些变量控制函数的输出。 隐藏依赖关系是很糟糕的。
  2. 在修改_a和_b以准备调用computeSum时,程序员需要确保不会影响依赖于这些变量的任何其他代码的正确性。 这在多线程环境中特别困难。

把上面的例子和下例做个对比:

+ (NSUInteger)computeSumOf:(NSUInteger)a plus:(NSUInteger)b
{
    return a + b;
}

这里对a和b的依赖是明确的。 我们不需要为了调用这个方法而改变实例状态。 我们不需要担心由于调用此方法而导致持久的副作用。 作为对这段代码的读者的一个提示,我们甚至可以使这个方法成为一个类方法来表明它不会修改实例状态。

那么这个例子与单例有什么关系呢? 用MiškoHevery的话来说,“单例就是披着羊皮的全局状态”。单例可以不必声明依赖关系就在任何地方使用。就像_a和_b被用在computeSum中而不需要明确依赖关系,程序的任何模块都可以调用[SPMySingleton sharedInstance]来访问单例。 这意味着与单例交互的任何副作用都有可能会影响程序中其他地方的任意代码。

@interface SPSingleton : NSObject
+ (instancetype)sharedInstance;
- (NSUInteger)badMutableState;
- (void)setBadMutableState:(NSUInteger)badMutableState;
@end

@implementation SPConsumerA
- (void)someMethod
{
    if ([[SPSingleton sharedInstance] badMutableState]) {
        // ...
    }
}
@end

@implementation SPConsumerB
- (void)someOtherMethod
{
    [[SPSingleton sharedInstance] setBadMutableState:0];
}
@end

在上面的例子中,SPConsumerA和SPConsumerB是该程序的两个完全独立的模块。 然而,SPConsumerB能够通过单例提供的setBadMutableState方法改变badMutableState变量的值,从而影响SPConsumerA的行为。 只有当SPConsumerA明确引用SPConsumerB时,才有可能明确两者之间的关系。 这里的单例由于其全局性和有状态性,会在看起来不相关的模块之间引起隐藏和隐含的耦合。

让我们来看一个更具体的例子,以暴露全局可变状态的另一个问题。 假设我们想在我们的应用程序内部构建一个Web查看器。 为了支持这个Web查看器,我们构建了一个简单的URL缓存:

@interface SPURLCache
+ (SPCache *)sharedURLCache;
- (void)storeCachedResponse:(NSCachedURLResponse *)cachedResponse forRequest:(NSURLRequest *)request;
@end    

开发人员基于Web浏览器编写了一些单元测试,以确保代码在几种不同情况下按预期工作。首先,他编写了一个测试以确保在无设备连接时Web查看器能显示错误。然后他又编写了一个测试以确保Web查看器能正确处理服务器故障。最后,他为成功情况写了一个测试,以确保返回的网页内容能够正确显示。开发人员运行所有测试,一切都如预期成功工作。太好了!

几个月后,这些测试开始失效,即使网页查看器代码自第一次写完以后未曾改动!发生了什么?

原来有人改变了单元测试的执行顺序。成功情况测试(第三个)首先运行,然后是其他两个。本来的错误捕捉测试现在居然意外地成功了,这是因为URL缓存单例在多个测试执行过程中一直缓存着第一次测试的响应。

持久状态是单元测试的敌人,因为单元测试是独立于所有其他测试而进行的。如果一个状态在多个测试中先后被引用,那么执行测试的顺序突然就变得很重要。 要知道,如果一个测试不应该成功但却测试成功,是一件非常糟糕的事情。

对象的生命周期

单例的另一个主要问题是他们的生命周期。 在你的程序中添加一个单例时,很容易想到,“只会有其中一个”。但是在我看到的大部分iOS代码中,这个假设可能会不成立。

例如,假设我们正在写一个应用程序,用户可以在其中查看朋友列表。 他们的每个朋友都有个人资料图片,我们希望应用能够下载并在设备上缓存这些图片。 由于dispatch_once用起来很方便,我们可能会发现自己正在编写一个叫SPThumbnailCache的单例:

@interface SPThumbnailCache : NSObject

+ (instancetype)sharedThumbnailCache;

- (void)cacheProfileImage:(NSData *)imageData forUserId:(NSString *)userId;
- (NSData *)cachedProfileImageForUserId:(NSString *)userId;

@end

我们继续构建应用程序,一切看起来都很正常。直到有一天,当我们决定实施“注销”功能时,用户可以在应用程序内切换帐户。 突然间,我们手上有一个令人讨厌的问题:用户把特定状态(当前用户朋友列表的个人资料图片)存储在全局单例中。 当用户注销应用程序时,我们希望能够清理磁盘上的所有持久状态。 否则我们会在用户的设备上留下孤立的数据,浪费宝贵的磁盘空间。 如果用户注销并登录了新帐户,我们也希望能够为新用户提供一个新的SPThumbnailCache。

这里的问题是,按照定义,单例被认为是“一次创造,永远活着”的实例。 你可以想象一下上述问题的一些解决方案。 也许我们可以在用户注销时释放单例实例:

static SPThumbnailCache *sharedThumbnailCache;

+ (instancetype)sharedThumbnailCache
{
    if (!sharedThumbnailCache) {
        sharedThumbnailCache = [[self alloc] init];
    }
    return sharedThumbnailCache;
}

+ (void)tearDown
{
    // The SPThumbnailCache will clean up persistent states when deallocated
    sharedThumbnailCache = nil;
}

我们当然可以使用这个方案解决上述问题,但成本太高了。 首先,我们失去了使用dispatch_once的简单性,这是一个保证线程安全的解决方案,并且所有调用[SPThumbnailCache sharedThumbnailCache]的代码都只能获得相同的实例对象。 我们现在需要对缩略图缓存的代码执行顺序非常小心。 假设用户正在注销过程中,有一些后台任务正在将图像保存到缓存中:

dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
    [[SPThumbnailCache sharedThumbnailCache] cacheProfileImage:newImage forUserId:userId];
});

我们需要确保tearDown方法在该后台任务完成之后才会执行,这能确保newImage数据被正确清理。或者,我们需要确保在tearDown方法在被调用时能取消后台任务。否则,一个新的缩略图缓存类将被懒加载,陈旧的用户状态(newImage)也会被存储在其中。

由于单例没有实质的所有者(即单例管理自己的生命周期),所以“关闭”单例变得非常困难。

在这一点上,我希望你说,“缩略图缓存类不应该是一个单例!” 问题是,在项目开始时,我们对对象的生命周期不可能完全了解。作为一个具体的例子,Dropbox iOS应用只支持一个帐户登录。Dropbox以该状态已存在多年,直到有一天我们希望支持多个帐户(包括个人帐户和商业帐户)同时登录。突然之间,“只有一个用户登录”的假设开始崩塌。假设对象的生命周期将与应用程序的生命周期同步,你将限制代码的可扩展性,并且你可能会在产品需求发生变化时为此假设付出代价。

这里的教训是,单例应该只为全局性状态而创建,而不是被任何范围绑定的局部状态。如果一个状态的应用范围小于应用程序的完整生命周期的话,则该状态不应由单例管理。管理用户特定状态的单例是一种代码异味,这时你应该批判性地重新评估对象的设计是否有误。

避免使用单例

所以,如果单例对于范围化的状态而言如此糟糕,我们应如何避免使用它们?

我们重新审视一下上面的例子。 由于我们需要一个缩略图缓存类来缓存个人用户的特定状态,那就让我们定义一个用户对象:

@interface SPUser : NSObject

@property (nonatomic, readonly) SPThumbnailCache *thumbnailCache;

@end

@implementation SPUser

- (instancetype)init
{
    if ((self = [super init])) {
        _thumbnailCache = [[SPThumbnailCache alloc] init];

        // Initialize other user-specific state...
    }
    return self;
}

@end

我们现在有一个对象SPUser来实体化验证过的用户数据,并且我们可以将所有用户的特定状态存储在此对象中。 现在假设我们有一个显示朋友列表的视图控制器:

@interface SPFriendListViewController : UIViewController

- (instancetype)initWithUser:(SPUser *)user;

@end

我们可以明确地将经过身份验证的用户对象传递给视图控制器。 这种将依赖关系传递给依赖对象的技术被称为依赖注入,它具有许多优点:

  1. 它向initWithUser这个接口的读者清楚地表明,只有注入一个已登录用户,SPFriendListViewController才能显示数据。
  2. 只要SPFriendListViewController被使用,它就可以保持对用户对象的强引用。 例如,我们可以将图像保存到后台任务中的缩略图缓存类,并更新前面的代码示例,如下所示:
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
    [_user.thumbnailCache cacheProfileImage:newImage forUserId:userId];
});

在这个后台任务仍然顺利执行的情况下,应用程序中的其他代码能够创建并使用全新的SPUser对象,而不会因为第一个SPUser实例正在被“杀死”而阻止了进一步的交互。

为了进一步证明第二点,让我们使用依赖注入之前和之后的可视化对象图。

假设我们的SPFriendListViewController是当前窗口中的根视图控制器。 在单例模型中,我们有一个如下所示的对象图:


01.png

视图控制器本身以及其中自定义的ImageView与sharedThumbnailCache对象进行交互。 当用户注销时,我们要清除根视图控制器并将用户带回登录页:

02.png

这里的问题是,朋友列表视图控制器可能仍然在执行代码(由于后台操作),因此可能仍然有待处理的sharedThumbnailCache调用。

将单例方案与使用依赖注入的解决方案进行对比:


03.png

假设,为了简单起见,SPApplicationDelegate负责管理SPUser实例(实际上,您可能希望将这种用户状态管理的职责转移到另一个对象上以使AppDelegate更轻)。 当window“安装”好朋友列表视图控制器时,一个用户对象会被传递过来。 此引用也可以从对象图形中下载到配置文件图像视图。 现在,当用户注销时,我们的对象图如下所示:


04.png

对象图看起来非常类似于我们使用单例的情况。 那么最重要的区别是什么?

问题是范围。 在单例情况下,sharedThumbnailCache仍然可以被程序的任意模块访问。 假设用户快速登录到新帐户。 新用户也希望看到他或她的朋友,这意味着我们需要再次与缩略图缓存单例进行交互:

05.png

当用户登录到新帐户时,我们应该能够构建全新的SPThumbnailCache并与其交互,而不必关心旧缩略图缓存的销毁。 根据对象管理的典型规则,旧视图控制器和旧缩略图缓存应该在后台自动清理。 简而言之,我们应该将与用户A关联的状态与用户B关联的状态隔离开来:


06.png

结论

希望这篇文章中没有什么特别新颖的东西。 人们一直抱怨多年来滥用单例,我们都知道全局状态很糟糕。 但是在iOS开发的世界里,单例的使用是如此普遍,以至于我们有时会忘记多年面向对象编程中学到的经验教训。

所有这一切的关键是,在面向对象编程中,我们希望尽量减少可变状态的范围。 单例模式直接违背了该原则,因为它们在程序中的任何地方都可以获取某个全局状态并修改它。 下次你想使用单例时,我希望你考虑依赖注入作为替代。

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

推荐阅读更多精彩内容