用 Swift 實(shí)現(xiàn)輕量的屬性監(jiān)聽(tīng)系統(tǒng)
前言
本文的主要目的是解決客戶(hù)端開(kāi)發(fā)中對(duì)“模型的一處修改,UI 要多處更新”的問(wèn)題。當(dāng)然,我們要知曉解決方案的細(xì)節(jié)和思考過(guò)程,以及看到其能達(dá)到的效果。我們會(huì)用到函數(shù)式編程的思想,以及偉大的“泛型”。請(qǐng)相信我,我們并非為了使用新技術(shù)而使用新技術(shù)。如果一個(gè)問(wèn)題有更好的方法去解決,那為何不替換掉舊方法呢?
正文
假如你正在寫(xiě)的 App 是有用戶(hù)系統(tǒng)的,也就是用戶(hù)需要管理自己的信息,如修改名字、頭發(fā)顏色之類(lèi)的。
單獨(dú)拿名字來(lái)說(shuō),除開(kāi)在修改界面,可能在系統(tǒng)的其他界面也會(huì)使用到它,這就涉及到在更新名字后再更新其他界面的問(wèn)題。
你的第一直覺(jué)是什么呢?多半是使用通知,也就是 NSNotification。這是一種很好的辦法,雖然邏輯松散,寫(xiě)起來(lái)有些麻煩。比如要定義一個(gè)通知名,發(fā)送通知,各界面都監(jiān)聽(tīng)通知再處理,等等。
例如,對(duì)于如下 3 個(gè)界面,都有顯示名字。通過(guò) push,用戶(hù)可以在第 3 個(gè)界面里修改名字,這就需要更新這 3 個(gè)界面的名字,不然用戶(hù) pop 返回時(shí)就會(huì)覺(jué)得奇怪。
UI
假如我們的名字放在一個(gè)叫做 UserInfo 的類(lèi)里(訪問(wèn)和修改都使用單例),如下:
- class UserInfo {
- static let sharedInstance = UserInfo()
- struct Notification {
- static let NameChanged = "UserInfo.Notification.NameChanged"
- }
- var name: String = "NIX" {
- didSet {
- NSNotificationCenter.defaultCenter().postNotificationName(Notification.NameChanged, object: name)
- }
- }
- }
同時(shí)我們定義了一個(gè)通知。在 name 被改變后就發(fā)出這個(gè)通知,并把 name 傳出去。
三個(gè)界面分別為 FirstViewController、SecondViewController、ThirdViewController,都有一個(gè) button 在正中間。其中前兩個(gè)負(fù)責(zé) push,最后一個(gè)點(diǎn)擊后可以改名字。因此,對(duì)于 FirstViewController 來(lái)說(shuō):
- class FirstViewController: UIViewController {
- @IBOutlet weak var nameButton: UIButton!
- override func viewDidLoad() {
- super.viewDidLoad()
- title = "First"
- nameButton.setTitle(UserInfo.sharedInstance.name, forState: .Normal)
- NSNotificationCenter.defaultCenter().addObserver(self, selector: "updateUI:", name: UserInfo.Notification.NameChanged, object: nil)
- }
- func updateUI(notification: NSNotification) {
- if let name = notification.object as? String {
- nameButton.setTitle(name, forState: .Normal)
- }
- }
- }
除了加載時(shí)設(shè)置 button 之外,我們還要監(jiān)聽(tīng)通知,并在 name 被改變時(shí)更新 button 的 title。
SecondViewController 的代碼類(lèi)似 FirstViewController,不贅述。
對(duì)于 ThirdViewController,除了設(shè)置和通知外,還有一個(gè) button 的 target-action 方法用于修改名字,也很簡(jiǎn)單:
- @IBAction func changeName(sender: UIButton) {
- let alertController = UIAlertController(title: "Change name", message: nil, preferredStyle: .Alert)
- alertController.addTextFieldWithConfigurationHandler { (textField) -> Void in
- textField.placeholder = self.nameButton.titleLabel?.text
- }
- let action: UIAlertAction = UIAlertAction(title: "OK", style: .Default) { action -> Void in
- if let textField = alertController.textFields?.first as? UITextField {
- UserInfo.sharedInstance.name = textField.text // 更新名字
- }
- }
- alertController.addAction(action)
- self.presentViewController(alertController, animated: true, completion: nil)
- }
似乎并不麻煩,看起來(lái)也算合理,那上面這樣寫(xiě)有什么問(wèn)題?我想答案是太重復(fù)。為了減少重復(fù),我們來(lái)增加自己的知識(shí),讓腦神經(jīng)稍微痛苦一點(diǎn),好形成一些新的聯(lián)結(jié)或破壞一些舊的聯(lián)結(jié)。
我們可以傳遞閉包給 UserInfo,它將閉包存儲(chǔ)起來(lái),并在 name 被改變時(shí)調(diào)用這些閉包,這樣閉包里的操作就會(huì)被執(zhí)行了。自然,我們要在閉包里更新 UI。
這樣,新的 UserInfo 如下:
- class UserInfo {
- static let sharedInstance = UserInfo()
- typealias NameListener = String -> Void
- var nameListeners = [NameListener]()
- class func bindNameListener(nameListener: NameListener) {
- self.sharedInstance.nameListeners.append(nameListener)
- }
- class func bindAndFireNameListener(nameListener: NameListener) {
- bindNameListener(nameListener)
- nameListener(self.sharedInstance.name)
- }
- var name: String = "NIX" {
- didSet {
- nameListeners.map { $0(self.name) }
- }
- }
- }
我們刪除了通知相關(guān)的代碼,定義了 NameListener,增加了一個(gè) nameListeners 用于保存監(jiān)聽(tīng)者閉包,并實(shí)現(xiàn)兩個(gè)類(lèi)方法 bindNameListener 和 bindAndFireNameListener 來(lái)保存(并觸發(fā))監(jiān)聽(tīng)者閉包。而在 name 的 didSet 里,我們只需要調(diào)用每個(gè)閉包即可,這里用了 map,也很直觀。
那么 FirstViewController 的代碼就簡(jiǎn)化為:
- class FirstViewController: UIViewController {
- @IBOutlet weak var nameButton: UIButton!
- override func viewDidLoad() {
- super.viewDidLoad()
- title = "First"
- UserInfo.bindAndFireNameListener { name in
- self.nameButton.setTitle(name, forState: .Normal)
- }
- }
- }
我們刪除了通知相關(guān)的代碼和 updateUI 方法,只需要將我們更新 UI 的閉包綁定到 UserInfo 即可。因?yàn)槲覀円残枰跏荚O(shè)置 button,所以用了 bindAndFireNameListener。
SecondViewController 和 ThirdViewController 的修改類(lèi)似 FirstViewController,不贅述。
這樣一來(lái),設(shè)置 UI 的操作和更新 UI 的操作就被很好地“融合”到一起了。代碼比第一版的的邏輯性更強(qiáng),VC 也更簡(jiǎn)單。
但是還有一個(gè)問(wèn)題, UserInfo 里的 nameListeners 數(shù)組可能會(huì)越來(lái)越長(zhǎng),比如用戶(hù)不斷地 push/pop。雖然在有限的時(shí)間里,nameListeners 的數(shù)量不會(huì)變的非常大,程序的性能可以接受,但這畢竟是一種浪費(fèi)(內(nèi)存和 CPU 時(shí)間)。我們?cè)賮?lái)解決這個(gè)問(wèn)題。
問(wèn)題關(guān)鍵是我們的閉包并沒(méi)有名字,我們無(wú)法將其找出并刪除。例如對(duì)于 SecondViewController 來(lái)說(shuō),第一次進(jìn)入它時(shí),bindAndFireNameListener 執(zhí)行了一次,如果 pop 再 push,它又執(zhí)行了一次。那么,第一次被綁定的閉包其實(shí)沒(méi)有任何用處了,因?yàn)榈诙慰吹降?VC 是新生成的。如果我們能為閉包取名字,我們就能在第二次進(jìn)入時(shí)用新的閉包替換舊的閉包,從而保證 nameListeners 的數(shù)量不會(huì)無(wú)限制的增長(zhǎng),也就不會(huì)浪費(fèi)內(nèi)存和 CPU 了。
為了限制 nameListeners 的無(wú)限制增長(zhǎng),我們可以將 nameListeners 改成 nameListenerSet,類(lèi)型從 Array 改成 Set,這樣綁定時(shí)就能保證其中“同一個(gè)地方添加的閉包”最多只有一個(gè)。但很不幸,我們無(wú)法將閉包 NameListener 放入 Set,因?yàn)殚]包無(wú)法實(shí)現(xiàn) Hashable 協(xié)議,而這正是使用 Set 所需要的。
似乎陷入困境了!
不要恐慌。雖然一個(gè)單純的閉包無(wú)法實(shí)現(xiàn) Hashable,但我們可以將其再封裝一次,例如放入一個(gè) struct 里,我們?cè)僮?struct 實(shí)現(xiàn) Hashable 協(xié)議。前面剛提到過(guò),閉包無(wú)法實(shí)現(xiàn) Hashable,那么我們必然要在 struct 放入另外一個(gè)可以 Hashable 的屬性來(lái)幫助我們的 struct 實(shí)現(xiàn) Hashable。也就是:為閉包取一個(gè)名字。因此,我們新的 UserInfo 如下:
- func ==(lhs: UserInfo.NameListener, rhs: UserInfo.NameListener) -> Bool {
- return lhs.name == rhs.name
- }
- class UserInfo {
- static let sharedInstance = UserInfo()
- struct NameListener: Hashable {
- let name: String
- typealias Action = String -> Void
- let action: Action
- var hashValue: Int {
- return name.hashValue
- }
- }
- var nameListenerSet = Set<NameListener>()
- class func bindNameListener(name: String, action: NameListener.Action) {
- let nameListener = NameListener(name: name, action: action)
- self.sharedInstance.nameListenerSet.insert(nameListener) // TODO:需要處理同名替換
- }
- class func bindAndFireNameListener(name: String, action: NameListener.Action) {
- bindNameListener(name, action: action)
- action(self.sharedInstance.name)
- }
- var name: String = "NIX" {
- didSet {
- for nameListener in nameListenerSet {
- nameListener.action(name)
- }
- }
- }
- }
我們?cè)O(shè)計(jì)了一個(gè)新的 struct:NameListener,它有一個(gè) name 表明它是誰(shuí),原來(lái)的閉包就變成了 action,也很合理。為了滿(mǎn)足 Hashable 協(xié)議,我們用 name.hashValue 來(lái)作為 struct 的 hashValue。另外,因?yàn)?Hashable 繼承于 Equatable,我們也要實(shí)現(xiàn)一個(gè) func ==。
另外,為了 API 更好使用,我們將 bindNameListener 與 bindAndFireNameListener 改造為接受一個(gè) name 和一個(gè) action 作為參數(shù),在方法內(nèi)部才“合成”一個(gè) nameListener,這樣 API 在使用時(shí)看起來(lái)會(huì)更合理,如下:
- UserInfo.bindAndFireNameListener("FirstViewController.nameButton") { name in
- self.nameButton.setTitle(name, forState: .Normal)
- }
我們只在閉包前面增加了一個(gè)閉包的“名字”而已。
最后,UserInfo 的 name 的 didSet 里要稍微修改,因?yàn)槭?Set,沒(méi)法 map 了,那就改成最傳統(tǒng)的循環(huán)吧。
小結(jié)
我們面臨一個(gè)“一處修改,多處更新”的問(wèn)題,起初時(shí)我們用通知來(lái)實(shí)現(xiàn),并無(wú)不可。之后我們想要更合理(或者更酷)一些,于是利用 Swift 的閉包特性實(shí)現(xiàn)了一個(gè)監(jiān)聽(tīng)者模式。最后,我們使用包裝的辦法,解決了監(jiān)聽(tīng)者可能會(huì)無(wú)限制增長(zhǎng)的問(wèn)題。
而這一切的目的,都是為了讓代碼更有邏輯性,并減少 VC 的代碼量。
最后的最后,UserInfo 里可能會(huì)包含其他類(lèi)型的屬性,例如 var hairColor: UIColor,如果它也面臨“一處修改,多處更新”的問(wèn)題,那么我們也需要實(shí)現(xiàn)一個(gè) HairColorListener 嗎?
也許我們?cè)摾?Swift 的泛型編寫(xiě)一個(gè)更加合理的 Listener,你說(shuō)對(duì)吧?
非最終的效果請(qǐng)查看并運(yùn)行 Demo 代碼:[1]。如果你愿意的話,可以查看 git 的各個(gè) commit 以得到整個(gè)過(guò)程。
(最終的)更好的泛型實(shí)現(xiàn)在分支 generic[2] 里,它的關(guān)鍵就是利用泛型實(shí)現(xiàn)一個(gè) class Listenable
參考資料
[1]運(yùn)行 Demo 代碼: https://github.com/nixzhu/PropertyListenerDemo
[2]generic: https://github.com/nixzhu/PropertyListenerDemo/tree/generic
本文轉(zhuǎn)載自微信公眾號(hào)「Swift社區(qū)」