單例使用問題

導(dǎo)語

單例(Singletons),是Cocoa的核心模式之一。在iOS上淮悼,單例十分常見,比如:UIApplication揽思,NSFileManager等等袜腥。雖然它們用起來十分方便,但實(shí)際上它們有許多問題需要注意钉汗。所以在你下次自動補(bǔ)全dispatch_once代碼片段的時候羹令,想一下這樣會導(dǎo)致什么后果。因?yàn)楸救嗽谑褂脝卫^程中碰到過許多坑损痰,希望大家慎用福侈!

什么是單例

在《設(shè)計模式》一書中給出了單例的定義:
單例模式:保證一個類僅有一個實(shí)例,并提供一個訪問它的全局訪問點(diǎn)卢未。

單例模式提供了一個訪問點(diǎn)肪凛,供客戶類為共享資源生成唯一實(shí)例,并通過它來對共享資源進(jìn)行訪問辽社,這一模式提供了靈活性伟墙。

在objective-c中,可以使用以下代碼創(chuàng)建一個單例:

+(instancetype)sharedInstance
{
    static dispatch_once_t once;
    static id sharedInstance;
    dispatch_once(&once, ^{
        sharedInstance = [[self alloc]init];
    });
    return sharedInstance;
}

當(dāng)類只能有一個實(shí)例滴铅,而且必須從一個訪問點(diǎn)對其進(jìn)行訪問時使用單例就顯得十分方便远荠,因?yàn)槭褂脝卫WC了訪問點(diǎn)的唯一、一致且為人熟知失息。

單例中的問題

全局狀態(tài)

首先我們都應(yīng)該達(dá)成一個共識“全局可變狀態(tài)”是危險的譬淳,因?yàn)檫@樣會讓程序變得難以理解和調(diào)試档址,就削減狀態(tài)性代碼上,面向?qū)ο缶幊虘?yīng)該向函數(shù)式編程學(xué)習(xí)邻梆。

比如下面的代碼:

@implementation Math{
    NSUInteger _a;
    NSUInteger _b;
}

-(NSUInteger)computeSum {
    return _a + _b;
}

這段代碼想要計算_a和_B相加的和守伸,并返回。但事實(shí)上這段代碼存在著不少問題:
1.computeSum方法中并沒有把_a和_b作為參數(shù)浦妄。相比查找interface并了解哪個變量控制方法的輸出尼摹,查找implementation來了解顯得更隱蔽,而隱蔽代表著容易發(fā)生錯誤剂娄。
2.當(dāng)準(zhǔn)備修改_a和_b的值來讓它們調(diào)用computeSum方法的時候蠢涝,程序員必清楚修改它們的值不會影響其他包含著兩個值的代碼的正確性,而在多線程的情況下作出這樣的判斷顯得尤其困難阅懦。

對比下面這段代碼:

+(NSUInteger)computeSumOf:(NSUInteger)a plus:(NSUInteger)b {
    return a + b;
}

這段代碼中和二,a和b的從屬顯得十分清晰,不再需要去改變實(shí)例的狀態(tài)來調(diào)用這個方法耳胎,而且不用擔(dān)心調(diào)用這個方法的副作用惯吕。

那這個例子和單例又有什么關(guān)系呢?事實(shí)上怕午,單例就是披著羊皮的全局狀態(tài)废登。一個單例可以在任何地方被使用,而且不用清晰地聲明從屬郁惜。程序中的任何模塊都可以簡單的調(diào)用[MySingleton sharedInstance]堡距,然后拿到這個單例的訪問點(diǎn),這意味著任何和單例交互時產(chǎn)生的副作用都會有可能影響程序中隨機(jī)的一段代碼兆蕉,如:

@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是程序中兩個完全獨(dú)立的模塊,但是ComsumerB中的方法會影響到ComsumerA中的行為恨樟,因?yàn)檫@個狀態(tài)的改變通過單例傳遞了過去半醉。

在這段代碼疚俱,正是因?yàn)閱卫娜中院蜖顟B(tài)性劝术,導(dǎo)致了ComsumerA和ComsumerB這兩個看起來似乎毫無關(guān)系的模塊之間隱含的耦合。

