The content of this article is also some thoughts on app architecture. If you are interested in architecture topics, we have also written a functional View Controller using reducer’s one-way data flow for reference.

How to avoid writing a Model View Controller as a Massive View Controller is a cliche. Whether it’s splitting View Controller functionality (using multiple Child View Controllers), switching to a “generalized” MVC framework (like MVVM or VIPER), or being more radical, The Reactive mode or Reducer mode is used. In fact, the essence of the problem we want to solve is how to manage the data flow of “user operations, model changes, AND UI feedback” more clearly. The original | address

Non-traditional MVC’s can help us follow some of the less error-prone programming paradigms (much like Java, which uses a jumbled pattern to regulate development so that newcomers can write “mature” code), but without a fundamental understanding of the role of data flow in MVC, it’s a dead end. Sooner or later something will go wrong.

example

Let’s take a very simple View Controller example. If we had a Table View Controller To record the To Do list, we could Swipe cell To Swipe an entry by clicking the plus button in the navigation bar. We want to have at most 10 backlog projects at a time. The code for this View Controller is very simple, and probably something many developers write every day. There are only 60 lines, including setting up the Playground and adding buttons. I put it into this GIST, and you can copy it all and throw it on the Playground to see what it looks like.

Here’s a quick explanation of some of the more critical code. The first is the model definition:

// define simple ToDo Model struct ToDoItem {let id: UUID let title: String init(title: String) { self.id = UUID() self.title = title } }Copy the code

We then use a subclass of UITableViewController to display and add to-do items:

Class ToDoListViewController: UITableViewController {// Save ToDoListViewController: [ToDoItem] = [] // Click add button @objc func addButtonPressed(_ sender: Any) {let newCount = items.count + 1 let title = "ToDo Item (newCount)" // Update 'items' items.append(.init(title: Let indexPath = indexPath (row: newcount-1, section: 0) tableView.insertrows (at: [indexPath], with:.automatic) // Determine whether the list limit is reached, if so, disable addButton if newCount >= 10 {addButton? .isEnabled = false } } }Copy the code

Next, deal with the table View presentation, which is boring:

extension ToDoListViewController {
    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return items.count
    }
    
    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier, for: indexPath)
        cell.textLabel?.text = items[indexPath.row].title
        return cell
    }
}
Copy the code

Finally, realize the function of sliding cell deletion:

extension ToDoListViewController { override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { let deleteAction = UIContextualAction(style: .destructive, title: "Delete") {_, _, done in // The user confirms the deletion, removes the item from 'items' self.items. Remove (at: IndexPath. Row) / / remove rows from the table view the self. The tableView. DeleteRows (at: [indexPath], with: If self.items.count < 10 {self.addButton?. IsEnabled = true} done(true)} return UISwipeActionsConfiguration(actions: [deleteAction]) } }Copy the code

The effect is as follows:

Everything seems to be working fine! But can you see what the problem is? Or do you think the code is flawless?

risk

In a nutshell, this is already a misuse of MVC. The code above has these potential problems:

  1. The Model layer is “parasitic” in the ViewController

    In this code, the items in the View Controller act as the Model.

    This leads to several problems: It is difficult to maintain or synchronize the status of items from the outside. Adding and removing items is “bound” to this View Controller. If you want to maintain to-do lists through other View Controllers, You have to consider data synchronization (we’ll see a few specific examples of this later); In addition, this setting makes items difficult to test. You can hardly test the Model layer for adding/removing/modifying to-do lists.

  2. Violation of data flow rules and single responsibility rules

    If we think about it, here’s what happens in the View Controller when the user clicks the Add button, or swipes to remove the cell:

    1. Maintaining the Model (i.eitems)
    2. Add or delete table view cells
    3. maintenanceaddButtonAvailable state of

