避免單例濫用——by Stephen Poletto
單例是整個(gè)Cocoa
使用的核心設(shè)計(jì)模式之一散劫。事實(shí)上肃续,蘋果的開發(fā)庫(kù)把單例當(dāng)做“Cocoa
核心競(jìng)爭(zhēng)力”之一商叹。作為iOS開發(fā)者穆趴,從UIApplication
到NSFileManager
割去,我們對(duì)與單例的交互已經(jīng)很熟悉了窟却。在開源項(xiàng)目、蘋果代碼示例和StackOverflow中呻逆,我們見到過的單例已多如牛毛夸赫。甚至,Xcode還有默認(rèn)的代碼片段咖城,如:”Dispatch Once“茬腿,這使得你往代碼中添加單例變的非常的簡(jiǎn)單:
+ (instancetype)sharedInstance {
static dispatch_once_t once;
static id sharedInstance;
dispatch_once(&once, ^{
sharedInstance = [[self alloc] init];
});
return sharedInstance;
}
因?yàn)檫@些原因呼奢,單例在iOS編程中就很常見。但問題是切平,它很容易被濫用握础。
其他人把單例稱作‘反面模式’,‘邪惡’和‘病態(tài)騙子’悴品,然而我并沒有完全抹去單例的價(jià)值禀综。相反,我想論證單例的幾個(gè)問題苔严,從而定枷,讓你在下次打算自動(dòng)完成dispatch_once
代碼片段的時(shí)候再三思考這樣做可能帶來的后果。
全局狀態(tài)
大多數(shù)開發(fā)者都認(rèn)為可變的全局狀態(tài)是不可取的届氢。有狀態(tài)性使程序難以理解和調(diào)試欠窒。在最小化有狀態(tài)代碼方面,面向?qū)ο蟪绦騿T有很多東西需要從函數(shù)編程上面學(xué)習(xí)退子。
@implementation SPMath {
NSUInteger _a;
NSUInteger _b;
}
- (NSUInteger)computeSum {
return _a + _b;
}
在上述簡(jiǎn)單數(shù)學(xué)庫(kù)的實(shí)現(xiàn)中岖妄,在調(diào)用computeSum
方法之前程序員希望為實(shí)例變量_a
和_b
設(shè)置合適的值。這存在幾個(gè)問題:
-
computeSum
方法沒有通過把_a
和_b
的值作為參數(shù)而顯式的指出方法依賴于上述的兩個(gè)值絮供。其他閱讀代碼的人必須通過檢查實(shí)現(xiàn)去理解依賴關(guān)系衣吠,而不是通過檢查接口并理解哪些變量控制函數(shù)輸出。隱藏依賴關(guān)系這樣是不好的壤靶。 - 當(dāng)為了準(zhǔn)備調(diào)用
computeSum
而修改_a
和_b
的時(shí)候缚俏,程序員需要確定這些修改不會(huì)影響其它依賴這些變量的代碼的正確性。這在多線程環(huán)境尤為困難贮乳。
把這下面這個(gè)例子與上述的例子比較一下:
+ (NSUInteger)computeSumOf:(NSUInteger)a plus:(NSUInteger)b {
return a + b;
}
這里方法對(duì)a
和b
的依賴就很明顯忧换。為了調(diào)用這個(gè)方法我們不需要改變實(shí)例的狀態(tài)。我們也不必?fù)?dān)心由于調(diào)用此方法而導(dǎo)致的持久的副作用向拆,我們甚至可以把這個(gè)方法當(dāng)做類方法亚茬,以表明我們調(diào)用此方法不需要修改實(shí)例狀態(tài)。
但是浓恳,這個(gè)例子和單例有什么關(guān)系呢刹缝?用Mi?ko Hevery的話說,“單例是披著羊皮的全局狀態(tài)颈将∩液唬”單例可以使用在任何地方,而不用明確的聲明依賴關(guān)系晴圾。就像computeSum
方法中的_a
和_b
沒有明確的依賴關(guān)系一樣颂砸,程序的任何模塊都可以調(diào)用[SPMySingleton sharedInstance]
并使用單例。這意味著與單例交互的任何副作用都會(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
可以通過單例提過的共享狀態(tài)影響SPConsumerA
的行為。在不使用單例的情況下色罚,只有在消費(fèi)者B中引入消費(fèi)者A碰缔,明確兩者之間的關(guān)系才能達(dá)到上述這樣的效果。在單例中保屯,由于它的全局有狀態(tài)的性質(zhì)手负,導(dǎo)致了看似兩個(gè)不相關(guān)的模塊之間的隱藏和隱式的耦合涤垫。
讓我們看一個(gè)更具體的例子姑尺,并提出另外一個(gè)由全局可變狀態(tài)而引起的問題。假設(shè)我們想在我們的應(yīng)用中創(chuàng)建一個(gè)web查看器蝠猬。為了支持這個(gè)web查看器切蟋,我們創(chuàng)建了一個(gè)簡(jiǎn)單地URL緩存:
@interface SPURLCache
+ (SPURLCache *)sharedURLCache;
- (void)storeCacheResponse:(NSCachedURLResponse *)cachedResponse forRequest:(NSURLRequest *)request;
@end
編寫web查看器的開發(fā)者開始寫幾個(gè)單元測(cè)試,以保證代碼在期望的幾個(gè)不同的情況下能夠正常工作榆芦。首先柄粹,寫一個(gè)測(cè)試程序保證web查看器在沒有設(shè)備連接的時(shí)候會(huì)顯示一個(gè)錯(cuò)誤。然后匆绣,寫一個(gè)測(cè)試程序保證web查看器可以適當(dāng)?shù)奶幚矸?wù)器錯(cuò)誤驻右。最后,為簡(jiǎn)單地成功情況寫一個(gè)測(cè)試程序崎淳,保證返回的web內(nèi)容能被適當(dāng)?shù)恼故境鰜砜柏病i_發(fā)者運(yùn)行所有的測(cè)試程序,并且它們會(huì)像預(yù)期的那樣工作拣凹。Nice森爽!
幾個(gè)月后,這些測(cè)試程序開始失敗嚣镜,盡管web查看器的代碼自從第一次寫過后在沒有進(jìn)行任何更改爬迟!發(fā)生了什么?
結(jié)果是有人改變了測(cè)試程序的執(zhí)行順序菊匿。成功情況的測(cè)試首先執(zhí)行付呕,其次是另外的兩個(gè)。現(xiàn)在失敗的情況以外的成功了跌捆,因?yàn)檎麄€(gè)測(cè)試是通過單例URL
緩存對(duì)結(jié)果進(jìn)行緩存的徽职。
持久狀態(tài)是單元測(cè)試的死敵,因?yàn)閱卧獪y(cè)試是由每個(gè)測(cè)試的相對(duì)立而產(chǎn)生的疹蛉。如果狀態(tài)從一個(gè)測(cè)試保留到下一個(gè)測(cè)試活箕,然后,測(cè)試的執(zhí)行循序突然就變的重要了。Buggy測(cè)試育韩,特別是當(dāng)測(cè)試應(yīng)該失敗的時(shí)候而它反而成功了克蚂,這不是一個(gè)好現(xiàn)象。
對(duì)象生命周期
單例的另外一個(gè)主要的問題是他們的生命周期筋讨。當(dāng)向你的代碼中添加添加單例時(shí)埃叭,很容易想到“只存在這樣的一個(gè)∠ず保”但是赤屋,我在自己項(xiàng)目之外看到的大部分iOS代碼中,這個(gè)假設(shè)都有可能失效壁袄。
例如类早,假設(shè)我們要?jiǎng)?chuàng)建一個(gè)能看見用戶好友列表的應(yīng)用。他們的每一個(gè)好友都有一個(gè)頭像嗜逻,并且我們想讓應(yīng)用把這個(gè)照片下載下來并把它緩存到設(shè)備上涩僻。使用dispatch_once
代碼片段很方便,但我們可能會(huì)發(fā)現(xiàn)自己正在編寫一個(gè)SPThumbnailCache
單例:
@interface SPThumbnailCache: NSObject
+ (instancetype)sharedThumbnailCache;
- (void)cacheProfileImage:(NSData *)imageData forUserId:(NSString *)userId;
- (NSData *)cachedProfileImageForUserId:(NSString *)userId;
@end
我們繼續(xù)開發(fā)這個(gè)應(yīng)用栈顷,并且看起來一切正常逆日,直到某一天,當(dāng)我們決定是時(shí)候?qū)崿F(xiàn)“l(fā)og out”函數(shù)了萄凤,這樣就可以在應(yīng)用中切換用戶了室抽。突然,我們出現(xiàn)了一個(gè)難以處理的問題:特定用戶的狀態(tài)保存到了全局的單例中了靡努。當(dāng)用戶退出登錄坪圾,我希望能夠把磁盤上的持久狀態(tài)清除掉。否則颤难,我們會(huì)在用戶設(shè)備上遺留下孤立數(shù)據(jù)神年,從而浪費(fèi)寶貴的磁盤空間。萬一行嗤,用戶退出后轉(zhuǎn)用另一個(gè)賬戶登錄已日,我們同樣希望能夠?yàn)樾掠脩魟?chuàng)建一個(gè)新的SPThumbnailCache
單例。
這里的問題是栅屏,根據(jù)定義飘千,單例被假定為“創(chuàng)建一次,永遠(yuǎn)存活”的實(shí)例栈雳。對(duì)于上述的問題你可能會(huì)想到好幾個(gè)解決方案护奈。也許當(dāng)用戶退出登陸的時(shí)候我們可以把單例實(shí)例銷毀掉:
static SPThumbnailCache *sharedThumbnailCache;
+ (instancetype)sharedThumbnailCache {
if(!sharedThumbnailCache) {
sharedThumbnailCache = [[self alloc] init];
}
return sharedThumbnailCache;
}
+ (void)tearDown {
sharedThumbnailCache = nil;
}
這是明目張膽的對(duì)單例模式的濫用,但是很管用對(duì)不對(duì)哥纫?
我們當(dāng)然可以讓這個(gè)解決方案起作用霉旗,但是代價(jià)太大了。舉例來說,我們已經(jīng)失去了dispatch_once
方案的簡(jiǎn)單性厌秒,并且這解決方案可以保證線程安全读拆,所有的代碼都調(diào)用[SPThumbnailCache sharedThumbnailCache]
這個(gè)方法只是獲取同一個(gè)實(shí)例。對(duì)于使用縮略圖緩存的代碼的執(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ù)執(zhí)行完之前不能執(zhí)行tearDown
方法蚌讼。這保證newImage
數(shù)據(jù)能夠正確的清除掉辟灰。或者篡石,我們需要保證當(dāng)縮略圖緩存被清除的時(shí)候能把后臺(tái)任務(wù)取消芥喇。否者,新的縮略圖緩存將被懶創(chuàng)建并且舊用戶狀態(tài)(也就是newImage
)將被存儲(chǔ)到它里面夏志。
因?yàn)槟死ぃ瑔卫龑?shí)例沒有明顯的所有者(例如:?jiǎn)卫约汗芾砺暶髦芷冢┛寥茫怨得铮P(guān)閉’單例就變得非常困難。
就因?yàn)檫@點(diǎn)狱杰,我希望你說瘦材,“縮略圖緩存就不應(yīng)該使用單例的!”問題是在項(xiàng)目剛開始并不能完全理解對(duì)象的生命周期仿畸。對(duì)于一個(gè)具體的例子食棕,Dropbox
iOS應(yīng)用僅僅支持單用戶的登陸。直到有一天错沽,當(dāng)我們?cè)试S多用戶(個(gè)人用戶和企業(yè)賬戶)同時(shí)登陸時(shí)簿晓,應(yīng)用在單用戶登陸這種情況下已經(jīng)存在好幾年了。突然千埃,假定“同一時(shí)刻只允許一個(gè)用戶登錄”開始閃退了憔儿。通過假設(shè)一個(gè)對(duì)象的生命周期匹配你的應(yīng)用的生命周期,你將會(huì)限制你的代碼的擴(kuò)展性放可,并且當(dāng)產(chǎn)品需要改變的時(shí)候你需要為此付出代價(jià)谒臼。
這里的教訓(xùn)是,單例應(yīng)該保存為全局的狀態(tài)耀里,而不是在某一個(gè)范圍內(nèi)蜈缤。如果把狀態(tài)限制在任何一個(gè)比“應(yīng)用完整生命周期”短的會(huì)話范圍內(nèi),這個(gè)狀態(tài)則不應(yīng)該被單例管理冯挎。管理特定用戶狀態(tài)的單例是“代碼異味”底哥,你應(yīng)該審慎的重新評(píng)估你的對(duì)象圖的設(shè)計(jì)。
避免(使用)單例
所以,如果單例對(duì)于范圍化的狀態(tài)如此的不利趾徽,那如何避免使用它們呢奶陈?
重新看一下上面例子。由于我們有一個(gè)緩存特定個(gè)體用戶狀態(tài)的縮略圖緩存附较,讓我們定義一個(gè)用戶對(duì)象:
@interface SPUser:NSObject
@property (nonatomic, readonly) SPThumbnailCache *thumbnailCache;
@end
@implementation SPUser
- (instancetype)init {
if((self = [super init])) {
_thumbnailCache = [[SPThumbnailCache alloc] init];
}
return self;
}
@end
現(xiàn)在我們有一個(gè)對(duì)象可以模擬授權(quán)的用戶會(huì)話了吃粒,我們可以把所有的特定用戶狀態(tài)存儲(chǔ)在這個(gè)對(duì)象內(nèi)。現(xiàn)在拒课,假設(shè)我們有一個(gè)渲染了好友列表的視圖控制器徐勃。
@interface SPFriendListViewController: UIViewController
- (instancetype)initWithUser:(SPUser *)user;
@end
我們可以明確地把授權(quán)的用戶對(duì)象傳遞到視圖控制器中。這種傳遞依賴到獨(dú)立的對(duì)象中的技術(shù)的一個(gè)更為正式的名字叫依賴注入(dependency injection)早像,并且他有一大堆的好處:
- 它能夠讓閱讀此接口的人清楚的明白:當(dāng)用戶登陸的時(shí)候
SPFriendListViewController
才會(huì)顯示出來僻肖。 - 只要
SPFriendListViewController
在使用它就可以保持用戶對(duì)象的強(qiáng)引用。例如卢鹦,更新先前的例子臀脏,我們可以使用下面的后臺(tái)任務(wù)把圖片保存到縮略圖緩存。
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
[_user.thumbnailCache cacheProfileImage:newImage forUserId:userId];
});
即使這個(gè)后臺(tái)任務(wù)仍然沒有完成冀自,應(yīng)用中其他地方的代碼也可以創(chuàng)建并使用全新的SPUser
對(duì)象揉稚,而不需要阻塞進(jìn)一步的交互因?yàn)榈谝粋€(gè)實(shí)力已經(jīng)被銷毀了。
為了進(jìn)一步證明第二點(diǎn)熬粗,讓我們想象一下使用依賴注入前后的對(duì)象圖搀玖。
假設(shè),我們的SPFriendListViewController
是當(dāng)前窗口的根視圖控制器驻呐。在單例對(duì)象模型中灌诅,我們有如下如這樣的一個(gè)對(duì)象圖:
視圖控制器和自定義圖片視圖列表與
sharedThumbnailCache
交互。當(dāng)用戶退出含末,我們希望清空更試圖控制器并把用戶帶入登錄界面猜拾。問題是,好友列表試圖控制器可能仍然在執(zhí)行代碼(由于后臺(tái)操作)佣盒,因此挎袜,仍會(huì)有未結(jié)束的調(diào)用掛起
sharedThumbnailCache
方法。
把這解決方案同使用依賴注入的解決方案對(duì)比:
假設(shè)沼撕,為簡(jiǎn)單起見宋雏,
SPApplicationDelegate
管理SPUser
實(shí)例(事實(shí)上,你可能想會(huì)想著把用戶狀態(tài)的管理拆分到里一個(gè)對(duì)象里面以保持你的應(yīng)用代理更輕)务豺。當(dāng)列表視圖控制器被安裝到了窗口上后磨总,用戶對(duì)象的引用也被傳了進(jìn)去。這個(gè)應(yīng)用也會(huì)順著對(duì)象圖到個(gè)人圖片視圖×ぃ現(xiàn)在蚪燕,當(dāng)用戶退出時(shí)娶牌,我們的對(duì)象圖想起來是這樣的:這個(gè)對(duì)象圖看起來和我們使用單例的情況沒有什么區(qū)別。所以有什么嚴(yán)重的問題馆纳?
問題是作用域诗良。在單例情況下,sharedThumbnailCache
在程序中的任何模塊都是可用的鲁驶。假設(shè)鉴裹,用戶快速的登錄一個(gè)新的賬戶。新用戶想看他的好友钥弯,這意味著又一次和縮略圖緩存交互:
當(dāng)用戶使用新賬戶登陸時(shí)径荔,我們應(yīng)該可以重新構(gòu)建并與全新的
SPThumbnailCache
進(jìn)行交互,而不必關(guān)心舊縮略圖緩存的銷毀脆霎。根據(jù)對(duì)象管理的標(biāo)準(zhǔn)規(guī)則总处,舊的視圖控制器和縮略圖緩存應(yīng)該在后臺(tái)自動(dòng)清理。簡(jiǎn)言之睛蛛,我們應(yīng)該把用戶A的狀態(tài)和用戶B的狀態(tài)隔離開來:結(jié)論
這篇文章沒有什么新穎的東西鹦马。人們對(duì)單例的抱怨已經(jīng)存在多年,而且也知道全局的狀態(tài)非常不好忆肾。但是在iOS開發(fā)的領(lǐng)域荸频,單例已司空見慣,以至于有時(shí)會(huì)忘記多年來從其他地方的面向?qū)ο缶幊塘?xí)得的教訓(xùn)难菌。
所有這一切的關(guān)鍵是试溯,在面向?qū)ο缶幊讨校覀兿M钚』勺儬顟B(tài)的作用域郊酒。單例站在了這種情況的對(duì)立面,因?yàn)樗茏尶勺儬顟B(tài)從程序中的任何地方獲取到键袱。下一次在你想要使用單例的時(shí)候燎窘,我希望你考慮一下依賴注入作為替代。