Implementation of lightweight attribute monitoring system with Swift

preface

The main purpose of this paper is to solve the problem of "one modification of the model and multiple updates of the UI" in client development. Of course, we should know the details and thinking process of the solution and see the effect it can achieve. We will use the idea of functional programming and the great "generics". Believe me, we don't use new technology for the purpose of using new technology. If there is a better way to solve a problem, why not replace the old method?

text

If the App you are writing has a user system, that is, users need to manage their own information, such as modifying names and hair colors.

Take the name alone. In addition to modifying the interface, it may also be used in other interfaces of the system, which involves updating other interfaces after updating the name.

What is your first instinct? Most of them use notification, that is, NSNotification. This is a good way, although the logic is loose and it is a little troublesome to write. For example, you need to define a notification name, send notifications, listen for notifications on all interfaces, and then process them.

For example, the following three interfaces have display names. Through push, the user can modify the name in the third interface, which requires updating the names of the three interfaces, otherwise the user will feel strange when pop returns.

If our name is placed in a class called UserInfo (single example is used for access and modification), it is as follows:

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

At the same time, we define a notification. This notification is issued after the name is changed and the name is sent out.

The three interfaces are FirstViewController, SecondViewController and ThirdViewController, with a button in the middle. The first two are responsible for push ing, and the last one can change its name after clicking. Therefore, for 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)
        }
    }
}

In addition to setting the button when loading, we also listen for notifications and update the title of the button when the name is changed.

The code of SecondViewController is similar to FirstViewController and will not be repeated.

For ThirdViewController, in addition to setting and notification, there is also a button target action method for modifying the name, which is also very simple:

@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 // Update name
        }
    }
    alertController.addAction(action)

    self.presentViewController(alertController, animated: true, completion: nil)
}

It doesn't seem troublesome and seems reasonable. What's the problem with it? I think the answer is too repetitive. In order to reduce repetition, let's increase our knowledge and make the brain nerve a little painful, so as to form some new connections or destroy some old connections.

We can pass closures to UserInfo, which stores closures and calls them when the name is changed, so that the operations in the closures will be executed. Naturally, we need to update the UI in the closure.

Thus, the new UserInfo is as follows:

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

We deleted the code related to notification, defined NameListener, added a NameListener to save listener closures, and implemented two class methods bindNameListener and bindAndFireNameListener to save (and trigger) listener closures. In name's didSet, we only need to call each closure. Here we use map, which is also very intuitive.

Then the code of FirstViewController is simplified to:

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

We deleted the code related to the notification and the updateUI method. We just need to bind the closure of our update UI to UserInfo. Because we also need to set the button initially, we use bindAndFireNameListener.

The modifications of SecondViewController and ThirdViewController are similar to FirstViewController and will not be repeated.

In this way, the operations of setting UI and updating UI are well "integrated". The code is more logical than the first version, and VC is also simpler.

But there is another problem. The nameListeners array in UserInfo may be longer and longer. For example, users keep pushing / pop. Although the number of nameListeners will not become very large in a limited time, and the performance of the program is acceptable, it is a waste of memory and CPU time after all. Let's solve this problem again.

The key problem is that our closure has no name and we can't find it and delete it. For example, for the SecondViewController, the bindAndFireNameListener executes once the first time it enters it. If pop push es again, it executes again. Then, the closure bound for the first time is actually useless, because the VC seen for the second time is newly generated. If we can name the closure, we can replace the old closure with the new closure on the second entry, so as to ensure that the number of nameListeners will not increase indefinitely and will not waste memory and CPU.

In order to limit the unlimited growth of nameListeners, we can change nameListeners to nameListenerSet, and the type from Array to Set, so that when binding, we can ensure that there is at most one "closure added in the same place". Unfortunately, we can't put the closure NameListener into Set because closures can't implement the Hashable protocol, which is what is needed to use Set.

Seems to be in trouble!

Don't panic. Although a simple closure cannot implement Hashable, we can encapsulate it again, for example, put it into a struct, and then let struct implement Hashable protocol. As mentioned earlier, closures cannot implement Hashable, so we must put another Hashable attribute in struct to help our struct implement Hashable. That is: give a name to the closure. Therefore, our new UserInfo is as follows:

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: replacement with the same name needs to be processed
    }

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

We have designed a new struct: NameListener. It has a name to indicate who it is. The original closure becomes action, which is also very reasonable. In order to meet the Hashable protocol, we use name.hashValue as the hashValue of struct. In addition, because Hashable inherits from Equatable, we also need to implement a func = =.

In addition, in order to better use the API, we transform bindNameListener and bindAndFireNameListener to accept a name and an action as parameters and "synthesize" a nameListener inside the method, so that the API will look more reasonable when used, as follows:

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

We only added a "name" of the closure in front of the closure.

Finally, the didSet of UserInfo's name needs to be slightly modified. Because it is a Set, there is no map, so change to the most traditional loop.

Summary

We are faced with the problem of "one modification, multiple updates". At first, we used notification to realize it. Then we wanted to be more reasonable (or cooler), so we implemented a listener pattern using Swift's closure feature. Finally, we use packaging to solve the problem of unlimited growth of listeners.

The purpose of all this is to make the code more logical and reduce the amount of VC code.

Finally, UserInfo may contain other types of attributes, such as var hairColor: UIColor. If it also faces the problem of "one modification and multiple updates", do we also need to implement a HairColorListener?

Maybe we should use Swift's generics to write a more reasonable Listener, right?

Non final effect, please review and run Demo code: [1]. If you like, you can check git's various commit s to get the whole process.

(the final) better generic implementation is in the branch generic[2]. The key is to use generics to implement a class listenable < T > corresponding to any type of attribute, and then implement the monitoring system internally. Of course, we also let listeners support generics (struct listener < T >) so that arbitrary types of parameters can be passed when executing action s. There are some differences in details. For example, it is more convenient to directly use the static variable in UserInfo, and there is no need to use a separate singleton to access its properties.

reference material

[1] Run Demo code: https://github.com/nixzhu/PropertyListenerDemo

[2]generic: https://github.com/nixzhu/PropertyListenerDemo/tree/generic

Posted on Fri, 26 Nov 2021 02:54:56 -0500 by Volitics