    That is, UI operations not only cause changes to the Model, but also to the UI. Ideally, the data flow should be one-way: UI action -> Model update via View Controller -> New model update VIA View Controller -> Wait for new UI action, and in the example, We became “model updates and UI operations via View Controller”. While this may seem like a trivial change, it can cause problems when the project is complex.

Maybe you don’t see a problem right now, but let’s imagine some scenarios and you can think about how to implement them.

The scene of a

Looking at editing to-do items first, we might need a detail page to edit a to-do item, such as adding attributes like Date, location, and detail to the ToDoItem. Alternatively, PMS and users may wish to delete the to-do being edited directly from the details page as well.

In current implementations, the naive idea is to create a New ToDoEditViewController and set a delegate to tell the ToDoListViewController that a ToDoItem has changed, Then in the ToDoListViewController you can manipulate items:

protocol ToDoEditViewControllerDelegate: class { func editViewController(_ viewController: ToDoEditViewController, editToDoItem original: ToDoItem, to new: ToDoItem) func editViewController(_ viewController: ToDoListViewController, remove item: ToDoEditViewController)} ToDoEditViewControllerDelegate { func editViewController(_ viewController: ToDoEditViewController, remove item: ToDoItem) { guard let index = (items.index { $0.id == item.id }) else { return } items.remove(at: index) let indexPath = IndexPath(row: index, section: 0) tableView.deleteRows(at: [indexPath], with: .automatic) if self.items.count < 10 { self.addButton?.isEnabled = true } } func editViewController(_ viewController: ToDoEditViewController, editToDoItem original: ToDoItem, to new: ToDoItem) { //... }}Copy the code

Some of the code is identical to the previous one, although it can be extracted as removeItem(at index: Int), but it cannot change the problem of non-single function. The ToDoEditViewController itself can’t communicate with items, so its role is almost a “dedicated” View, and once it’s out of the ToDoListViewController, it’s “awkward.”

Scenario 2

Plus, standalone apps are out of date. Whether it’s iCloud syncing or a home-built server, we’ll always want a back end to save lists for users across devices, which is a very likely enhancement. Under the existing architecture, it is also natural to put the logic of getting existing entries from the server into the ToDoListViewController:

override func viewDidLoad() { super.viewDidLoad() //.. NetworkService.getExistingToDoItems().then { items in self.items = items self.tableView.reloadData() if self.items.count  >= 10 { self.addButton? .isEnabled = false } } }Copy the code

This simple implementation presents a number of challenges that we have to consider in a real app:

  1. Is it supposed to be ingetExistingToDoItemsBlock the UI during the process, otherwise the items added by the user before the request completes will be overwritten.
  2. We need to make network requests when adding and removing items, and we also need to add the status of the buttons based on the status updates returned by the request.
  3. Block user input will cause the app to become disconnected and unusable. Without Block, data synchronization needs to be considered.
  4. In addition, do we need to allow users to add or delete, and cache operations when there is no Internet, and then reflect these caches to the server when there is Internet.
  5. If you need to implement 4, you also need to consider error handling that results in exceeding the maximum number of entries, as well as data conflict handling across multiple devices.

Do you suddenly feel a little head?

To improve the

These problems are caused by the fact that we chose a less efficient Model and a risky way to flow data in order to “save trouble”. Or, we are not using the MVC architecture properly and rigorously.

About MVC, CS193p Paul at Stanford has a very classic diagram, which I believe many iOS developers have seen:

In our example, we put the Model into the Controller, and the Model does not communicate effectively with the Controller (Notification & KVO in the figure). This results in too much functionality for the Controller, which is often a glorious first step towards a Massive View Controller.

A separate Model

The immediate task is to extract the Model layer. For simplicity’s sake, let’s consider purely local cases for now:

extension ToDoItem: Equatable {
    public static func == (lhs: ToDoItem, rhs: ToDoItem) -> Bool {
        return lhs.id == rhs.id
    }
}

class ToDoStore {
    static let shared = ToDoStore()
    
    private(set) var items: [ToDoItem] = []
    private init() {}
    
    func append(item: ToDoItem) {
        items.append(item)
    }
    
    func append(newItems: [ToDoItem]) {
        items.append(contentsOf: newItems)
    }
    
    func remove(item: ToDoItem) {
        guard let index = items.index(of: item) else { return }
        remove(at: index)
    }
    
    func remove(at index: Int) {
        items.remove(at: index)
    }
    
    func edit(original: ToDoItem, new: ToDoItem) {
        guard let index = items.index(of: original) else { return }
        items[index] = new
    }
    
    var count: Int {
        return items.count
    }
    
    func item(at index: Int) -> ToDoItem {
        return items[index]
    }
}
Copy the code

We can also add NetworkService to the asynchronous API, for example:

func getAll() -> Promise<[ToDoItem]> {
    return NetworkService.getExistingToDoItems()
      .then { items in
          self.items = items
          return Promise.value(items)
      }
}

func append(item: ToDoItem) -> Promise<Void> {
    return NetworkService.appendToDoItem(item: item)
      .then {
          self.items.append(item)
          return Promise.value(())
      }
}
Copy the code

PromiseKit is used for good looks, but if you’re not familiar with promises, don’t worry, just think of them as a closure, and continue reading:

func getAll(completion: @escaping (([ToDoItem]? , Error?) -> Void)?) { NetworkService.getExistingToDoItems { response, error in if let error = error { completion? (nil, error) } else { self.items = response.items completion? (response.items, nil) } } }Copy the code

This way, we can take items out of the ToDoListViewController. It’s much easier to test separately extracted Models, pure Model operations independent of the Controller, and the ToDoEditViewController no longer needs to delegate its behavior back to the ToDoListViewController, The ViewController that edits the entries can be made a ViewController in the true sense of the word, not just a “member View” of the ToDoListViewController.

Another benefit of the separate ToDoStore as a model is that because it is separated from the specific View Controller, we have more options when it comes to persistence. These operations do not have to be (and should not be written in the View Controller), whether retrieved from the network or stored in a local database. If we have multiple data sources, we can easily create a type like ToDoStoreCoordinator or ToDoStoreDataProvider. Can satisfy a single responsibility, but also easy to cover full tests.

Unidirectional data flow

Next, it’s natural to tease out the data flow according to MVC standards. Our goal is to avoid UI behavior directly affecting the UI, and instead determine UI state by Model state via Controller. This requires our Model to be able to report back to the Controller in some “indirect” way. Following the MVC diagram above, we use Notification to do this.

Some changes to ToDoStore:

class ToDoStore {
    enum ChangeBehavior {
        case add([Int])
        case remove([Int])
        case reload
    }
    
    static func diff(original: [ToDoItem], now: [ToDoItem]) -> ChangeBehavior {
        let originalSet = Set(original)
        let nowSet = Set(now)
        
        if originalSet.isSubset(of: nowSet) { // Appended
            let added = nowSet.subtracting(originalSet)
            let indexes = added.compactMap { now.index(of: $0) }
            return .add(indexes)
        } else if (nowSet.isSubset(of: originalSet)) { // Removed
            let removed = originalSet.subtracting(nowSet)
            let indexes = removed.compactMap { original.index(of: $0) }
            return .remove(indexes)
        } else { // Both appended and removed
            return .reload
        }
    }
    
    private var items: [ToDoItem] = [] {
        didSet {
            let behavior = ToDoStore.diff(original: oldValue, now: items)
            NotificationCenter.default.post(
                name: .toDoStoreDidChangedNotification,
                object: self,
                typedUserInfo: [.toDoStoreDidChangedChangeBehaviorKey: behavior]
            )
        }
    }
    
    // ...
}
Copy the code

ChangeBehavior is added as a “hint” to tell the outside world exactly what is happening in the Model. The diff method determines which ChangeBehavior occurred by comparing the original items with the current items. Finally, use items’ didSet to send the Notification.

Since Swift’s array is of value type, additions, deletions, changes, or overall variable replacements of items trigger a didSet call. Swift’s value semantics programming brings great convenience.

In ToDoListViewController, all you need to do now is subscribe to this notification and do UI feedback based on the message content:

class ToDoListViewController: UITableViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        //...
        NotificationCenter.default.addObserver(
            self, 
            selector: #selector(todoItemsDidChange),
            name: .toDoStoreDidChangedNotification, 
            object: nil)
    }
    
