1. 程式人生 > >用 Swift 實現輕量的屬性監聽系統

用 Swift 實現輕量的屬性監聽系統

注:會不會有記憶體問題。block呼叫一個頁面的UI內容,並被另一個頁面儲存住。需要注意弱引用,有待測試。 本文的主要目的是解決客戶端開發中對“模型的一處修改,UI 要多處更新”的問題。當然,我們要知曉解決方案的細節和思考過程,以及看到其能達到的效果。我們會用到函數語言程式設計的思想,以及偉大的“泛型”。請相信我,我們並非為了使用新技術而使用新技術。如果一個問題有更好的方法去解決,那為何不替換掉舊方法呢?

假如你正在寫的 App 是有使用者系統的,也就是使用者需要管理自己的資訊,如修改名字、頭髮顏色之類的。

單獨拿名字來說,除開在修改介面,可能在系統的其他介面也會使用到它,這就涉及到在更新名字後再更新其他介面的問題。

你的第一直覺是什麼呢?多半是使用通知,也就是 NSNotification。這是一種很好的辦法,雖然邏輯鬆散,寫起來有些麻煩。比如要定義一個通知名,傳送通知,各介面都監聽通知再處理,等等。

例如,對於如下 3 個介面,都有顯示名字。通過 push,使用者可以在第 3 個介面裡修改名字,這就需要更新這 3 個介面的名字,不然使用者 pop 返回時就會覺得奇怪。


假如我們的名字放在一個叫做 UserInfo 的類裡(訪問和修改都使用單例),如下:

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

同時我們定義了一個通知。在 name 被改變後就發出這個通知,並把 name 傳出去。

三個介面分別為 FirstViewController、SecondViewController、ThirdViewController,都有一個 button 在正中間。其中前兩個負責 push,最後一個點選後可以改名字。因此,對於 FirstViewController 來說:

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

除了載入時設定 button 之外,我們還要監聽通知,並在 name 被改變時更新 button 的 title。

SecondViewController 的程式碼類似 FirstViewController,不贅述。

對於 ThirdViewController,除了設定和通知外,還有一個 button 的 target-action 方法用於修改名字,也很簡單:

@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)
}

似乎並不麻煩,看起來也算合理,那上面這樣寫有什麼問題?我想答案是太重複。為了減少重複,我們來增加自己的知識,讓腦神經稍微痛苦一點,好形成一些新的聯結或破壞一些舊的聯結。

我們可以傳遞閉包給 UserInfo,它將閉包儲存起來,並在 name 被改變時呼叫這些閉包,這樣閉包裡的操作就會被執行了。自然,我們要在閉包裡更新 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) }
                }
        }
}

我們刪除了通知相關的程式碼,定義了 NameListener,增加了一個 nameListeners 用於儲存監聽者閉包,並實現兩個類方法 bindNameListener 和 bindAndFireNameListener 來儲存(並觸發)監聽者閉包。而在 name 的 didSet 裡,我們只需要呼叫每個閉包即可,這裡用了 map,也很直觀。 

那麼 FirstViewController 的程式碼就簡化為:

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

我們刪除了通知相關的程式碼和 updateUI 方法,只需要將我們更新 UI 的閉包繫結到 UserInfo 即可。因為我們也需要初始設定 button,所以用了 bindAndFireNameListener 。 

SecondViewController 和 ThirdViewController 的修改類似 FirstViewController,不贅述。

這樣一來,設定 UI 的操作和更新 UI 的操作就被很好地“融合”到一起了。程式碼比第一版的的邏輯性更強,VC 也更簡單。

但是還有一個問題, UserInfo 裡的 nameListeners 陣列可能會越來越長,比如使用者不斷地 push/pop。雖然在有限的時間裡,nameListeners 的數量不會變的非常大,程式的效能可以接受,但這畢竟是一種浪費(記憶體和CPU時間)。我們再來解決這個問題。

問題關鍵是我們的閉包並沒有名字,我們無法將其找出並刪除。例如對於 SecondViewController 來說,第一次進入它時,bindAndFireNameListener 執行了一次,如果 pop 再 push,它又執行了一次。那麼,第一次被繫結的閉包其實沒有任何用處了,因為第二次看到的 VC 是新生成的。如果我們能為閉包取名字,我們就能在第二次進入時用新的閉包替換舊的閉包,從而保證 nameListeners 的數量不會無限制的增長,也就不會浪費記憶體和 CPU 了。

為了限制 nameListeners 的無限制增長,我們可以將 nameListeners 改成 nameListenerSet,型別從 Array 改成 Set,這樣繫結時就能保證其中“同一個地方新增的閉包”最多隻有一個。但很不幸,我們無法將閉包 NameListener 放入 Set,因為閉包無法實現 Hashable 協議,而這正是使用 Set 所需要的。

似乎陷入困境了!

不要恐慌。雖然一個單純的閉包無法實現 Hashable,但我們可以將其再封裝一次,例如放入一個 struct 裡,我們再讓 struct 實現 Hashable 協議。前面剛提到過,閉包無法實現 Hashable,那麼我們必然要在 struct 放入另外一個可以 Hashable 的屬性來幫助我們的 struct 實現 Hashable。也就是:為閉包取一個名字。因此,我們新的 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)
        }
        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)
                        }
                }
        }
}

我們設計了一個新的 struct:NameListener,它有一個 name 表明它是誰,原來的閉包就變成了 action,也很合理。為了滿足 Hashable 協議,我們用 name.hashValue 來作為 struct 的 hashValue。另外,因為 Hashable 繼承於 Equatable,我們也要實現一個 func == 。 

另外,為了 API 更好使用,我們將 bindNameListener 與 bindAndFireNameListener 改造為接受一個 name 和一個 action 作為引數,在方法內部才“合成”一個 nameListener,這樣 API 在使用時看起來會更合理,如下: 

UserInfo.bindAndFireNameListener("FirstViewController.nameButton") { name in
    self.nameButton.setTitle(name, forState: .Normal)
}

我們只在閉包前面增加了一個閉包的“名字”而已。

最後,UserInfo 的 name 的 didSet 裡要稍微修改,因為是 Set,沒法 map 了,那就改成最傳統的迴圈吧。

我們面臨一個“一處修改,多處更新”的問題,起初時我們用通知來實現,並無不可。之後我們想要更合理(或者更酷)一些,於是利用 Swift 的閉包特性實現了一個監聽者模式。最後,我們使用包裝的辦法,解決了監聽者可能會無限制增長的問題。

而這一切的目的,都是為了讓程式碼更有邏輯性,並減少 VC 的程式碼量。

最後的最後,UserInfo 裡可能會包含其他型別的屬性,例如 var hairColor: UIColor ,如果它也面臨“一處修改,多處更新”的問題,那麼我們也需要實現一個 HairColorListener 嗎? 

也許我們該利用 Swift 的泛型編寫一個更加合理的 Listener,你說對吧?

(最終的)更好的泛型實現在分支generic 裡,它的關鍵就是利用泛型實現一個 class Listenable<T> 以對應任何型別的屬性,它內部再實現監聽系統即可。當然,我們也讓監聽者支援泛型( struct Listener<T> )以便執行 action 時可以傳遞任意型別的引數。還有少許細節不同,例如 UserInfo 裡直接使用 static 變數更方便,不需要用一個單獨的單例再訪問其屬性。