對象生命周期

另外一個關(guān)鍵問題就是單例的生命周期呆奕。
當(dāng)你在程序中添加一個單例時养晋,很容易會認(rèn)為 “永遠(yuǎn)只會有一個實(shí)例”。但是在很多我看到過的 iOS 代碼中梁钾,這種假定都可能被打破绳泉。

比如,假設(shè)我們正在構(gòu)建一個應(yīng)用姆泻,在這個應(yīng)用里用戶可以看到他們的好友列表零酪。他們的每個朋友都有一張個人信息的圖片冒嫡,并且我們想使我們的應(yīng)用能夠下載并且在設(shè)備上緩存這些圖片。 使用 dispatch_once 代碼片段四苇,我們可以寫一個 SPThumbnailCache 單例:

@interface SPThumbnailCache : NSObject

+ (instancetype)sharedThumbnailCache;

- (void)cacheProfileImage:(NSData *)imageData forUserId:(NSString *)userId;
- (NSData *)cachedProfileImageForUserId:(NSString *)userId;

@end

我們繼續(xù)構(gòu)建我們的應(yīng)用孝凌,一切看起來都很正常,直到有一天月腋,我們決定去實(shí)現(xiàn)‘注銷’功能蟀架,這樣用戶可以在應(yīng)用中進(jìn)行賬號切換。突然我們發(fā)現(xiàn)我們將要面臨一個討厭的問題:用戶相關(guān)的狀態(tài)存儲在全局單例中榆骚。當(dāng)用戶注銷后片拍,我們希望能夠清理掉所有的硬盤上的持久化狀態(tài)。否則妓肢,我們將會把這些被遺棄的數(shù)據(jù)殘留在用戶的設(shè)備上捌省,浪費(fèi)寶貴的硬盤空間。對于用戶登出又登錄了一個新的賬號這種情況职恳,我們也想能夠?qū)@個新用戶使用一個全新的 SPThumbnailCache 實(shí)例所禀。

問題在于按照定義單例被認(rèn)為是“創(chuàng)建一次,永久有效”的實(shí)例放钦。你可以想到一些對于上述問題的解決方案色徘。或許我們可以在用戶登出時移除這個單例:

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;
}

這是一個明顯的對單例模式的濫用操禀,但是它可以工作褂策,對吧?

我們當(dāng)然可以使用這種方式去解決颓屑,但是代價實(shí)在是太大了斤寂。我們不能使用簡單的的 dispatch_once 方案了,而這個方案能夠保證線程安全以及所有調(diào)用 [SPThumbnailCache sharedThumbnailCache] 的地方都能訪問到同一個實(shí)例【镜耄現(xiàn)在我們需要對使用縮略圖 cache 的代碼的執(zhí)行順序非常小心遍搞。假設(shè)當(dāng)用戶正在執(zhí)行登出操作時,有一些后臺任務(wù)正在執(zhí)行把圖片保存到緩存中的操作:

dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
    [[SPThumbnailCache sharedThumbnailCache] cacheProfileImage:newImage forUserId:userId];
});

