爲避免撕逼,提早聲明:本文純屬翻譯,僅僅是爲了學習,加上水平有限,見諒!程序員
【原文】https://www.objc.io/issues/13-architecture/singletons/web
單例是整個Cocoa
使用的核心設計模式之一。事實上,蘋果的開發庫把單例當作「Cocoa
核心競爭力」之一。做爲iOS開發者,從UIApplication
到NSFileManager
,咱們對與單例的交互已經很熟悉了。在開源項目、蘋果代碼示例和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
設置合適的值。這存在幾個問題:bash
computeSum
方法沒有經過把_a
和_b
的值做爲參數而顯式的指出方法依賴於上述的兩個值。其餘閱讀代碼的人必須經過檢查實現去理解依賴關係,而不是經過檢查接口並理解哪些變量控制函數輸出。隱藏依賴關係這樣是很差的。computeSum
而修改_a
和_b
的時候,程序員須要肯定這些修改不會影響其它依賴這些變量的代碼的正確性。這在多線程環境尤其困難。把這下面這個例子與上述的例子比較一下:服務器
+ (NSUInteger)computeSumOf:(NSUInteger)a plus:(NSUInteger)b {
return a + b;
}
複製代碼
這裏方法對a
和b
的依賴就很明顯。爲了調用這個方法咱們不須要改變實例的狀態。咱們也沒必要擔憂因爲調用此方法而致使的持久的反作用,咱們甚至能夠把這個方法當作類方法,以代表咱們調用此方法不須要修改實例狀態。多線程
可是,這個例子和單例有什麼關係呢?用Miško Hevery的話說,「單例是披着羊皮的全局狀態。」單例可使用在任何地方,而不用明確的聲明依賴關係。就像computeSum
方法中的_a
和_b
沒有明確的依賴關係同樣,程序的任何模塊均可以調用[SPMySingleton sharedInstance]
並使用單例。這意味着與單例交互的任何反作用都會影響到程序的任何地方的任何代碼。async
@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
能夠經過單例提過的共享狀態影響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
)將被存儲到它裏面。
由於,單例實例沒有明顯的全部者(例如:單例本身管理聲明週期),因此,‘關閉’單例就變得很是困難。
就由於這點,我但願你說,「縮略圖緩存就不該該使用單例的!」問題是在項目剛開始並不能徹底理解對象的生命週期。對於一個具體的例子,Dropbox
iOS應用僅僅支持單用戶的登錄。直到有一天,當咱們容許多用戶(我的用戶和企業帳戶)同時登錄時,應用在單用戶登錄這種狀況下已經存在好幾年了。忽然,假定「同一時刻只容許一個用戶登陸」開始閃退了。經過假設一個對象的生命週期匹配你的應用的生命週期,你將會限制你的代碼的擴展性,而且當產品須要改變的時候你須要爲此付出代價。
這裏的教訓是,單例應該保存爲全局的狀態,而不是在某一個範圍內。若是把狀態限制在任何一個比「應用完整生命週期」短的會話範圍內,這個狀態則不該該被單例管理。管理特定用戶狀態的單例是「代碼異味」,你應該審慎的從新評估你的對象圖的設計。
因此,若是單例對於範圍化的狀態如此的不利,那如何避免使用它們呢?
從新看一下上面例子。因爲咱們有一個緩存特定個體用戶狀態的縮略圖緩存,讓咱們定義一個用戶對象:
@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),而且他有一大堆的好處:
SPFriendListViewController
纔會顯示出來。SPFriendListViewController
在使用它就能夠保持用戶對象的強引用。例如,更新先前的例子,咱們可使用下面的後臺任務把圖片保存到縮略圖緩存。dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
[_user.thumbnailCache cacheProfileImage:newImage forUserId:userId];
});
複製代碼
即便這個後臺任務仍然沒有完成,應用中其餘地方的代碼也能夠建立並使用全新的SPUser
對象,而不須要阻塞進一步的交互由於第一個實力已經被銷燬了。
爲了進一步證實第二點,讓咱們想象一下使用依賴注入先後的對象圖。
假設,咱們的SPFriendListViewController
是當前窗口的根視圖控制器。在單例對象模型中,咱們有以下如這樣的一個對象圖:
sharedThumbnailCache
交互。當用戶退出,咱們但願清空更試圖控制器並把用戶帶入登陸界面。
問題是,好友列表試圖控制器可能仍然在執行代碼(因爲後臺操做),所以,仍會有未結束的調用掛起
sharedThumbnailCache
方法。
把這解決方案同使用依賴注入的解決方案對比:
假設,爲簡單起見,SPApplicationDelegate
管理
SPUser
實例(事實上,你可能想會想着把用戶狀態的管理拆分到裏一個對象裏面以保持你的應用代理更輕)。當列表視圖控制器被安裝到了窗口上後,用戶對象的引用也被傳了進去。這個應用也會順着對象圖到我的圖片視圖。如今,當用戶退出時,咱們的對象圖想起來是這樣的:
這個對象圖看起來和咱們使用單例的狀況沒有什麼區別。因此有什麼嚴重的問題?
問題是做用域。在單例狀況下,sharedThumbnailCache
在程序中的任何模塊都是可用的。假設,用戶快速的登陸一個新的帳戶。新用戶想看他的好友,這意味着又一次和縮略圖緩存交互:
SPThumbnailCache
進行交互,而沒必要關心舊縮略圖緩存的銷燬。根據對象管理的標準規則,舊的視圖控制器和縮略圖緩存應該在後臺自動清理。簡言之,咱們應該把用戶A的狀態和用戶B的狀態隔離開來:
這篇文章沒有什麼新穎的東西。人們對單例的抱怨已經存在多年,並且也知道全局的狀態很是很差。可是在iOS開發的領域,單例已司空見慣,以致於有時會忘記多年來從其餘地方的面向對象編程習得的教訓。
全部這一切的關鍵是,在面向對象編程中,咱們但願最小化可變狀態的做用域。單例站在了這種狀況的對立面,由於它能讓可變狀態從程序中的任何地方獲取到。下一次在你想要使用單例的時候,我但願你考慮一下依賴注入做爲替代。