導語
單例(Singletons),是Cocoa的核心模式之一。在iOS上,單例十分常見,比如:UIApplication,NSFileManager等等。雖然它們用起來十分方便,但實際上它們有許多問題需要注意。所以在你下次自動補全dispatch_once代碼片段的時候,想一下這樣會導致什么后果。因為本人在使用單例過程中碰到過許多坑,希望大家慎用!
什么是單例
在《設計模式》一書中給出了單例的定義:
單例模式:保證一個類僅有一個實例,并提供一個訪問它的全局訪問點。
單例模式提供了一個訪問點,供客戶類為共享資源生成唯一實例,并通過它來對共享資源進行訪問,這一模式提供了靈活性。
在objective-c中,可以使用以下代碼創(chuàng)建一個單例:
+(instancetype)sharedInstance
{
static dispatch_once_t once;
static id sharedInstance;
dispatch_once(&once, ^{
sharedInstance = [[self alloc]init];
});
return sharedInstance;
}
當類只能有一個實例,而且必須從一個訪問點對其進行訪問時使用單例就顯得十分方便,因為使用單例保證了訪問點的唯一、一致且為人熟知。
單例中的問題
全局狀態(tài)
首先我們都應該達成一個共識“全局可變狀態(tài)”是危險的,因為這樣會讓程序變得難以理解和調試,就削減狀態(tài)性代碼上,面向對象編程應該向函數(shù)式編程學習。
比如下面的代碼:
@implementation Math{
NSUInteger _a;
NSUInteger _b;
}
-(NSUInteger)computeSum {
return _a + _b;
}
這段代碼想要計算_a和_B相加的和,并返回。但事實上這段代碼存在著不少問題:
1.computeSum方法中并沒有把_a和_b作為參數(shù)。相比查找interface并了解哪個變量控制方法的輸出,查找implementation來了解顯得更隱蔽,而隱蔽代表著容易發(fā)生錯誤。
2.當準備修改_a和_b的值來讓它們調用computeSum方法的時候,程序員必清楚修改它們的值不會影響其他包含著兩個值的代碼的正確性,而在多線程的情況下作出這樣的判斷顯得尤其困難。
對比下面這段代碼:
+(NSUInteger)computeSumOf:(NSUInteger)a plus:(NSUInteger)b {
return a + b;
}
這段代碼中,a和b的從屬顯得十分清晰,不再需要去改變實例的狀態(tài)來調用這個方法,而且不用擔心調用這個方法的副作用。
那這個例子和單例又有什么關系呢?事實上,單例就是披著羊皮的全局狀態(tài)。一個單例可以在任何地方被使用,而且不用清晰地聲明從屬。程序中的任何模塊都可以簡單的調用[MySingleton sharedInstance],然后拿到這個單例的訪問點,這意味著任何和單例交互時產(chǎn)生的副作用都會有可能影響程序中隨機的一段代碼,如:
@interface MySingleton : NSObject
+(instancetype)sharedInstance;
-(NSUInteger)badMutableState;
-(void)setBadMutableState:(NSUInteger)badMutableState;
@end
@implementation ConsumerA
-(void)someMethod {
if([[MySingleton sharedInstance] badMutableState]){
//do something...
}
}
@end
@implementation ConsumerB
-(void)someOtherMethod {
[[MySingleton sharedInstance] setBadMutableState:0];
}
在上面的代碼中,ConsumerA和ComsumerB是程序中兩個完全獨立的模塊,但是ComsumerB中的方法會影響到ComsumerA中的行為,因為這個狀態(tài)的改變通過單例傳遞了過去。
在這段代碼,正是因為單例的全局性和狀態(tài)性,導致了ComsumerA和ComsumerB這兩個看起來似乎毫無關系的模塊之間隱含的耦合。
對象生命周期
另外一個關鍵問題就是單例的生命周期。
當你在程序中添加一個單例時,很容易會認為 “永遠只會有一個實例”。但是在很多我看到過的 iOS 代碼中,這種假定都可能被打破。
比如,假設我們正在構建一個應用,在這個應用里用戶可以看到他們的好友列表。他們的每個朋友都有一張個人信息的圖片,并且我們想使我們的應用能夠下載并且在設備上緩存這些圖片。 使用 dispatch_once 代碼片段,我們可以寫一個 SPThumbnailCache 單例:
@interface SPThumbnailCache : NSObject
+ (instancetype)sharedThumbnailCache;
- (void)cacheProfileImage:(NSData *)imageData forUserId:(NSString *)userId;
- (NSData *)cachedProfileImageForUserId:(NSString *)userId;
@end
我們繼續(xù)構建我們的應用,一切看起來都很正常,直到有一天,我們決定去實現(xiàn)‘注銷’功能,這樣用戶可以在應用中進行賬號切換。突然我們發(fā)現(xiàn)我們將要面臨一個討厭的問題:用戶相關的狀態(tài)存儲在全局單例中。當用戶注銷后,我們希望能夠清理掉所有的硬盤上的持久化狀態(tài)。否則,我們將會把這些被遺棄的數(shù)據(jù)殘留在用戶的設備上,浪費寶貴的硬盤空間。對于用戶登出又登錄了一個新的賬號這種情況,我們也想能夠對這個新用戶使用一個全新的 SPThumbnailCache 實例。
問題在于按照定義單例被認為是“創(chuàng)建一次,永久有效”的實例。你可以想到一些對于上述問題的解決方案。或許我們可以在用戶登出時移除這個單例:
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] 的地方都能訪問到同一個實例?,F(xiàn)在我們需要對使用縮略圖 cache 的代碼的執(zhí)行順序非常小心。假設當用戶正在執(zhí)行登出操作時,有一些后臺任務正在執(zhí)行把圖片保存到緩存中的操作:
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
[[SPThumbnailCache sharedThumbnailCache] cacheProfileImage:newImage forUserId:userId];
});
我們需要保證在所有的后臺任務完成前, tearDown 一定不能被執(zhí)行。這確保了 newImage
數(shù)據(jù)可以被正確的清理掉。或者,我們需要保證在縮略圖 cache 被移除時,后臺緩存任務一定要被取消掉。否則,一個新的縮略圖 cache 的實例將會被延遲創(chuàng)建,并且之前用戶的數(shù)據(jù) (newImage
對象) 會被存儲在它里面。
由于對于單例實例來說它沒有明確的所有者,(因為單例自己管理自己的生命周期),“關閉”一個單例變得非常的困難。
分析到這里,我希望你能夠意識到,“這個縮略圖 cache 從來就不應該作為一個單例!”。問題在于一個對象得生命周期可能在項目的最初階段沒有被很好得考慮清楚。舉一個具體的例子,Dropbox 的 iOS 客戶端曾經(jīng)只支持一個賬號登錄。它以這樣的狀態(tài)存在了數(shù)年,直到有一天我們希望能夠同時支持多個用戶賬號登錄 (同時登陸私人賬號和工作賬號)。突然之間,我們以前的的假設“只能夠同時有一個用戶處于登錄狀態(tài)”就不成立了。如果假定了一個對象的生命周期和應用的生命周期一致,那你的代碼的靈活擴展就受到了限制,早晚有一天當產(chǎn)品的需求產(chǎn)生變化時,你會為當初的這個假定付出代價的。
這里我們得到的教訓是,單例應該只用來保存全局的狀態(tài),并且不能和任何作用域綁定。如果這些狀態(tài)的作用域比一個完整的應用程序的生命周期要短,那么這個狀態(tài)就不應該使用單例來管理。用一個單例來管理用戶綁定的狀態(tài),是代碼的壞味道,你應該認真的重新評估你的對象圖的設計。
避免使用單例
既然單例對局部作用域的狀態(tài)有這么多的壞處,那么我們應該怎樣避免使用它們呢?
讓我們來重溫一下上面的例子。既然我們的縮略圖 cache 的緩存狀態(tài)是和具體的用戶綁定的,那么讓我們來定義一個user對象吧:
@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)在用一個對象來作為一個經(jīng)過認證的用戶會話的模型類,并且我們可以把所有和用戶相關的狀態(tài)存儲在這個對象中?,F(xiàn)在假設我們有一個view controller來展現(xiàn)好友列表:
@interface SPFriendListViewController : UIViewController
- (instancetype)initWithUser:(SPUser *)user;
@end
我們可以顯式地把經(jīng)過認證的 user 對象作為參數(shù)傳遞給這個 view controller。這種把依賴性傳遞給依賴對象的技術正式的叫法是依賴注入,它有很多優(yōu)點:
1.對于閱讀這個 SPFriendListViewController 頭文件的讀者來說,可以很清楚的知道它只有在有登錄用戶的情況下才會被展示。
2.這個 SPFriendListViewController 只要還在使用中,就可以強引用 user 對象。舉例來說,對于前面的例子,我們可以像下面這樣在后臺任務中保存一個圖片到縮略圖 cache 中:
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
[_user.thumbnailCache cacheProfileImage:newImage forUserId:userId];
});
就算后臺任務還沒有完成,應用其他地方的代碼也可以創(chuàng)建和使用一個全新的 SPUser 對象,而不會在清理第一個實例時阻塞用戶交互。
為了更詳細的說明一下第二點,讓我們畫一下在使用依賴注入之前和之后的對象圖。
假設我們的 SPFriendListViewController 是當前 window 的 root view controller。使用單例時,我們的對象圖看起來如下所示:

