鍍金池/ 教程/ iOS/ 避免濫用單例
與四軸無人機(jī)的通訊
在沙盒中編寫腳本
結(jié)構(gòu)體和值類型
深入理解 CocoaPods
UICollectionView + UIKit 力學(xué)
NSString 與 Unicode
代碼簽名探析
測試
架構(gòu)
第二期-并發(fā)編程
Metal
自定義控件
iOS 中的行為
行為驅(qū)動開發(fā)
Collection View 動畫
截圖測試
MVVM 介紹
使 Mac 應(yīng)用數(shù)據(jù)腳本化
一個完整的 Core Data 應(yīng)用
插件
字符串
為 iOS 建立 Travis CI
先進(jìn)的自動布局工具箱
動畫
為 iOS 7 重新設(shè)計 App
XPC
從 NSURLConnection 到 NSURLSession
Core Data 網(wǎng)絡(luò)應(yīng)用實(shí)例
GPU 加速下的圖像處理
自定義 Core Data 遷移
子類
與調(diào)試器共舞 - LLDB 的華爾茲
圖片格式
并發(fā)編程:API 及挑戰(zhàn)
IP,TCP 和 HTTP
動畫解釋
響應(yīng)式 Android 應(yīng)用
初識 TextKit
客戶端
View-Layer 協(xié)作
回到 Mac
Android
Core Image 介紹
自定義 Formatters
Scene Kit
調(diào)試
項(xiàng)目介紹
Swift 的強(qiáng)大之處
測試并發(fā)程序
Android 通知中心
調(diào)試:案例學(xué)習(xí)
從 UIKit 到 AppKit
iOS 7 : 隱藏技巧和變通之道
安全
底層并發(fā) API
消息傳遞機(jī)制
更輕量的 View Controllers
用 SQLite 和 FMDB 替代 Core Data
字符串解析
終身學(xué)習(xí)的一代人
視頻
Playground 快速原型制作
Omni 內(nèi)部
同步數(shù)據(jù)
設(shè)計優(yōu)雅的移動游戲
繪制像素到屏幕上
相機(jī)與照片
音頻 API 一覽
交互式動畫
常見的后臺實(shí)踐
糟糕的測試
避免濫用單例
數(shù)據(jù)模型和模型對象
Core Data
字符串本地化
View Controller 轉(zhuǎn)場
照片框架
響應(yīng)式視圖
Square Register 中的擴(kuò)張
DTrace
基礎(chǔ)集合類
視頻工具箱和硬件加速
字符串渲染
讓東西變得不那么糟
游戲中的多點(diǎn)互聯(lián)
iCloud 和 Core Data
Views
虛擬音域 - 聲音設(shè)計的藝術(shù)
導(dǎo)航應(yīng)用
線程安全類的設(shè)計
置換測試: Mock, Stub 和其他
Build 工具
KVC 和 KVO
Core Image 和視頻
Android Intents
在 iOS 上捕獲視頻
四軸無人機(jī)項(xiàng)目
Mach-O 可執(zhí)行文件
UI 測試
值對象
活動追蹤
依賴注入
Swift
項(xiàng)目管理
整潔的 Table View 代碼
Swift 方法的多面性
為什么今天安全仍然重要
Core Data 概述
Foundation
Swift 的函數(shù)式 API
iOS 7 的多任務(wù)
自定義 Collection View 布局
測試 View Controllers
訪談
收據(jù)驗(yàn)證
數(shù)據(jù)同步
自定義 ViewController 容器轉(zhuǎn)場
游戲
調(diào)試核對清單
View Controller 容器
學(xué)無止境
XCTest 測試實(shí)戰(zhàn)
iOS 7
Layer 中自定義屬性的動畫
第一期-更輕量的 View Controllers
精通 iCloud 文檔存儲
代碼審查的藝術(shù):Dropbox 的故事
GPU 加速下的圖像視覺
Artsy
照片擴(kuò)展
理解 Scroll Views
使用 VIPER 構(gòu)建 iOS 應(yīng)用
Android 中的 SQLite 數(shù)據(jù)庫支持
Fetch 請求
導(dǎo)入大數(shù)據(jù)集
iOS 開發(fā)者的 Android 第一課
iOS 上的相機(jī)捕捉
語言標(biāo)簽
同步案例學(xué)習(xí)
依賴注入和注解,為什么 Java 比你想象的要好
編譯器
基于 OpenCV 的人臉識別
玩轉(zhuǎn)字符串
相機(jī)工作原理
Build 過程

