objc:Issue13——Architecture【2】

避免单例滥用——by Stephen Poletto

单例是整个Cocoa使用的核心设计模式之一。事实上,苹果的开发库把单例当做“Cocoa核心竞争力”之一。作为iOS开发者,从UIApplicationNSFileManager,我们对与单例的交互已经很熟悉了。在开源项目、苹果代码示例和StackOverflow中,我们见到过的单例已多如牛毛。甚至,Xcode还有默认的代码片段,如:”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. 当为了准备调用computeSum而修改_a_b的时候,程序员需要确定这些修改不会影响其它依赖这些变量的代码的正确性。这在多线程环境尤为困难。

把这下面这个例子与上述的例子比较一下:

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

这里方法对ab的依赖就很明显。为了调用这个方法我们不需要改变实例的状态。我们也不必担心由于调用此方法而导致的持久的副作用,我们甚至可以把这个方法当做类方法,以表明我们调用此方法不需要修改实例状态。

但是,这个例子和单例有什么关系呢?用Miško Hevery的话说,“单例是披着羊皮的全局状态。”单例可以使用在任何地方,而不用明确的声明依赖关系。就像computeSum方法中的_a_b没有明确的依赖关系一样,程序的任何模块都可以调用[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

在上述的例子中,SPConsumerASPConsumerB是程序中两个完全独立的模块。然而SPConsumerB可以通过单例提过的共享状态影响SPConsumerA的行为。在不使用单例的情况下,只有在消费者B中引入消费者A,明确两者之间的关系才能达到上述这样的效果。在单例中,由于它的全局有状态的性质,导致了看似两个不相关的模块之间的隐藏和隐式的耦合。

让我们看一个更具体的例子,并提出另外一个由全局可变状态而引起的问题。假设我们想在我们的应用中创建一个web查看器。为了支持这个web查看器,我们创建了一个简单地URL缓存:

@interface SPURLCache

+ (SPURLCache *)sharedURLCache;
- (void)storeCacheResponse:(NSCachedURLResponse *)cachedResponse forRequest:(NSURLRequest *)request;

@end

编写web查看器的开发者开始写几个单元测试,以保证代码在期望的几个不同的情况下能够正常工作。首先,写一个测试程序保证web查看器在没有设备连接的时候会显示一个错误。然后,写一个测试程序保证web查看器可以适当的处理服务器错误。最后,为简单地成功情况写一个测试程序,保证返回的web内容能被适当的展示出来。开发者运行所有的测试程序,并且它们会像预期的那样工作。Nice!

几个月后,这些测试程序开始失败,尽管web查看器的代码自从第一次写过后在没有进行任何更改!发生了什么?

结果是有人改变了测试程序的执行顺序。成功情况的测试首先执行,其次是另外的两个。现在失败的情况以外的成功了,因为整个测试是通过单例URL缓存对结果进行缓存的。

持久状态是单元测试的死敌,因为单元测试是由每个测试的相对立而产生的。如果状态从一个测试保留到下一个测试,然后,测试的执行循序突然就变的重要了。Buggy测试,特别是当测试应该失败的时候而它反而成功了,这不是一个好现象。

对象生命周期

单例的另外一个主要的问题是他们的生命周期。当向你的代码中添加添加单例时,很容易想到“只存在这样的一个。”但是,我在自己项目之外看到的大部分iOS代码中,这个假设都有可能失效。

例如,假设我们要创建一个能看见用户好友列表的应用。他们的每一个好友都有一个头像,并且我们想让应用把这个照片下载下来并把它缓存到设备上。使用dispatch_once代码片段很方便,但我们可能会发现自己正在编写一个SPThumbnailCache单例:

@interface SPThumbnailCache: NSObject
+ (instancetype)sharedThumbnailCache;
- (void)cacheProfileImage:(NSData *)imageData forUserId:(NSString *)userId;
- (NSData *)cachedProfileImageForUserId:(NSString *)userId;
@end

我们继续开发这个应用,并且看起来一切正常,直到某一天,当我们决定是时候实现“log out”函数了,这样就可以在应用中切换用户了。突然,我们出现了一个难以处理的问题:特定用户的状态保存到了全局的单例中了。当用户退出登录,我希望能够把磁盘上的持久状态清除掉。否则,我们会在用户设备上遗留下孤立数据,从而浪费宝贵的磁盘空间。万一,用户退出后转用另一个账户登录,我们同样希望能够为新用户创建一个新的SPThumbnailCache单例。

这里的问题是,根据定义,单例被假定为“创建一次,永远存活”的实例。对于上述的问题你可能会想到好几个解决方案。也许当用户退出登陆的时候我们可以把单例实例销毁掉:

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

+ (void)tearDown {
    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数据能够正确的清除掉。或者,我们需要保证当缩略图缓存被清除的时候能把后台任务取消。否者,新的缩略图缓存将被懒创建并且旧用户状态(也就是newImage)将被存储到它里面。

因为,单例实例没有明显的所有者(例如:单例自己管理声明周期),所以,‘关闭’单例就变得非常困难。

就因为这点,我希望你说,“缩略图缓存就不应该使用单例的!”问题是在项目刚开始并不能完全理解对象的生命周期。对于一个具体的例子,DropboxiOS应用仅仅支持单用户的登陆。直到有一天,当我们允许多用户(个人用户和企业账户)同时登陆时,应用在单用户登陆这种情况下已经存在好几年了。突然,假定“同一时刻只允许一个用户登录”开始闪退了。通过假设一个对象的生命周期匹配你的应用的生命周期,你将会限制你的代码的扩展性,并且当产品需要改变的时候你需要为此付出代价。

这里的教训是,单例应该保存为全局的状态,而不是在某一个范围内。如果把状态限制在任何一个比“应用完整生命周期”短的会话范围内,这个状态则不应该被单例管理。管理特定用户状态的单例是“代码异味”,你应该审慎的重新评估你的对象图的设计。

避免(使用)单例

所以,如果单例对于范围化的状态如此的不利,那如何避免使用它们呢?

重新看一下上面例子。由于我们有一个缓存特定个体用户状态的缩略图缓存,让我们定义一个用户对象:

@interface SPUser:NSObject
@property (nonatomic, readonly) SPThumbnailCache *thumbnailCache;
@end

@implementation SPUser
- (instancetype)init {
    if((self = [super init])) {
        _thumbnailCache = [[SPThumbnailCache alloc] init];
    }
    return self;
}
@end

现在我们有一个对象可以模拟授权的用户会话了,我们可以把所有的特定用户状态存储在这个对象内。现在,假设我们有一个渲染了好友列表的视图控制器。

@interface SPFriendListViewController: UIViewController
- (instancetype)initWithUser:(SPUser *)user;
@end

我们可以明确地把授权的用户对象传递到视图控制器中。这种传递依赖到独立的对象中的技术的一个更为正式的名字叫依赖注入(dependency injection),并且他有一大堆的好处:

  1. 它能够让阅读此接口的人清楚的明白:当用户登陆的时候SPFriendListViewController才会显示出来。
  2. 只要SPFriendListViewController在使用它就可以保持用户对象的强引用。例如,更新先前的例子,我们可以使用下面的后台任务把图片保存到缩略图缓存。
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
    [_user.thumbnailCache cacheProfileImage:newImage forUserId:userId];
});