view controller 自己,以及自定義的 image view 的列表,都會和 sharedThumbnailCache 產(chǎn)生交互。當用戶登出后,我們想要清理 root view controller 并且退出到登錄頁面:

這里的問題在于這個好友列表的 view controller 可能仍然在執(zhí)行代碼 (由于后臺操作的原因),并且可能因此仍然有一些沒有執(zhí)行的涉及到 sharedThumbnailCache 的調用。
和使用依賴注入的解決方案對比一下:

簡單起見,假設 SPApplicationDelegate 管理 SPUser 的實例 (在實踐中,你可能會把這些用戶狀態(tài)的管理工作交給另外一個對象來做,這樣可以使你的 application delegate 簡化)。當展現(xiàn)好友列表 view controller 時,會傳遞進去一個 user 的引用。這個引用也會向下傳遞給 profile image views?,F(xiàn)在,當用戶登出時,我們的對象圖如下所示:

這個對象圖看起來和使用單例時很像。那么,區(qū)別是什么呢?
關鍵問題是作用域。在單例那種情況中,sharedThumbnailCache 仍然可以被程序的任意模塊訪問。假如用戶快速的登錄了一個新的賬號。該用戶也想看看他的好友列表,這也就意味著需要再一次的和縮略圖 cache 產(chǎn)生交互:

當用戶登錄一個新賬號,我們應該能夠構建并且與全新的 SPThumbnailCache 交互,而不需要再在銷毀老的縮略圖 cache 上花費精力。基于對象管理的典型規(guī)則,老的 view controllers 和老的縮略圖 cache 應該能夠自己在后臺延遲被清理掉。簡而言之,我們應該隔離用戶 A 相關聯(lián)的狀態(tài)和用戶 B 相關聯(lián)的狀態(tài):

結論
我們都知道全局可變狀態(tài)是不好的,但是在使用單例的時候我們又不經(jīng)意地把它變成我們討厭的全局可變狀態(tài)。
在面向對象編程中,我們需要盡可能減少可變狀態(tài)的作用域,而單例與這個思想背道而馳,希望在下一次使用單例的時候能夠多想一想,考慮是否這個變量真正值得成為一個單例,如果不是,還請使用“依賴注入模式”來代替。