單例不是一種反模式,它們只是被濫用的模式。它們因方便,而大受初學者歡迎。但它們也有可能增加複雜性並引起致命bug。
讓我們先從優點說起。經常會遇到這樣的情況,你需要用一個例項表示某個物件,有且只能有一個。想想 UIApplication:
1 2 |
UIApplication.sharedApplication.applicationIconBadgeNumber = 0 UIApplication.sharedApplication.applicationIconBadgeNumber = 1 |
那比下面這個要更清楚明白:
1 2 3 4 5 |
let firstInstance = UIApplication() firstInstance.applicationIconBadgeNumber = 0 let secondInstance = UIApplication() secondInstance.applicationIconBadgeNumber = 1 |
firstInstance == secondInstance 是否應該總是返回 true?當我們更新一個,是否更新另一個?
舉個恰當的比喻,單例代表了一個無所不在的物件,一個永遠不會隨你改變的物件,像目前的應用,裝置的加速度計,或你的上帝。
很少有需要在應用程式中共享一個單一服務的情況。NSNotificationCenter 只在一種情況下起作用,即整個元件層只有一個單一的廣播,因此它的名字中含有“中心(Center)”。1 在除此以外的其他地方,不要使用單例。
不當的模型
有時我看到用 User.currentUser 或 Account.sharedAccount 表示當前登入使用者。我不怪你。因為這樣很方便。
1 2 3 4 5 |
class NewsFeedController:UIViewController { func didPullToRefresh(sender:AnyObject){ Account.currentAccount.newsFeed.loadNewer() } } |
但帳戶不是單例。使用者會登出帳戶。許多應用程式通過一種特殊型別的帳戶表示一個登出的狀態。服務逐漸支援多賬戶同時登入。帳戶是可變的,這樣單例就是一個謊言。
如果帳戶是一個真正的單例,這將不是一個問題:
1 2 3 4 5 |
Account.currentAccount.networkAPI.validatePhoto(photo) { newPhoto, errorOrNil in guard errorOrNil == nil else { return } Account.currentAccount.networkAPI.updatePhoto(newPhoto) } |
如果頭像在一個緩慢的網路中需要幾分鐘才能上傳,如果在這期間使用者切換了帳戶呢?那麼得到頭像的帳戶就不對了。
單例共享狀態
“單例”可以看作是“全域性變數”。有時,全域性變數幾乎不是必要的,你應該盡力避免它們。儲存狀態很難,共享狀態就更難了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
class HomeViewController:UIViewController { override func viewWillAppear(animated:Bool){ super.viewWillAppear(animated) Analytics.sharedAnalytics.currentViewController = self } func didTapLike(sender:AnyObject) { Analytics.sharedAnalytics.recordEvent(.TappedLike) } } // An Alert Modal that can pop up any time class IncomingMessagePrompt:UIViewController { override func viewWillAppear(animated:Bool){ super.viewWillAppear(animated) Analytics.sharedAnalytics.currentViewController = self } func didTapReply(sender:AnyObject) { Analytics.sharedAnalytics.recordEvent(.TappedReply) } } |
自定義容器的檢視控制器使 viewWillAppear 沒那麼難以預料。即使是普通的導航也存在邊緣情況:如果你嘗試滑動返回,你會改變想法,並取消它,因為你會得到一個錯誤的 viewWillappear,從而在錯誤的檢視控制器中記錄事件。
這樣會更好:
1 2 3 |
func didTapReply(sender:AnyObject) { Analytics.sharedAnalytics.recordEvent(.TappedReply, fromViewController:self) } |
但現實世界往往會是不一樣的情況。也許你的分析團隊會發現存在重複點選,因而他們要求你只記錄一次,即每個檢視控制器例項只呼叫 Tappedreply 一次。
1 2 3 4 5 6 |
class IncomingMessagePrompt:UIViewController { lazy var analytics:Analytics = { Analytics(controller:self) }() func didTapReply(sender:AnyObject){ analytics.recordEvent(.TappedReply) } } |
在真實情況中,你可能會想把你所有的分析請求都保留在一個佇列中,這樣你就可以將它們進行節流。也許一個單例是比較好的選擇。至少縮小了範圍:
1 |
Analytics(controller:self, queue:AnalyticsQueue.sharedQueue) |
跨界單例
這裡有一個很有趣大bug,是我重構的時候遇到的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
class Account:NSObject { static sharedAccount = Account() var homeTimeline:Timeline() var preferences:AccountPreference init(){ preferences = AccountPreference.loadFromDisk() homeTimeline = Timeline() super.init() } } class Timeline:NSObject { init(){ self.orderPreference = Account.sharedAccount.preferences.timelineOrder super.init() } } |
這段程式碼連初始化都完成不了,因為 Timeline 物件在初始化的過程中訪問 Account 單例,而 Account 單例在初始化中也呼叫了 Timeline 初始化方法。想象這種錯誤程式碼在物件圖中埋藏更深的隱患。
如果我們想對 Timeline 進行單元測試該怎麼辦呢?我們必須模擬 Account 物件,並返回一個模擬 preference 物件。啊,真是夠了。
如果任何人都可以持有一個物件,應用程式中的任何物件都可以具有隱藏的依賴關係是一件很糾結的事情。
還是重構一下吧:
1 2 3 4 5 |
init(){ preferences = AccountPreference.loadFromDisk() homeTimeline = Timeline(order:preferences.timelineOrder) super.init() } |
沒有必要將檢視控制器與 Account 單例分離。和重構以前相比:
1 2 |
let news = NewsFeedController() self.showViewController(news) |
重構以後:
1 2 |
let news = NewsFeedController(account:self.account) self.showViewController(news) |
最好的實踐
對於非常簡單的應用程式,你將永遠不會遇到單例問題。當你第一次建立一個應用程式,這個最好的做法似乎是多餘的。“你不需要它,”有人說,“任何時候你都可以重構!“
根據我以往的經驗,填以前的坑顯然比第一次就把事情做好要難的多。當你把自己逼入困境的時候,並且你的產品經理又要求你做一個快速的改變,只嘗試一次就成功還是會有很多壓力的。
這不是關於假想功能的設計,如支援多個帳戶。而是關於如何應對未知的明天。為了保留靈活性適當的額外工作也是值得的。