我們需要保證在所有的后臺任務(wù)完成前器腋, tearDown 一定不能被執(zhí)行溪猿。這確保了 newImage
數(shù)據(jù)可以被正確的清理掉∪宜或者诊县,我們需要保證在縮略圖 cache 被移除時,后臺緩存任務(wù)一定要被取消掉措左。否則依痊,一個新的縮略圖 cache 的實(shí)例將會被延遲創(chuàng)建,并且之前用戶的數(shù)據(jù) (newImage
對象) 會被存儲在它里面怎披。
由于對于單例實(shí)例來說它沒有明確的所有者胸嘁,(因?yàn)閱卫约汗芾碜约旱纳芷?瓶摆,“關(guān)閉”一個單例變得非常的困難。
分析到這里性宏,我希望你能夠意識到赏壹,“這個縮略圖 cache 從來就不應(yīng)該作為一個單例!”衔沼。問題在于一個對象得生命周期可能在項目的最初階段沒有被很好得考慮清楚蝌借。舉一個具體的例子,Dropbox 的 iOS 客戶端曾經(jīng)只支持一個賬號登錄指蚁。它以這樣的狀態(tài)存在了數(shù)年菩佑,直到有一天我們希望能夠同時支持多個用戶賬號登錄 (同時登陸私人賬號和工作賬號)。突然之間凝化,我們以前的的假設(shè)“只能夠同時有一個用戶處于登錄狀態(tài)”就不成立了稍坯。如果假定了一個對象的生命周期和應(yīng)用的生命周期一致,那你的代碼的靈活擴(kuò)展就受到了限制搓劫,早晚有一天當(dāng)產(chǎn)品的需求產(chǎn)生變化時瞧哟,你會為當(dāng)初的這個假定付出代價的。
這里我們得到的教訓(xùn)是枪向,單例應(yīng)該只用來保存全局的狀態(tài)勤揩,并且不能和任何作用域綁定。如果這些狀態(tài)的作用域比一個完整的應(yīng)用程序的生命周期要短秘蛔,那么這個狀態(tài)就不應(yīng)該使用單例來管理陨亡。用一個單例來管理用戶綁定的狀態(tài),是代碼的壞味道深员,你應(yīng)該認(rèn)真的重新評估你的對象圖的設(shè)計负蠕。

避免使用單例

既然單例對局部作用域的狀態(tài)有這么多的壞處,那么我們應(yīng)該怎樣避免使用它們呢倦畅?

讓我們來重溫一下上面的例子遮糖。既然我們的縮略圖 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)過認(rèn)證的用戶會話的模型類叠赐,并且我們可以把所有和用戶相關(guān)的狀態(tài)存儲在這個對象中∮耍現(xiàn)在假設(shè)我們有一個view controller來展現(xiàn)好友列表:

@interface SPFriendListViewController : UIViewController

- (instancetype)initWithUser:(SPUser *)user;

@end

我們可以顯式地把經(jīng)過認(rèn)證的 user 對象作為參數(shù)傳遞給這個 view controller。這種把依賴性傳遞給依賴對象的技術(shù)正式的叫法是依賴注入燎悍,它有很多優(yōu)點(diǎn):
1.對于閱讀這個 SPFriendListViewController 頭文件的讀者來說敬惦,可以很清楚的知道它只有在有登錄用戶的情況下才會被展示盼理。
2.這個 SPFriendListViewController 只要還在使用中谈山,就可以強(qiáng)引用 user 對象。舉例來說宏怔,對于前面的例子奏路,我們可以像下面這樣在后臺任務(wù)中保存一個圖片到縮略圖 cache 中:

dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
     [_user.thumbnailCache cacheProfileImage:newImage forUserId:userId];
 });

就算后臺任務(wù)還沒有完成畴椰,應(yīng)用其他地方的代碼也可以創(chuàng)建和使用一個全新的 SPUser 對象,而不會在清理第一個實(shí)例時阻塞用戶交互鸽粉。
為了更詳細(xì)的說明一下第二點(diǎn)斜脂,讓我們畫一下在使用依賴注入之前和之后的對象圖。

假設(shè)我們的 SPFriendListViewController 是當(dāng)前 window 的 root view controller触机。使用單例時帚戳,我們的對象圖看起來如下所示:

image.png

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

image.png

這里的問題在于這個好友列表的 view controller 可能仍然在執(zhí)行代碼 (由于后臺操作的原因)蔬胯,并且可能因此仍然有一些沒有執(zhí)行的涉及到 sharedThumbnailCache 的調(diào)用对供。

和使用依賴注入的解決方案對比一下:

image.png

簡單起見,假設(shè) SPApplicationDelegate 管理 SPUser 的實(shí)例 (在實(shí)踐中氛濒,你可能會把這些用戶狀態(tài)的管理工作交給另外一個對象來做产场,這樣可以使你的 application delegate 簡化)。當(dāng)展現(xiàn)好友列表 view controller 時舞竿,會傳遞進(jìn)去一個 user 的引用京景。這個引用也會向下傳遞給 profile image views。現(xiàn)在骗奖,當(dāng)用戶登出時醋粟,我們的對象圖如下所示:

