一、單例介紹
單例:該類在程序運行期間有且僅有一個實例。
1.1 單例模式的要點
- 該類有且只有一個實例;
- 該類必須能夠自行創建這個實例;
- 該類必須能夠自行向整個系統提供這個實例。
1.2 單例的主要優點
- 單例可以保證系統中該類有且僅有一個實例,確保所有對象都訪問這個唯一實例;
- 因為類控制了實例化過程,所以類可以靈活更改實例化過程;
- 基於第 1 條,對於項目中的個別場景的傳值、存儲狀態等業務更加方便;
- 可以節約系統資源,對於一些需要頻繁創建和銷毀的對象單例模式無疑可以提高系統的性能。
1.3 單例的主要缺點
- 由於單利模式中沒有抽象層,因此單例類的擴展有很大的困難。單例不能被繼承,不能有子類;
- 不易被重寫或擴展(可以使用分類)
- 單例實例一旦創建,對象指針是保存在靜態區,那么在堆區分配的空間只有在應用程序終止后才會被釋放;
- 單例類的職責過重,在一定程度上違背了“單一職責原則”。
1.4 單例的生命周期
下面的表格展示了程序中中不同的變量在手機存儲器中的存儲位置;
位置 | 存放的變量 |
---|---|
棧 | 臨時變量(由編譯器管理自動創建/分配/釋放的,棧中的內存被調用時處於存儲空間中,調用完畢后由系統系統自動釋放內存) |
堆 | 通過 alloc、calloc、malloc 或 new 申請內存,由開發者手動在調用之后通過 free 或 delete 釋放內存。動態內存的生存期可以由我們決定,如果我們不釋放內存,程序將在最后才釋放掉動態內存,在ARC模式下,由系統自動管理。 |
全局區域 | 靜態變量(編譯時分配,APP 結束時由系統釋放) |
常量 | 常量(編譯時分配,APP結束時由系統釋放) |
代碼區 | 存放代碼 |
在程序中,一個單例類在程序中只能初始化一次,為了保證在使用中始終都是存在的,所以單例是在存儲器的全局區域,在編譯時分配內存,只要程序還在運行就會一直占用內存,在 APP 結束后由系統釋放這部分內存內存。
單例的靜態變量被置為 nil,是否內存會得到釋放?
static Singletion * singleton;
- (void)dealloc
{
NSLog(@"%s", __func__);
}
Singleton * s = [Singleton sharedSingleton];
s = nil;
singleton = nil;
將單例類實例對象賦值 nil 后,會觸發單例的 dealloc 方法。
靜態變量修飾的指針保存在了全局區域,不會被釋放。但是指針保存的首地址關聯的對象是保存在堆區的,是會被釋放的。
二、單例的實現
單例的實現重點就是防止在外部調用的時候出現多個不同的實例,也就是說要從創建的方式入手禁止出現多個不同的實例。
主要是做到以下幾點:
- 防止調用 [[A alloc] init] 引起的錯誤
- 防止調用 new 引起的錯誤
- 防止調用 copy 引起的錯誤
- 防止調用 mutableCopy 引起的錯誤
2.1 實現方式一
把所有可能出現的初始化方法做了相應的處理來其保證安全性
+ (instancetype)sharedSingleton
{
static Singleton *_sharedSingleton = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
// 不能再使用 alloc 方法
// 因為已經重寫了 allocWithZone 方法,所以這里要調用父類的分配空間的方法
_sharedSingleton = [[super allocWithZone:NULL] init];
});
return _sharedSingleton;
}
// ②、防止 [[A alloc] init] 和 new 引起的錯誤。因為 [[A alloc] init] 和 new 實際是一樣的工作原理,都是執行了下面方法
+ (instancetype)allocWithZone:(struct _NSZone *)zone
{
return [Singleton sharedSingleton];
}
// ③、NSCopying 防止 copy 引起的錯誤。當你的單例類不遵循 NSCopying 協議,外部調用本身就會出錯.
- (id)copyWithZone:(nullable NSZone *)zone
{
return [Singleton sharedSingleton];
}
// ④、防止 mutableCopy 引起的錯誤,當你的單例類不遵循 NSMutableCopying 協議,外部調用本身就會出錯.
- (id)mutableCopyWithZone:(nullable NSZone *)zone
{
return [Singleton sharedSingleton];
}
dispatch_once 主要是根據 onceToken
的值來決定怎么去執行代碼。
- 當 onceToken = 0 時,線程執行 dispatch_once 的 block 中代碼;
- 當 onceToken = -1 時,線程跳過 dispatch_once 的 block 中代碼不執行;
- 當 onceToken 為其他值時,線程被阻塞,等待 onceToken 值改變。
當線程調用 shareInstance,此時 onceToken = 0,調用 block 中的代碼,此時 onceToken 的值變為 140734537148864。當其他線程再調用 shareInstance 方法時,onceToken 的值已經是 140734537148864 了,線程阻塞。當 block 線程執行完 block 之后,onceToken 變為 -1,其他線程不再阻塞,跳過 block。下次再調用 shareInstance 時,block 已經為 -1,直接跳過 block。
2.2 實現方式二
不做處理的情況下禁止外部調用
一些成熟的第三方代碼的單例中也有使用該方法的。
.h 文件
- (instancetype)init NS_UNAVAILABLE;
+ (instancetype)new NS_UNAVAILABLE;
- (id)copy NS_UNAVAILABLE;
- (id)mutableCopy NS_UNAVAILABLE;
.m 文件
+ (instancetype)sharedSingleton
{
static Singleton *_sharedSingleton = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
_sharedSingleton = [[self alloc] init]; // 要使用 self 來調用
});
return _sharedSingleton;
}
當運行 [[A alloc] init] 或 [A new] 時,會直接報錯 'init' is unavailable 或 'new' is unavailable。
三、單例的濫用
3.1 全局狀態
大多數的開發者都認同使用全局可變的狀態是不好的行為。有狀態使得程序難以理解和難以調試。面向對象的程序員在最小化代碼的有狀態性方面,有很多還需要向函數式編程學習的地方。
@implementation SPMath
{
NSInteger _a;
NSInteger _b;
}
- (NSInteger)add
{
return _a + _b;
}
在上面這個簡單的數學庫的實現中,程序員需要在調用 add 前正確的設置實例變量 _a 和 _b。這樣有以下問題:
add 沒有顯式的通過使用參數的形式聲明它依賴於 _a 和 _b 的狀態。與僅僅通過查看函數聲明就可以知道這個函數的輸出依賴於哪些變量不同的是,另一個開發者必須查看這個函數的具體實現才能明白這個函數依賴那些變量。隱藏依賴是不好的。
當修改 _a 和 _b 的數值為調用 add 做准備時,程序員需要保證修改不會影響任何其他依賴於這兩個變量的代碼的正確性。而這在多線程的環境中是尤其困難的。
把下面的代碼和上面的例子做對比:
+ (NSUInteger)addOf:(NSUInteger)a plus:(NSUInteger)b
{
return a + b;
}
這里,對變量 a 和 b 的依賴被顯式的聲明了,並且不需要為了調用這個方法而去改變實例變量的狀態,也不需要擔心調用這個函數會留下持久的副作用。甚至可以聲明為類方法,這樣就顯式的告訴了代碼的閱讀者:這個方法不會修改任何實例的狀態。
那么,這個例子和單例相比又有什么關系呢?用 Miško Hevery 的話來說,“單例就是披着羊皮的全局狀態” 。
一個單例可以在不需要顯式聲明對其依賴的情況下,被使用在任何地方。就像變量 _a 和 _b 在 add 內部被使用了,卻沒有被顯式聲明一樣,程序的任意模塊都可以調用 [A sharedInstance] 並且訪問這個單例。這意味着任何和這個單例交互產生的副作用都會影響程序其他地方的任意代碼。
@interface Singleton : NSObject
+ (instancetype)sharedInstance;
- (NSString *)name;
- (void)setName:(NSString *)name;
@end
@implementation A
- (void)a
{
if ([[Singleton sharedInstance] name]) {
// ...
}
}
@end
@implementation B
- (void)b
{
[[Singleton sharedInstance] setName:""];
}
@end
在上面的代碼中,A 和 B 是兩個完全獨立的模塊。但是 B 可以通過使用單例提供的共享狀態來影響 A 的行為。這種情況應該只能發生在 B 顯式引用了 A,顯式建立了它們兩者之間的關系時。由於這里使用了單例,單例的全局性和有狀態性,導致隱式的在兩個看起來完全不相關的模塊之間建立了耦合。
來看一個更具體的例子,並且暴露一個使用全局可變狀態的額外問題。
想要在我們的應用中構建一個網頁查看器(web viewer)。我們構建了一個簡單的 URL cache 來支持這個網頁查看器:
@interface URLCache
+ (NSCache *)sharedURLCache;
- (void)storeCachedResponse:(NSCachedURLResponse *)cachedResponse forRequest:(NSURLRequest *)request;
@end
這個開發者開始寫了一些單元測試來保證代碼在不同的情況下都能達到預期。首先,他寫了一個測試用例來保證網頁查看器在沒有設備鏈接時能夠展示出錯誤信息。然后他寫了一個測試用例來保證網頁查看器能夠正確的處理服務器錯誤。最后,他為成功情況時寫了一個測試用例,來保證返回的網絡內容能夠被正確的顯示出來。這個開發者運行了所有的測試用例,並且它們都如預期一樣正確。
幾個月以后,這些測試用例開始出現失敗,盡管網頁查看器的代碼從它寫完后就從來沒有再改動過!到底發生了什么?
原來,有人改變了測試的順序。處理成功的那個測試用例首先被運行,然后再運行其他兩個。處理錯誤的那兩個測試用例現在竟然成功了,和預期不一樣,因為 URL cache 這個單例把不同測試用例之間的 response 緩存起來了。
持久化狀態是單元測試的敵人,因為單元測試在各個測試用例相互獨立的情況下才有效。如果狀態從一個測試用例傳遞到了另外一個,這樣就和測試用例的執行順序就有關系了。有 bug 的測試用例是非常糟糕的事情,特別是那些有時候能通過測試,有時候又不能通過測試的。
3.2 對象的生命周期
另外一個關鍵問題就是單例的生命周期。當你在程序中添加一個單例時,很容易會認為 “它們永遠只能有一個實例”。但是在很多我看到過的 iOS 代碼中,這種假定都可能被打破。
假設我們正在構建一個應用,在這個應用里用戶可以看到他們的好友列表。他們的每個朋友都有一張個人信息的圖片,並且我們想使我們的應用能夠下載並且在設備上緩存這些圖片。 使用 dispatch_once 代碼片段,寫一個 ThumbnailCache 單例:
@interface ThumbnailCache : NSObject
+ (instancetype)sharedThumbnailCache;
- (void)cacheProfileImage:(NSData *)imageData forUserId:(NSString *)userId;
- (NSData *)cachedProfileImageForUserId:(NSString *)userId;
@end
繼續構建我們的應用,一切看起來都很正常,直到有一天,決定實現“注銷”功能時,這樣用戶可以在應用中進行賬號切換。突然發現我們將要面臨一個討厭的問題:用戶相關的狀態存儲在全局單例中。
當用戶注銷后,我們希望能夠清理掉所有的硬盤上的持久化狀態。否則,我們將會把這些被遺棄的數據殘留在用戶的設備上,浪費寶貴的硬盤空間。對於用戶登出又登錄了一個新的賬號這種情況,我們也想能夠對這個新用戶使用一個全新的 ThumbnailCache 實例。
問題在於按照定義單例被認為是“創建一次,永久有效”的實例。你可以想到一些對於上述問題的解決方案。或許我們可以在用戶登出時移除這個單例:
static ThumbnailCache * sharedThumbnailCache;
+ (instancetype)sharedThumbnailCache
{
if (!sharedThumbnailCache) {
sharedThumbnailCache = [[self alloc] init];
}
return sharedThumbnailCache;
}
+ (void)cleanUp
{
// The SPThumbnailCache will clean up persistent states when deallocated
sharedThumbnailCache = nil;
}
這是一個明顯的對單例模式的濫用,但是它可以工作,對吧。
當然可以使用這種方式去解決,但代價實在是太大了。我們不能使用簡單的、能夠保證線程安全和所有的調用 [ThumbnailCache sharedThumbnailCache] 的地方都會訪問同一個實例的 dispatch_once 解決方案了。現在我們需要對使用 thumbnail cache 時的代碼的執行順序非常小心。假設當用戶正在執行登出操作時,有一些后台任務正在執行把圖片保存到緩存中的操作:
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
[[ThumbnailCache sharedThumbnailCache] cacheProfileImage:newImage forUserId:userId];
});
需要保證在所有的后台任務完成前, cleanUp 一定不能被執行。這保證了 newImage 可以被正確的清理掉。或者,我們需要保證在 thumbnail cache 被移除時,后台緩存任務一定要被取消掉。否則,一個新的 thumbnail cache 的實例將會被延遲創建,並且之前用戶的數據(newImage 對象)會被存儲在它里面。
由於對於單例實例來說它沒有明確的所有者,(比如,單例自己管理自己的生命周期),永遠“關閉”一個單例變得非常的困難。
分析到這里,希望能夠意識到,這個 thumbnail cache 從來就不應該作為一個單例。問題在於一個對象的生命周期可能在項目的最初階段沒有被很好得考慮清楚。
舉一個具體的例子,Dropbox 的 iOS 客戶端曾經只支持一個賬號登錄。它以這樣的狀態存在了數年,直到有一天我們希望能夠同時支持多個用戶賬號登錄(既包括個人賬號也包括企業賬號)。突然之間,我們以前的的假設“只能夠同時有一個用戶處於登錄狀態”就不成立了。 假定一個對象的生命周期和應用的生命周期一致,會限制你的代碼的靈活擴展,早晚有一天當產品的需求產生變化時,你會為當初的這個假定付出代價的。
這里我們得到的教訓是:單例應該只用來保存全局的狀態,並且不能和任何作用域綁定。如果這些狀態的作用域比一個完整的應用程序的生命周期要短,那么這個狀態就不應該使用單例來管理。用一個單例來管理用戶綁定的狀態,是代碼的壞味道,你應該認真的重新評估你的對象圖的設計。
四、避免使用單例
既然單例對局部作用域的狀態有這么多的壞處,那么應該怎樣避免使用它們呢?
重溫上面的例子。既然我們的 thumbnail cache 的緩存狀態是和具體的用戶綁定的,那么定義一個 user 對象吧。
@interface User : NSObject
@property (nonatomic, readonly) ThumbnailCache * thumbnailCache;
@end
@implementation User
- (instancetype)init
{
if ((self = [super init])) {
_thumbnailCache = [[ThumbnailCache alloc] init];
}
return self;
}
@end
現在用一個對象來作為一個經過認證的用戶會話的模型類,並且可以把所有和用戶相關的狀態存儲在這個對象中。
現在假設我們有一個 VC 來展現好友列表:
@interface FriendListVC : UIViewController
- (instancetype)initWithUser:(User *)user;
@end
我們可以顯式的把經過認證的 user 對象作為參數傳遞給這個 vc。這種把依賴性傳遞給依賴對象的技術正式的叫法是依賴注入,並且它有很多優點:
①、對於閱讀這個 FriendListVC 頭文件的人來說,可以很清楚的知道它只有在有登錄用戶的情況下才會被展示。
②、這個 FriendListVC 只要還在使用中,就可以強引用 user 對象。
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
[_user.thumbnailCache cacheProfileImage:newImage forUserId:userId];
});
這種后台任務仍然意義重大,當第一個實例失效時,應用其他地方的代碼可以創建和使用一個全新的 User 對象,而不會阻塞用戶交互。
為了更詳細的說明一下第二點,讓我們畫一下在使用依賴注入之前和之后的對象圖。
- 假設 FriendListVC 是當前 window 的 root view controller。使用單例時,對象圖看起來如下所示:
vc 以及自定義的 imageView,都會和 sharedThumbnailCache 產生交互。
當用戶登出后,清理 rootViewController 並且退出到登錄頁面:
這里的問題在於這個 FriendListVC 可能仍然在執行代碼(由於后台操作的原因),並且可能因此仍然有一些調用被掛起到 sharedThumbnailCache 上。
- 使用依賴注入的對象圖:
簡單起見,假設 UIApplicationDelegate 管理 User 的實例(在實際中,為了簡化 applicationDelegate 可能會把這些用戶狀態的管理工作交給另外一個對象來做)。當展現 FriendListVC 時,會傳遞進去一個 user 的引用。這個引用也會向下傳遞給 profileImageView。現在,當用戶登出時,我們的對象圖如下所示:
這個對象圖看起來和使用單例時很像。這有什么區別?
關鍵問題是作用域。在單例情況下,sharedThumbnailCache 仍然可以被程序的任意模塊訪問。假如用戶快速的登錄了一個新的賬號。該用戶也想看看他的好友列表,這也就意味着需要再一次的和 thumbnailCache 產生交互:
當用戶登錄一個新賬號,我們應該能夠構建並且與全新的 ThumbnailCache 交互,而不需要再在銷毀老的 thumbnailCache 上花費精力。基於對象管理的典型規則,舊的 vc 和老的 thumbnailCache 應該能夠自己在后台延遲被清理掉。簡而言之,我們應該隔離用戶 A 相關聯的狀態和用戶 B 相關聯的狀態:
五、結論
在 iOS 開發的世界中,單例的使用是如此的普遍以至於我們有時候忘記了多年來在其他面向對象編程中學到的教訓。
這一切的關鍵點在於,在面向對象編程中我們想要最小化可變狀態的作用域。但是單例卻站在了對立面,因為它們使可變的狀態可以被程序中的任何地方訪問。下一次使用單例時,希望能夠好好考慮一下使用依賴注入作為替代方案。