1. 程式人生 > >iOS 單例的濫用和用依賴注入替代

iOS 單例的濫用和用依賴注入替代

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

  1. + (instancetype)sharedInstance  
  2. {  
  3. static dispatch_once_t once;  
  4. static id sharedInstance;  
  5.     dispatch_once(&once, ^{  
  6.         sharedInstance = [[self alloc] init];  
  7.     });  
  8. return sharedInstance;  

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


儘管有些人認為單例是 '反模式,' '魔鬼,' 和 '病態的說謊者',但是我不能完全的排除單例所帶來的好處。相反,我會展示一些使用單例所帶來的問題,這樣下一次你使用 dispatch_once 程式碼片段的自動補全功能時,三思一下它的影響。


全域性狀態
大多數的開發者都認同使用全域性可變的狀態是不好的行為。有狀態使得程式難以理解和難以除錯。我們這些面向物件的程式設計師在最小化程式碼的有狀態性方面,有很多還需要向函數語言程式設計學習的地方。

  1. @implementation SPMath {  
  2.     NSUInteger _a;  
  3.     NSUInteger _b;  
  4. }  
  5. - (NSUInteger)computeSum  
  6. {  
  7. return _a + _b;  

在上面這個簡單的數學庫的實現中,程式設計師需要在呼叫 computeSum前正確的設定例項變數_a and _b。這樣有以下問題:


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

2、當修改_a and _b的數值為呼叫 computeSum做準備時,程式設計師需要保證這些修改不會影響任何其他依賴於這兩個變數的程式碼的正確性。而這在多執行緒的環境中是尤其困難的。


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

  1. + (NSUInteger)computeSumOf:(NSUInteger)a plus:(NSUInteger)b  
  2. {  
  3. return a + b;  

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


那麼,這個例子和單例相比又有什麼關係呢?用 Miško Hevery 的話來說,"單例就是披著羊皮的全域性狀態" 。一個單例可以在不需要顯示宣告對其依賴的情況下,被使用在任何地方。就像變數_a 和 _b 在 computeSum 內部被使用了,卻沒有被顯示宣告一樣,程式的任意模組都可以呼叫[SPMySingleton sharedInstance] 並且訪問這個單例。這意味著任何和這個單例互動產生的副作用都會影響程式其他地方的任意程式碼。

  1. @interface SPSingleton : NSObject  
  2. + (instancetype)sharedInstance;  
  3. - (NSUInteger)badMutableState;  
  4. - (void)setBadMutableState:(NSUInteger)badMutableState;  
  5. @end  
  6. @implementation SPConsumerA  
  7. - (void)someMethod  
  8. {  
  9. if ([[SPSingleton sharedInstance] badMutableState]) {  
  10. // ...
  11.     }  
  12. }  
  13. @end  
  14. @implementation SPConsumerB  
  15. - (void)someOtherMethod  
  16. {  
  17.     [[SPSingleton sharedInstance] setBadMutableState:0];  
  18. }  
  19. @end 

在上面的程式碼中,SPConsumerA and SPConsumerB是兩個完全獨立的模組。但是SPConsumerB 可以通過使用單例提供的共享狀態來影響 SPConsumerA 的行為。這種情況應該只能發生在consumer B顯示引用了A,顯示建立了它們兩者之間的關係時。由於這裡使用了單例,單例的全域性性和有狀態性,導致隱式的在兩個看起來完全不相關的模組之間建立了耦合。


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

  1. @interface SPURLCache  
  2. + (SPCache *)sharedURLCache;  
  3. - (void)storeCachedResponse:(NSCachedURLResponse *)cachedResponse forRequest:(NSURLRequest *)request;  
  4. @end    

 這個開發者開始寫了一些單元測試來保證程式碼在不同的情況下都能達到預期。首先,他寫了一個測試用例來保證網頁檢視器在沒有裝置連結時能夠展示出錯誤資訊。然後他寫了一個測試用例來保證網頁檢視器能夠正確的處理伺服器錯誤。最後,他為成功情況時寫了一個測試用例,來保證返回的網路內容能夠被正確的顯示出來。這個開發者運行了所有的測試用例,並且它們都如預期一樣正確。贊!


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


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

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


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


比如,假設我們正在構建一個應用,在這個應用裡使用者可以看到他們的好友列表。他們的每個朋友都有一張個人資訊的圖片,並且我們想使我們的應用能夠下載並且在裝置上快取這些圖片。 使用dispatch_once 程式碼片段,我們可以寫一個SPThumbnailCache單例:

  1. @interface SPThumbnailCache : NSObject  
  2. + (instancetype)sharedThumbnailCache;  
  3. - (void)cacheProfileImage:(NSData *)imageData forUserId:(NSString *)userId;  
  4. - (NSData *)cachedProfileImageForUserId:(NSString *)userId;  
  5. @end 

我們繼續構建我們的應用,一切看起來都很正常,直到有一天,當我們決定去實現‘登出’功能時,這樣使用者可以在應用中進行賬號切換。突然我們發現我們將要面臨一個討厭的問題:使用者相關的狀態儲存在全域性單例中。當用戶登出後,我們希望能夠清理掉所有的硬碟上的持久化狀態。否則,我們將會把這些被遺棄的資料殘留在使用者的裝置上,浪費寶貴的硬碟空間。對於使用者登出又登入了一個新的賬號這種情況,我們也想能夠對這個新使用者使用一個全新的SPThumbnailCache 例項。
問題在於按照定義單例被認為是“建立一次,永久有效”的例項。你可以想到一些對於上述問題的解決方案。或許我們可以在使用者登出時移除這個單例:

  1. static SPThumbnailCache *sharedThumbnailCache;  
  2. + (instancetype)sharedThumbnailCache  
  3. {  
  4. if (!sharedThumbnailCache) {  
  5.         sharedThumbnailCache = [[self alloc] init];  
  6.     }  
  7. return sharedThumbnailCache;  
  8. }  
  9. + (void)tearDown  
  10. {  
  11. // The SPThumbnailCache will clean up persistent states when deallocated
  12.     sharedThumbnailCache = nil;  

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


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

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

我們需要保證在所有的後臺任務完成前, tearDown一定不能被執行。這保證了newImage可以被正確的清理掉。或者,我們需要保證在thumbnail cache被移除時,後臺快取任務一定要被取消掉。否則,一個新的 thumbnail cache 的例項將會被延遲建立,並且之前使用者的資料 (newImage物件)會被儲存在它裡面。


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


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


這裡我們得到的教訓是,單例應該只用來儲存全域性的狀態,並且不能和任何作用域繫結。如果這些狀態的作用域比一個完整的應用程式的生命週期要短,那麼這個狀態就不應該使用單例來管理。用一個單例來管理使用者繫結的狀態,是程式碼的壞味道,你應該認真的重新評估你的物件圖的設計。


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


讓我們來重溫一下上面的例子。既然我們的 thumbnail cache 的快取狀態是和具體的使用者繫結的,那麼讓我們來定義一個user物件吧:

  1. @interface SPUser : NSObject  
  2. @property (nonatomic, readonly) SPThumbnailCache *thumbnailCache;  
  3. @end  
  4. @implementation SPUser  
  5. - (instancetype)init  
  6. {  
  7. if ((self = [super init])) {  
  8.         _thumbnailCache = [[SPThumbnailCache alloc] init];  
  9. // Initialize other user-specific state...
  10.     }  
  11. return self;  
  12. }  
  13. @end 

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

  1. @interface SPFriendListViewController : UIViewController  
  2. - (instancetype)initWithUser:(SPUser *)user;  
  3. @end 

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


1、對於閱讀這個SPFriendListViewController標頭檔案的讀者來說,可以很清楚的知道它只有在有登入使用者的情況下才會被展示。


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

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

這種後臺任務仍然意義重大,當第一個例項失效時,應用其他地方的程式碼可以建立和使用一個全新的SPUser物件,而不會阻塞使用者互動。


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

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

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

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


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

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

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


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

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

結論

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

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