image.png

這個對象圖看起來和使用單例時很像。那么重归,區(qū)別是什么呢米愿?

關(guān)鍵問題是作用域。在單例那種情況中鼻吮,sharedThumbnailCache 仍然可以被程序的任意模塊訪問育苟。假如用戶快速的登錄了一個新的賬號。該用戶也想看看他的好友列表椎木,這也就意味著需要再一次的和縮略圖 cache 產(chǎn)生交互:

image.png

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

image.png

結(jié)論

我們都知道全局可變狀態(tài)是不好的,但是在使用單例的時候我們又不經(jīng)意地把它變成我們討厭的全局可變狀態(tài)。

在面向?qū)ο缶幊讨型蚍覀冃枰M可能減少可變狀態(tài)的作用域悼吱,而單例與這個思想背道而馳,希望在下一次使用單例的時候能夠多想一想良狈,考慮是否這個變量真正值得成為一個單例后添,如果不是,還請使用“依賴注入模式”來代替薪丁。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末遇西,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子严嗜,更是在濱河造成了極大的恐慌努溃,老刑警劉巖,帶你破解...
    沈念sama閱讀 211,639評論 6 492
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件阻问,死亡現(xiàn)場離奇詭異梧税,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī)称近,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 90,277評論 3 385
  • 文/潘曉璐 我一進(jìn)店門第队,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人刨秆,你說我怎么就攤上這事凳谦。” “怎么了衡未?”我有些...
    開封第一講書人閱讀 157,221評論 0 348
  • 文/不壞的土叔 我叫張陵尸执,是天一觀的道長。 經(jīng)常有香客問我缓醋,道長如失,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 56,474評論 1 283
  • 正文 為了忘掉前任送粱,我火速辦了婚禮褪贵,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘抗俄。我一直安慰自己脆丁,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 65,570評論 6 386
  • 文/花漫 我一把揭開白布动雹。 她就那樣靜靜地躺著槽卫,像睡著了一般。 火紅的嫁衣襯著肌膚如雪胰蝠。 梳的紋絲不亂的頭發(fā)上歼培,一...
    開封第一講書人閱讀 49,816評論 1 290
  • 那天震蒋,我揣著相機(jī)與錄音,去河邊找鬼丐怯。 笑死,一個胖子當(dāng)著我的面吹牛翔横,可吹牛的內(nèi)容都是我干的读跷。 我是一名探鬼主播,決...
    沈念sama閱讀 38,957評論 3 408
  • 文/蒼蘭香墨 我猛地睜開眼禾唁,長吁一口氣:“原來是場噩夢啊……” “哼效览!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起荡短,我...
    開封第一講書人閱讀 37,718評論 0 266
  • 序言:老撾萬榮一對情侶失蹤丐枉,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后掘托,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體瘦锹,經(jīng)...
    沈念sama閱讀 44,176評論 1 303
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 36,511評論 2 327
  • 正文 我和宋清朗相戀三年闪盔,在試婚紗的時候發(fā)現(xiàn)自己被綠了弯院。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 38,646評論 1 340
  • 序言:一個原本活蹦亂跳的男人離奇死亡泪掀,死狀恐怖听绳,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情异赫,我是刑警寧澤椅挣,帶...
    沈念sama閱讀 34,322評論 4 330
  • 正文 年R本政府宣布,位于F島的核電站塔拳,受9級特大地震影響鼠证,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜靠抑,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,934評論 3 313
  • 文/蒙蒙 一名惩、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧孕荠,春花似錦娩鹉、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,755評論 0 21
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至个曙,卻和暖如春锈嫩,著一層夾襖步出監(jiān)牢的瞬間受楼,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 31,987評論 1 266
  • 我被黑心中介騙來泰國打工呼寸, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留艳汽,地道東北人。 一個月前我還...
    沈念sama閱讀 46,358評論 2 360
  • 正文 我出身青樓对雪,卻偏偏與公主長得像河狐,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子瑟捣,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 43,514評論 2 348

推薦閱讀更多精彩內(nèi)容