避免濫用單例

單例是整個 Cocoa 中被廣泛使用的核心設(shè)計模式之一。事實(shí)上,蘋果開發(fā)者庫把單例作為 "Cocoa 核心競爭力" 之一。作為一個iOS開發(fā)者,我們經(jīng)常和單例打交道,比如 UIApplicationNSFileManager 等等。我們在開源項(xiàng)目、蘋果示例代碼和 StackOverflow 中見過了無數(shù)使用單例的例子。Xcode 甚至有一個默認(rèn)的 "Dispatch Once" 代碼片段,可以使我們非常簡單地在代碼中添加一個單例:

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

由于這些原因,單例在 iOS 開發(fā)中隨處可見。問題是,它們很容易被濫用。

盡管有些人認(rèn)為單例是 '反模式', '魔鬼' 以及 ,我不會去完全否認(rèn)單例所帶來的的好處,而是會展示一些使用單例所帶來的問題,這樣下一次在使用 dispatch_once 代碼片段的自動補(bǔ)全功能時,你可以對它的影響進(jìn)行評估,三思而行。

全局狀態(tài)

大多數(shù)的開發(fā)者都認(rèn)同使用全局可變的狀態(tài)是不好的行為。太多狀態(tài)使得程序難以理解,難以調(diào)試。我們這些面向?qū)ο蟮某绦騿T在最小化代碼的狀態(tài)復(fù)雜程度的方面,有很多需要向函數(shù)式編程學(xué)習(xí)的地方。

@implementation SPMath {
    NSUInteger _a;
    NSUInteger _b;
}

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

在上面這個簡單的數(shù)學(xué)庫的實(shí)現(xiàn)中,程序員需要在調(diào)用 computeSum 前正確的設(shè)置實(shí)例變量 _a_b。這樣有以下問題:

  1. computeSum 沒有顯式地通過使用參數(shù)的形式聲明它依賴于 _a_b 的狀態(tài)。與僅僅通過查看函數(shù)聲明就可以知道這個函數(shù)的輸出依賴于哪些變量不同的是,另一個開發(fā)者必須查看這個函數(shù)的具體實(shí)現(xiàn)才能明白這個函數(shù)依賴那些變量。隱藏依賴是不好的。

  2. 當(dāng)為調(diào)用 computeSum 做準(zhǔn)備而修改 _a_b 的數(shù)值時,程序員需要保證這些修改不會影響任何其他依賴于這兩個變量的代碼的正確性。而這在多線程的環(huán)境中是尤其困難的。

把下面的代碼和上面的例子做對比:

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

這里,對變量 ab 的依賴被顯式地聲明了。我們不需要為了調(diào)用這個方法而去改變實(shí)例變量的狀態(tài)。并且我們也不需要擔(dān)心調(diào)用這個函數(shù)會留下持久的副作用。我們甚至可以把這個方法聲明為類方法,這樣就告訴了代碼的閱讀者這個方法不會修改任何實(shí)例的狀態(tài)。

那么,這個例子和單例又有什么關(guān)系呢?用 Mi?ko Hevery 的話來說,"單例就是披著羊皮的全局狀態(tài)"。一個單例可以被使用在任何地方,而不需要顯式地聲明依賴。就像變量 _a_bcomputeSum 內(nèi)部被使用了,卻沒有被顯式聲明一樣,程序的任意模塊都可以調(diào)用 [SPMySingleton sharedInstance] 并且訪問這個單例。這意味著任何和這個單例交互產(chǎn)生的副作用都會影響程序其他地方的任意代碼。

@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