    private func syncTableView(for behavior: ToDoStore.ChangeBehavior) {
        switch behavior {
        case .add(let indexes):
            let indexPathes = indexes.map { IndexPath(row: $0, section: 0) }
            tableView.insertRows(at: indexPathes, with: .automatic)
        case .remove(let indexes):
            let indexPathes = indexes.map { IndexPath(row: $0, section: 0) }
            tableView.deleteRows(at: indexPathes, with: .automatic)
        case .reload:
            tableView.reloadData()
        }
    }
    
    private func updateAddButtonState() {
        addButton?.isEnabled = ToDoStore.shared.count < 10
    }

    @objc func todoItemsDidChange(_ notification: Notification) {
        let behavior = notification.getUserInfo(for: .toDoStoreDidChangedChangeBehaviorKey)
        syncTableView(for: behavior)
        updateAddButtonState()
    }
}
Copy the code

Notification itself has a long history and is a loose set of string-based apis. Here, by means of extension and generic by. ToDoStoreDidChangedNotification, ToDoStoreDidChangedChangeBehaviorKey and post (name: object: typedUserInfo) and getUserInfo (for) constitute a set of more Swifty type safety How to use NotificationCenter and userInfo. See the final code if you’re interested.

Finally, we can remove all the code used to maintain the table View Cell and addButton state. The only effect of user manipulation on the UI is to trigger model updates that refresh the UI via notifications:

Class ToDoListViewController: UITableViewController {// Save the current to-do list // var items: [ToDoItem] = [] // Click add button @objc func addButtonPressed(_ sender: Any) {// let newCount = items.count + 1 // let title = "ToDo Item (newCount)" // Update 'items' // Items.append (.init(title: title)) // Add a new row to the table view // let indexPath = indexPath (row: newcount-1, section: 0) // tableView.insertRows(at: [indexPath], with: .automatic) // Determine whether the list limit is reached, if so, disable addButton // if newCount >= 10 {// addButton? .isEnabled = false // } let store = ToDoStore.shared let newCount = store.count + 1 let title = "ToDo Item (newCount)" store.append(item: .init(title: title)) } } extension ToDoListViewController { override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { let deleteAction = UIContextualAction(style: .destructive, title: "Delete") {_, _, done in // User confirm Delete, remove this item from 'items' // self.items. IndexPath. Row) / / remove rows from the table view. / / the self tableView. DeleteRows (at: [indexPath], with: If self.items. Count < 10 {// self.addButton?. IsEnabled = true //} ToDoStore.shared.remove(at: indexPath.row) done(true) } return UISwipeActionsConfiguration(actions: [deleteAction]) } }Copy the code

Now, consider the requirements for scenario 1 (editing entries) and Scenario 2 (network synchronization) from the previous section. Is the structure much clearer?

  1. We now have a single Model layer that we can test, and with simple mocks,ToDoListViewControllerIt can also be easily tested.
  2. UI operations -> model changes through the Controller -> “maps” the current model to the UI state through the Controller, and this data flow direction is strictly predictable (and should always be kept in mind to keep this loop going). This greatly reduces the burden on the Controller layer.
  3. Since the model layer is no longer held by a single View Controller, other controllers (not just View controllers like the Edit View Controller used for editing), This includes data controllers such as download controllers, etc.) that can also manipulate the model layer. At the same time, all model results are automatically and correctly reflected to the View, which provides a solid foundation for multi-Controller collaboration and more complex scenarios.

A modified final version of this example can be found here.

The other options

The concept of MVC itself is fairly simple, but it also gives developers a lot of freedom. Massive View controllers often take advantage of this freedom and place logic “arbitrarily” on the Controller layer.

There are other architectural options, most commonly MVVM and responsive programming (such as RxSwift). MVVM is almost an MVC, but binds the data to the View through the View Model layer. If you’ve written about Reactive architecture, you may find that the notification reception in the MVC Controller layer in this article is very similar to the event flow in Rx. The difference is that reactive programming “borrows” MVVM ideas and provides a set of apis to bind event flows to UI state (RxCocoa).

These architectural approaches that “go beyond” MVC invariably add additional rules and restrictions, providing less freedom than MVC. This can go some way to standardizing developer behavior and providing more uniform code (at the cost of additional learning costs, of course). With a full understanding and strict adherence to the idea of MVC, we can actually use MVC as “small and beautiful.” The first step is to avoid this common “mistake” in the text

Being able to build complex projects using simple architectures, making software that other developers can easily understand, avoiding high follow-up maintenance costs, and making software sustainable and active for a long time should be something every developer must consider when building software.

Recommended: iOS technical documents