避免濫用單例

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

+ (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 and _b。這樣有以下問題:

  1. computeSum 沒有顯示的通過使用參數的形式聲明它依賴於_a 和 _b的狀態。與僅僅通過查看函數聲明就可以知道這個函數的輸出依賴於哪些變量不同的是,另一個開發者必須查看這個函數的具體實現才能明白這個函數依賴那些變量。隱藏依賴是不好的。

  2. 當修改_a and _b的數值爲調用 computeSum做準備時,程序員需要保證這些修改不會影響任何其他依賴於這兩個變量的代碼的正確性。而這在多線程的環境中是尤其困難的。

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

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

這裏,對變量a 和 b 的依賴被顯示的聲明瞭。我們不需要爲了調用這個方法而去改變實例變量的狀態。並且我們也不需要擔心調用這個函數會留下持久的副作用。我們甚至可以把這個方法聲明爲類方法,這樣就顯示的告訴了代碼的閱讀者這個方法不會修改任何實例的狀態。

那麼,這個例子和單例相比又有什麼關係呢?用 Miko Hevery 的話來說,"單例就是披着羊皮的全局狀態" 。一個單例可以在不需要顯示聲明對其依賴的情況下,被使用在任何地方。就像變量_a 和 _b 在 computeSum 內部被使用了,卻沒有被顯示聲明一樣,程序的任意模塊都可以調用[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 and SPConsumerB是兩個完全獨立的模塊。但是SPConsumerB 可以通過使用單例提供的共享狀態來影響 SPConsumerA 的行爲。這種情況應該只能發生在consumer B顯示引用了A,顯示建立了它們兩者之間的關係時。由於這裏使用了單例,單例的全局性和有狀態性,導致隱式的在兩個看起來完全不相關的模塊之間建立了耦合。

讓我們來看一個更具體的例子,並且暴露一個使用全局可變狀態的額外問題。我們想要在我們的應用中構建一個網頁查看器(web viewer)。我們構建了一個簡單的 URL cache來支持這個網頁查看器:

@interface SPURLCache

+ (SPCache *)sharedURLCache;

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

@end

這個開發者開始寫了一些單元測試來保證代碼在不同的情況下都能達到預期。首先,他寫了一個測試用例來保證網頁查看器在沒有設備鏈接時能夠展示出錯誤信息。然後他寫了一個測試用例來保證網頁查看器能夠正確的處理服務器錯誤。最後,他爲成功情況時寫了一個測試用例,來保證返回的網絡內容能夠被正確的顯示出來。這個開發者運行了所有的測試用例,並且它們都如預期一樣正確。贊!

幾個月以後,這些測試用例開始出現失敗,儘管網頁查看器的代碼從它寫完後就從來沒有再改動過!到底發生了什麼?

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

持久化狀態是單元測試的敵人,因爲單元測試在各個測試用例相互獨立的情況下才有效。如果狀態從一個測試用例傳遞到了另外一個,這樣就和測試用例的執行順序就有關係了。有bug的測試用例是非常糟糕的事情,特別是那些有時候能通過測試,有時候又不能通過測試的。

對象的生命週期

另外一個關鍵問題就是單例的生命週期。當你在程序中添加一個單例時,很容易會認爲 “它們永遠只能有一個實例”。但是在很多我看到過的iOS代碼中,這種假定都可能被打破。

比如,假設我們正在構建一個應用,在這個應用裏用戶可以看到他們的好友列表。他們的每個朋友都有一張個人信息的圖片,並且我們想使我們的應用能夠下載並且在設備上緩存這些圖片。 使用dispatch_once 代碼片段,我們可以寫一個SPThumbnailCache單例:

@interface SPThumbnailCache : NSObject

+ (instancetype)sharedThumbnailCache;

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

@end

我們繼續構建我們的應用,一切看起來都很正常,直到有一天,當我們決定去實現‘註銷’功能時,這樣用戶可以在應用中進行賬號切換。突然我們發現我們將要面臨一個討厭的問題:用戶相關的狀態存儲在全局單例中。當用戶註銷後,我們希望能夠清理掉所有的硬盤上的持久化狀態。否則,我們將會把這些被遺棄的數據殘留在用戶的設備上,浪費寶貴的硬盤空間。對於用戶登出又登錄了一個新的賬號這種情況,我們也想能夠對這個新用戶使用一個全新的SPThumbnailCache 實例。

問題在於按照定義單例被認爲是“創建一次,永久有效”的實例。你可以想到一些對於上述問題的解決方案。或許我們可以在用戶登出時移除這個單例:

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

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

我們當然可以使用這種方式去解決,但是代價實在是太大了。我們不能使用簡單的、能夠保證線程安全和所有的調用 [SPThumbnailCache sharedThumbnailCache] 的地方都會訪問同一個實例的dispatch_once解決方案了。現在我們需要對使用thumbnail cache時的代碼的執行順序非常小心。假設當用戶正在執行登出操作時,有一些後臺任務正在執行把圖片保存到緩存中的操作:

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

我們需要保證在所有的後臺任務完成前, tearDown一定不能被執行。這保證了newImage可以被正確的清理掉。或者,我們需要保證在thumbnail cache被移除時,後臺緩存任務一定要被取消掉。否則,一個新的 thumbnail cache 的實例將會被延遲創建,並且之前用戶的數據 (newImage對象)會被存儲在它裏面。

由於對於單例實例來說它沒有明確的所有者,(比如,單例自己管理自己的生命週期),永遠“關閉”一個單例變得非常的困難。

分析到這裏,我希望你能夠意識到,“這個thumbnail cache從來就不應該作爲一個單例”!問題在於一個對象得生命週期可能在項目的最初階段沒有被很好得考慮清楚。舉一個具體的例子,Dropbox 的 iOS 客戶端曾經只支持一個賬號登錄。它以這樣的狀態存在了數年,直到有一天我們希望能夠同時支持多個用戶賬號登錄(既包括個人賬號也包括企業賬號)。突然之間,我們以前的的假設“只能夠同時有一個用戶處於登錄狀態”就不成立了。 假定一個對象的生命週期和應用的生命週期一致,會限制你的代碼的靈活擴展,早晚有一天當產品的需求產生變化時,你會爲當初的這個假定付出代價的。

這裏我們得到的教訓是,單例應該只用來保存全局的狀態,並且不能和任何作用域綁定。如果這些狀態的作用域比一個完整的應用程序的生命週期要短,那麼這個狀態就不應該使用單例來管理。用一個單例來管理用戶綁定的狀態,是代碼的壞味道,你應該認真的重新評估你的對象圖的設計。

避免使用單例

既然單例對局部作用域的狀態有這麼多的壞處,那麼我們應該怎樣避免使用它們呢?

讓我們來重溫一下上面的例子。既然我們的 thumbnail cache 的緩存狀態是和具體的用戶綁定的,那麼讓我們來定義一個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

我們現在用一個對象來作爲一個經過認證的用戶會話(authenticated user session)的模型類,並且我們可以把所有和用戶相關的狀態存儲在這個對象中。現在假設我們有一個view controller來展現好友列表:

@interface SPFriendListViewController : UIViewController

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

@end

我們可以顯示的把經過認證的 user 對象作爲參數傳遞給這個view controller。這種把依賴性傳遞給依賴對象的技術正式的叫法是 依賴注入, 並且它有很多優點:

  1. 對於閱讀這個SPFriendListViewController頭文件的讀者來說,可以很清楚的知道它只有在有登錄用戶的情況下才會被展示。

  2. 這個 SPFriendListViewController只要還在使用中,就可以強引用 user 對象。舉例來說,對於前面的例子,我們可以像下面這樣在後臺任務中保存一個圖片到thumbnail cache中:

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

這種後臺任務仍然意義重大,當第一個實例失效時,應用其他地方的代碼可以創建和使用一個全新的SPUser對象,而不會阻塞用戶交互。

爲了更詳細的說明一下第二點,讓我們畫一下在使用依賴注入之前和之後的對象圖。

假設我們的SPFriendListViewController是當前window的root view controller。使用單例時,我們的對象圖看起來如下所示:

Screen%20Shot%202014-06-02%20at%205.21.2

view controller自己,以及自定義的p_w_picpath view,都會和sharedThumbnailCache產生交互。當用戶登出後,我們想要清理root view controller並且退出到登錄頁面:

Screen%20Shot%202014-06-02%20at%205.53.4

這裏的問題在於這個friend list view controller可能仍然在執行代碼(由於後臺操作的原因),並且可能因此仍然有一些調用被掛起到 sharedThumbnailCache上。

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

Screen%20Shot%202014-06-02%20at%205.38.5

簡單起見,假設 SPApplicationDelegate 管理SPUser 的實例 (在實踐中,你可能會把這些用戶狀態的管理工作交給另外一個對象來做,這樣可以使你的 application delegate 簡化)。當展現friend list view controller時,會傳遞進去一個user的引用。這個引用也會向下傳遞給profile p_w_picpath views。現在,當用戶登出時,我們的對象圖如下所示:

Screen%20Shot%202014-06-02%20at%205.54.0

這個對象圖看起來和使用單例時很像。那麼,這有什麼大不了的呢?

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

Screen%20Shot%202014-06-02%20at%205.59.2

當用戶登錄一個新賬號,我們應該能夠構建並且與全新的SPThumbnailCache交互,而不需要再在銷燬老的thumbnail cache上花費精力。基於對象管理的典型規則,老的view controllers和老的 thumbnail cache 應該能夠自己在後臺延遲被清理掉。簡而言之,我們應該隔離用戶A相關聯的狀態和用戶B相關聯的狀態:

Screen%20Shot%202014-06-02%20at%206.43.5

結論

希望這篇文章中的內容沒有特別新奇之處。人們已經對單例的濫用抱怨了很多年了,並且我們也都知道全局狀態是很不好的事情。但是在iOS開發的世界中,單例的使用是如此的普遍以至於我們有時候忘記了我們多年來在其他面向對象編程中學到的教訓。

這一切的關鍵點是,在面向對象編程中我們想要最小化可變狀態的作用域。但是單例卻站在了相反的對立面,因爲它們使可變的狀態可以被程序中的任何地方訪問。下一次你想使用單例時,我希望你能夠好好考慮一下使用依賴注入作爲替代方案。


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章