即使这个后台任务仍然没有完成,应用中其他地方的代码也可以创建并使用全新的SPUser对象,而不需要阻塞进一步的交互因为第一个实力已经被销毁了。

为了进一步证明第二点,让我们想象一下使用依赖注入前后的对象图。

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

image

视图控制器和自定义图片视图列表与sharedThumbnailCache交互。当用户退出,我们希望清空更试图控制器并把用户带入登录界面。
image

问题是,好友列表试图控制器可能仍然在执行代码(由于后台操作),因此,仍会有未结束的调用挂起sharedThumbnailCache方法。

把这解决方案同使用依赖注入的解决方案对比:

image

假设,为简单起见,SPApplicationDelegate管理SPUser实例(事实上,你可能想会想着把用户状态的管理拆分到里一个对象里面以保持你的应用代理更轻)。当列表视图控制器被安装到了窗口上后,用户对象的引用也被传了进去。这个应用也会顺着对象图到个人图片视图。现在,当用户退出时,我们的对象图想起来是这样的:
image

这个对象图看起来和我们使用单例的情况没有什么区别。所以有什么严重的问题?

问题是作用域。在单例情况下,sharedThumbnailCache在程序中的任何模块都是可用的。假设,用户快速的登录一个新的账户。新用户想看他的好友,这意味着又一次和缩略图缓存交互:

image

当用户使用新账户登陆时,我们应该可以重新构建并与全新的SPThumbnailCache进行交互,而不必关心旧缩略图缓存的销毁。根据对象管理的标准规则,旧的视图控制器和缩略图缓存应该在后台自动清理。简言之,我们应该把用户A的状态和用户B的状态隔离开来:
image

结论

这篇文章没有什么新颖的东西。人们对单例的抱怨已经存在多年,而且也知道全局的状态非常不好。但是在iOS开发的领域,单例已司空见惯,以至于有时会忘记多年来从其他地方的面向对象编程习得的教训。

所有这一切的关键是,在面向对象编程中,我们希望最小化可变状态的作用域。单例站在了这种情况的对立面,因为它能让可变状态从程序中的任何地方获取到。下一次在你想要使用单例的时候,我希望你考虑一下依赖注入作为替代。

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

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 172,146评论 25 707
  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,657评论 18 139
  • 1.ios高性能编程 (1).内层 最小的内层平均值和峰值(2).耗电量 高效的算法和数据结构(3).初始化时...
    欧辰_OSR阅读 29,392评论 8 265
  • 原文地址:浅谈程序员的英语学习 作为在中国工作的程序员,不懂得英语似乎也不妨碍找到好工作,升职加薪。但程序员这个工...
    Albert陈凯阅读 514评论 0 2
  • 那些不请自到,不告而别的人们,那些深埋心底的感情,那些无法言语的悲伤,那些甚是离奇的邂逅,宛如夏日里清凉的...
    孤郁阅读 179评论 0 0