為避免撕逼,提前宣告:本文純屬翻譯,僅僅是為了學習,加上水平有限,見諒!
【原文】https://www.objc.io/issues/13-architecture/singletons/
避免單例濫用——by Stephen Poletto
單例是整個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
設定合適的值。這存在幾個問題:
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]
並使用單例。這意味著與單例互動的任何副作用都會影響到程式的任何地方的任何程式碼。
@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開發的領域,單例已司空見慣,以至於有時會忘記多年來從其他地方的物件導向程式設計習得的教訓。
所有這一切的關鍵是,在物件導向程式設計中,我們希望最小化可變狀態的作用域。單例站在了這種情況的對立面,因為它能讓可變狀態從程式中的任何地方獲取到。下一次在你想要使用單例的時候,我希望你考慮一下依賴注入作為替代。