在上面的例子中,SPConsumerASPConsumerB 是兩個完全獨(dú)立的模塊。但是 SPConsumerB 可以通過使用單例提供的共享狀態(tài)來影響 SPConsumerA 的行為。這種情況應(yīng)該只能發(fā)生在 consumer B 顯式引用了 A,并表明了兩者之間的關(guān)系時。這里使用了單例,由于其具有全局和多狀態(tài)的特性,導(dǎo)致隱式地在兩個看起來完全不相關(guān)的模塊之間建立了耦合。

讓我們來看一個更具體的例子,并且暴露一個使用全局可變狀態(tài)的額外問題。比如我們想要在我們的應(yīng)用中構(gòu)建一個網(wǎng)頁查看器。為了支持這個查看器,我們構(gòu)建了一個簡單的 URL cache:

@interface SPURLCache

+ (SPCache *)sharedURLCache;

- (void)storeCachedResponse:(NSCachedURLResponse *)cachedResponse forRequest:(NSURLRequest *)request;

@end    

這個開發(fā)者開始寫一些單元測試來保證代碼在一些不同的情況下都能達(dá)到預(yù)期。首先,他寫了一個測試用例來保證網(wǎng)頁查看器在設(shè)備沒有連接時能夠展示出錯誤信息。然后他寫了一個測試用例來保證網(wǎng)頁查看器能夠正確的處理服務(wù)器錯誤。最后,他為成功情況時寫了一個測試用例,來保證返回的網(wǎng)絡(luò)內(nèi)容能夠被正確的顯示出來。這個開發(fā)者運(yùn)行了所有的測試用例,并且它們都如預(yù)期一樣正確。贊!

幾個月以后,這些測試用例開始出現(xiàn)失敗,盡管網(wǎng)頁查看器的代碼從它寫完后就從來沒有再改動過!到底發(fā)生了什么?

原來,有人改變了測試的順序。處理成功的那個測試用例首先被運(yùn)行,然后再運(yùn)行其他兩個。處理錯誤的那兩個測試用例現(xiàn)在竟然成功了,和預(yù)期不一樣,因?yàn)?URL cache 這個單例把不同測試用例之間的 response 緩存起來了。

持久化狀態(tài)是單元測試的敵人,因?yàn)閱卧獪y試在各個測試用例相互獨(dú)立的情況下才有效。如果狀態(tài)從一個測試用例傳遞到了另外一個,這樣就和測試用例的執(zhí)行順序就有關(guān)系了。有 bug 的測試用例,尤其是那些本來不應(yīng)該通過的測試用例,是非常糟糕的事情。

對象的生命周期

另外一個關(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í)例?,F(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)該作為一個單例!”。問題在于一個對象得生命周期可能在項(xià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)存儲在這個對象中?,F(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。使用單例時,我們的對象圖看起來如下所示:

http://wiki.jikexueyuan.com/project/objc/images/13-4.png" alt="" />

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

http://wiki.jikexueyuan.com/project/objc/images/13-5.png" alt="" />

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

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

http://wiki.jikexueyuan.com/project/objc/images/13-6.png" alt="" />

簡單起見,假設(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)用戶登出時,我們的對象圖如下所示:

http://wiki.jikexueyuan.com/project/objc/images/13-7.png" alt="" />

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

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

http://wiki.jikexueyuan.com/project/objc/images/13-8.png" alt="" />

當(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):

http://wiki.jikexueyuan.com/project/objc/images/13-9.png" alt="" />

結(jié)論

希望這篇文章中的內(nèi)容讀起來不像奇幻小說那樣難以理解。人們已經(jīng)對單例的濫用抱怨了很多年了,并且我們也都知道全局狀態(tài)是很不好的事情。但是在 iOS 開發(fā)的世界中,單例的使用是如此的普遍以至于我們有時候忘記了我們多年來在其他面向?qū)ο缶幊讨袑W(xué)到的教訓(xùn)。

這一切的關(guān)鍵點(diǎn)是,在面向?qū)ο缶幊讨形覀兿胍钚』勺儬顟B(tài)的作用域。但是單例卻因?yàn)槭箍勺兊臓顟B(tài)可以被程序中的任何地方訪問,而站在了對立面。下一次你想使用單例時,我希望你能夠好好考慮一下使用依賴注入作為替代方案。