單例是整個(gè) Cocoa 中被廣泛使用的核心設(shè)計(jì)模式之一。事實(shí)上,蘋果開發(fā)者庫(kù)把單例作為 "Cocoa 核心競(jìng)爭(zhēng)力" 之一。作為一個(gè)iOS開發(fā)者,我們經(jīng)常和單例打交道,比如 UIApplication 和 NSFileManager 等等。我們?cè)陂_源項(xiàng)目、蘋果示例代碼和 StackOverflow 中見過(guò)了無(wú)數(shù)使用單例的例子。Xcode 甚至有一個(gè)默認(rèn)的 "Dispatch Once" 代碼片段,可以使我們非常簡(jiǎn)單地在代碼中添加一個(gè)單例:
+ (instancetype)sharedInstance
{
static dispatch_once_t once;
static id sharedInstance;
dispatch_once(&once, ^{
sharedInstance = [[self alloc] init];
});
return sharedInstance;
}
由于這些原因,單例在 iOS 開發(fā)中隨處可見。問(wèn)題是,它們很容易被濫用。
盡管有些人認(rèn)為單例是 '反模式', '魔鬼' 以及 ,我不會(huì)去完全否認(rèn)單例所帶來(lái)的的好處,而是會(huì)展示一些使用單例所帶來(lái)的問(wèn)題,這樣下一次在使用 dispatch_once 代碼片段的自動(dòng)補(bǔ)全功能時(shí),你可以對(duì)它的影響進(jìn)行評(píng)估,三思而行。
大多數(shù)的開發(fā)者都認(rèn)同使用全局可變的狀態(tài)是不好的行為。太多狀態(tài)使得程序難以理解,難以調(diào)試。我們這些面向?qū)ο蟮某绦騿T在最小化代碼的狀態(tài)復(fù)雜程度的方面,有很多需要向函數(shù)式編程學(xué)習(xí)的地方。
@implementation SPMath {
NSUInteger _a;
NSUInteger _b;
}
- (NSUInteger)computeSum
{
return _a + _b;
}
在上面這個(gè)簡(jiǎn)單的數(shù)學(xué)庫(kù)的實(shí)現(xiàn)中,程序員需要在調(diào)用 computeSum 前正確的設(shè)置實(shí)例變量 _a 和 _b。這樣有以下問(wèn)題:
computeSum 沒(méi)有顯式地通過(guò)使用參數(shù)的形式聲明它依賴于 _a 和 _b 的狀態(tài)。與僅僅通過(guò)查看函數(shù)聲明就可以知道這個(gè)函數(shù)的輸出依賴于哪些變量不同的是,另一個(gè)開發(fā)者必須查看這個(gè)函數(shù)的具體實(shí)現(xiàn)才能明白這個(gè)函數(shù)依賴那些變量。隱藏依賴是不好的。
computeSum 做準(zhǔn)備而修改 _a 和 _b 的數(shù)值時(shí),程序員需要保證這些修改不會(huì)影響任何其他依賴于這兩個(gè)變量的代碼的正確性。而這在多線程的環(huán)境中是尤其困難的。把下面的代碼和上面的例子做對(duì)比:
+ (NSUInteger)computeSumOf:(NSUInteger)a plus:(NSUInteger)b
{
return a + b;
}
這里,對(duì)變量 a 和 b 的依賴被顯式地聲明了。我們不需要為了調(diào)用這個(gè)方法而去改變實(shí)例變量的狀態(tài)。并且我們也不需要擔(dān)心調(diào)用這個(gè)函數(shù)會(huì)留下持久的副作用。我們甚至可以把這個(gè)方法聲明為類方法,這樣就告訴了代碼的閱讀者這個(gè)方法不會(huì)修改任何實(shí)例的狀態(tài)。
那么,這個(gè)例子和單例又有什么關(guān)系呢?用 Mi?ko Hevery 的話來(lái)說(shuō),"單例就是披著羊皮的全局狀態(tài)"。一個(gè)單例可以被使用在任何地方,而不需要顯式地聲明依賴。就像變量 _a 和 _b 在 computeSum 內(nèi)部被使用了,卻沒(méi)有被顯式聲明一樣,程序的任意模塊都可以調(diào)用 [SPMySingleton sharedInstance] 并且訪問(wèn)這個(gè)單例。這意味著任何和這個(gè)單例交互產(chǎn)生的副作用都會(huì)影響程序其他地方的任意代碼。
@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 是兩個(gè)完全獨(dú)立的模塊。但是 SPConsumerB 可以通過(guò)使用單例提供的共享狀態(tài)來(lái)影響 SPConsumerA 的行為。這種情況應(yīng)該只能發(fā)生在 consumer B 顯式引用了 A,并表明了兩者之間的關(guān)系時(shí)。這里使用了單例,由于其具有全局和多狀態(tài)的特性,導(dǎo)致隱式地在兩個(gè)看起來(lái)完全不相關(guān)的模塊之間建立了耦合。
讓我們來(lái)看一個(gè)更具體的例子,并且暴露一個(gè)使用全局可變狀態(tài)的額外問(wèn)題。比如我們想要在我們的應(yīng)用中構(gòu)建一個(gè)網(wǎng)頁(yè)查看器。為了支持這個(gè)查看器,我們構(gòu)建了一個(gè)簡(jiǎn)單的 URL cache:
@interface SPURLCache
+ (SPCache *)sharedURLCache;
- (void)storeCachedResponse:(NSCachedURLResponse *)cachedResponse forRequest:(NSURLRequest *)request;
@end
這個(gè)開發(fā)者開始寫一些單元測(cè)試來(lái)保證代碼在一些不同的情況下都能達(dá)到預(yù)期。首先,他寫了一個(gè)測(cè)試用例來(lái)保證網(wǎng)頁(yè)查看器在設(shè)備沒(méi)有連接時(shí)能夠展示出錯(cuò)誤信息。然后他寫了一個(gè)測(cè)試用例來(lái)保證網(wǎng)頁(yè)查看器能夠正確的處理服務(wù)器錯(cuò)誤。最后,他為成功情況時(shí)寫了一個(gè)測(cè)試用例,來(lái)保證返回的網(wǎng)絡(luò)內(nèi)容能夠被正確的顯示出來(lái)。這個(gè)開發(fā)者運(yùn)行了所有的測(cè)試用例,并且它們都如預(yù)期一樣正確。贊!
幾個(gè)月以后,這些測(cè)試用例開始出現(xiàn)失敗,盡管網(wǎng)頁(yè)查看器的代碼從它寫完后就從來(lái)沒(méi)有再改動(dòng)過(guò)!到底發(fā)生了什么?
原來(lái),有人改變了測(cè)試的順序。處理成功的那個(gè)測(cè)試用例首先被運(yùn)行,然后再運(yùn)行其他兩個(gè)。處理錯(cuò)誤的那兩個(gè)測(cè)試用例現(xiàn)在竟然成功了,和預(yù)期不一樣,因?yàn)?URL cache 這個(gè)單例把不同測(cè)試用例之間的 response 緩存起來(lái)了。
持久化狀態(tài)是單元測(cè)試的敵人,因?yàn)閱卧獪y(cè)試在各個(gè)測(cè)試用例相互獨(dú)立的情況下才有效。如果狀態(tài)從一個(gè)測(cè)試用例傳遞到了另外一個(gè),這樣就和測(cè)試用例的執(zhí)行順序就有關(guān)系了。有 bug 的測(cè)試用例,尤其是那些本來(lái)不應(yīng)該通過(guò)的測(cè)試用例,是非常糟糕的事情。
另外一個(gè)關(guān)鍵問(wèn)題就是單例的生命周期。當(dāng)你在程序中添加一個(gè)單例時(shí),很容易會(huì)認(rèn)為 “永遠(yuǎn)只會(huì)有一個(gè)實(shí)例”。但是在很多我看到過(guò)的 iOS 代碼中,這種假定都可能被打破。
比如,假設(shè)我們正在構(gòu)建一個(gè)應(yīng)用,在這個(gè)應(yīng)用里用戶可以看到他們的好友列表。他們的每個(gè)朋友都有一張個(gè)人信息的圖片,并且我們想使我們的應(yīng)用能夠下載并且在設(shè)備上緩存這些圖片。 使用 dispatch_once 代碼片段,我們可以寫一個(gè) SPThumbnailCache 單例:
@interface SPThumbnailCache : NSObject
+ (instancetype)sharedThumbnailCache;
- (void)cacheProfileImage:(NSData *)imageData forUserId:(NSString *)userId;
- (NSData *)cachedProfileImageForUserId:(NSString *)userId;
@end
我們繼續(xù)構(gòu)建我們的應(yīng)用,一切看起來(lái)都很正常,直到有一天,我們決定去實(shí)現(xiàn)‘注銷’功能,這樣用戶可以在應(yīng)用中進(jìn)行賬號(hào)切換。突然我們發(fā)現(xiàn)我們將要面臨一個(gè)討厭的問(wèn)題:用戶相關(guān)的狀態(tài)存儲(chǔ)在全局單例中。當(dāng)用戶注銷后,我們希望能夠清理掉所有的硬盤上的持久化狀態(tài)。否則,我們將會(huì)把這些被遺棄的數(shù)據(jù)殘留在用戶的設(shè)備上,浪費(fèi)寶貴的硬盤空間。對(duì)于用戶登出又登錄了一個(gè)新的賬號(hào)這種情況,我們也想能夠?qū)@個(gè)新用戶使用一個(gè)全新的 SPThumbnailCache 實(shí)例。
問(wèn)題在于按照定義單例被認(rèn)為是“創(chuàng)建一次,永久有效”的實(shí)例。你可以想到一些對(duì)于上述問(wèn)題的解決方案。或許我們可以在用戶登出時(shí)移除這個(gè)單例:
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;
}
這是一個(gè)明顯的對(duì)單例模式的濫用,但是它可以工作,對(duì)吧?
我們當(dāng)然可以使用這種方式去解決,但是代價(jià)實(shí)在是太大了。我們不能使用簡(jiǎn)單的的 dispatch_once 方案了,而這個(gè)方案能夠保證線程安全以及所有調(diào)用 [SPThumbnailCache sharedThumbnailCache] 的地方都能訪問(wèn)到同一個(gè)實(shí)例?,F(xiàn)在我們需要對(duì)使用縮略圖 cache 的代碼的執(zhí)行順序非常小心。假設(shè)當(dāng)用戶正在執(zhí)行登出操作時(shí),有一些后臺(tái)任務(wù)正在執(zhí)行把圖片保存到緩存中的操作:
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
[[SPThumbnailCache sharedThumbnailCache] cacheProfileImage:newImage forUserId:userId];
});
我們需要保證在所有的后臺(tái)任務(wù)完成前, tearDown 一定不能被執(zhí)行。這確保了 newImage 數(shù)據(jù)可以被正確的清理掉?;蛘?,我們需要保證在縮略圖 cache 被移除時(shí),后臺(tái)緩存任務(wù)一定要被取消掉。否則,一個(gè)新的縮略圖 cache 的實(shí)例將會(huì)被延遲創(chuàng)建,并且之前用戶的數(shù)據(jù) (newImage 對(duì)象) 會(huì)被存儲(chǔ)在它里面。
由于對(duì)于單例實(shí)例來(lái)說(shuō)它沒(méi)有明確的所有者,(因?yàn)閱卫约汗芾碜约旱纳芷?,“關(guān)閉”一個(gè)單例變得非常的困難。
分析到這里,我希望你能夠意識(shí)到,“這個(gè)縮略圖 cache 從來(lái)就不應(yīng)該作為一個(gè)單例!”。問(wèn)題在于一個(gè)對(duì)象得生命周期可能在項(xiàng)目的最初階段沒(méi)有被很好得考慮清楚。舉一個(gè)具體的例子,Dropbox 的 iOS 客戶端曾經(jīng)只支持一個(gè)賬號(hào)登錄。它以這樣的狀態(tài)存在了數(shù)年,直到有一天我們希望能夠同時(shí)支持多個(gè)用戶賬號(hào)登錄 (同時(shí)登陸私人賬號(hào)和工作賬號(hào))。突然之間,我們以前的的假設(shè)“只能夠同時(shí)有一個(gè)用戶處于登錄狀態(tài)”就不成立了。如果假定了一個(gè)對(duì)象的生命周期和應(yīng)用的生命周期一致,那你的代碼的靈活擴(kuò)展就受到了限制,早晚有一天當(dāng)產(chǎn)品的需求產(chǎn)生變化時(shí),你會(huì)為當(dāng)初的這個(gè)假定付出代價(jià)的。
這里我們得到的教訓(xùn)是,單例應(yīng)該只用來(lái)保存全局的狀態(tài),并且不能和任何作用域綁定。如果這些狀態(tài)的作用域比一個(gè)完整的應(yīng)用程序的生命周期要短,那么這個(gè)狀態(tài)就不應(yīng)該使用單例來(lái)管理。用一個(gè)單例來(lái)管理用戶綁定的狀態(tài),是代碼的壞味道,你應(yīng)該認(rèn)真的重新評(píng)估你的對(duì)象圖的設(shè)計(jì)。
既然單例對(duì)局部作用域的狀態(tài)有這么多的壞處,那么我們應(yīng)該怎樣避免使用它們呢?
讓我們來(lái)重溫一下上面的例子。既然我們的縮略圖 cache 的緩存狀態(tài)是和具體的用戶綁定的,那么讓我們來(lái)定義一個(gè)user對(duì)象吧:
@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
我們現(xiàn)在用一個(gè)對(duì)象來(lái)作為一個(gè)經(jīng)過(guò)認(rèn)證的用戶會(huì)話的模型類,并且我們可以把所有和用戶相關(guān)的狀態(tài)存儲(chǔ)在這個(gè)對(duì)象中?,F(xiàn)在假設(shè)我們有一個(gè)view controller來(lái)展現(xiàn)好友列表:
@interface SPFriendListViewController : UIViewController
- (instancetype)initWithUser:(SPUser *)user;
@end
我們可以顯式地把經(jīng)過(guò)認(rèn)證的 user 對(duì)象作為參數(shù)傳遞給這個(gè) view controller。這種把依賴性傳遞給依賴對(duì)象的技術(shù)正式的叫法是依賴注入,它有很多優(yōu)點(diǎn):
SPFriendListViewController 頭文件的讀者來(lái)說(shuō),可以很清楚的知道它只有在有登錄用戶的情況下才會(huì)被展示。這個(gè) SPFriendListViewController 只要還在使用中,就可以強(qiáng)引用 user 對(duì)象。舉例來(lái)說(shuō),對(duì)于前面的例子,我們可以像下面這樣在后臺(tái)任務(wù)中保存一個(gè)圖片到縮略圖 cache 中:
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
[_user.thumbnailCache cacheProfileImage:newImage forUserId:userId];
});
就算后臺(tái)任務(wù)還沒(méi)有完成,應(yīng)用其他地方的代碼也可以創(chuàng)建和使用一個(gè)全新的 SPUser 對(duì)象,而不會(huì)在清理第一個(gè)實(shí)例時(shí)阻塞用戶交互。
為了更詳細(xì)的說(shuō)明一下第二點(diǎn),讓我們畫一下在使用依賴注入之前和之后的對(duì)象圖。
假設(shè)我們的 SPFriendListViewController 是當(dāng)前 window 的 root view controller。使用單例時(shí),我們的對(duì)象圖看起來(lái)如下所示:
http://wiki.jikexueyuan.com/project/objc/images/13-4.png" alt="" />
view controller 自己,以及自定義的 image view 的列表,都會(huì)和 sharedThumbnailCache 產(chǎn)生交互。當(dāng)用戶登出后,我們想要清理 root view controller 并且退出到登錄頁(yè)面:
http://wiki.jikexueyuan.com/project/objc/images/13-5.png" alt="" />
這里的問(wèn)題在于這個(gè)好友列表的 view controller 可能仍然在執(zhí)行代碼 (由于后臺(tái)操作的原因),并且可能因此仍然有一些沒(méi)有執(zhí)行的涉及到 sharedThumbnailCache 的調(diào)用。
和使用依賴注入的解決方案對(duì)比一下:
http://wiki.jikexueyuan.com/project/objc/images/13-6.png" alt="" />
簡(jiǎn)單起見,假設(shè) SPApplicationDelegate 管理 SPUser 的實(shí)例 (在實(shí)踐中,你可能會(huì)把這些用戶狀態(tài)的管理工作交給另外一個(gè)對(duì)象來(lái)做,這樣可以使你的 application delegate 簡(jiǎn)化)。當(dāng)展現(xiàn)好友列表 view controller 時(shí),會(huì)傳遞進(jìn)去一個(gè) user 的引用。這個(gè)引用也會(huì)向下傳遞給 profile image views。現(xiàn)在,當(dāng)用戶登出時(shí),我們的對(duì)象圖如下所示:
http://wiki.jikexueyuan.com/project/objc/images/13-7.png" alt="" />
這個(gè)對(duì)象圖看起來(lái)和使用單例時(shí)很像。那么,區(qū)別是什么呢?
關(guān)鍵問(wèn)題是作用域。在單例那種情況中,sharedThumbnailCache 仍然可以被程序的任意模塊訪問(wèn)。假如用戶快速的登錄了一個(gè)新的賬號(hào)。該用戶也想看看他的好友列表,這也就意味著需要再一次的和縮略圖 cache 產(chǎn)生交互:
http://wiki.jikexueyuan.com/project/objc/images/13-8.png" alt="" />
當(dāng)用戶登錄一個(gè)新賬號(hào),我們應(yīng)該能夠構(gòu)建并且與全新的 SPThumbnailCache 交互,而不需要再在銷毀老的縮略圖 cache 上花費(fèi)精力?;趯?duì)象管理的典型規(guī)則,老的 view controllers 和老的縮略圖 cache 應(yīng)該能夠自己在后臺(tái)延遲被清理掉。簡(jiǎn)而言之,我們應(yīng)該隔離用戶 A 相關(guān)聯(lián)的狀態(tài)和用戶 B 相關(guān)聯(lián)的狀態(tài):
http://wiki.jikexueyuan.com/project/objc/images/13-9.png" alt="" />
希望這篇文章中的內(nèi)容讀起來(lái)不像奇幻小說(shuō)那樣難以理解。人們已經(jīng)對(duì)單例的濫用抱怨了很多年了,并且我們也都知道全局狀態(tài)是很不好的事情。但是在 iOS 開發(fā)的世界中,單例的使用是如此的普遍以至于我們有時(shí)候忘記了我們多年來(lái)在其他面向?qū)ο缶幊讨袑W(xué)到的教訓(xùn)。
這一切的關(guān)鍵點(diǎn)是,在面向?qū)ο缶幊讨形覀兿胍钚』勺儬顟B(tài)的作用域。但是單例卻因?yàn)槭箍勺兊臓顟B(tài)可以被程序中的任何地方訪問(wèn),而站在了對(duì)立面。下一次你想使用單例時(shí),我希望你能夠好好考慮一下使用依賴注入作為替代方案。