View Controller has always been the most troublesome part of MVC (Model-view-View Controller). MVC architecture itself is not complicated. But it’s easy to throw a lot of code into the Controller that coordinates the View and Model. You can’t call this a mistake, because the View Controller takes care of the glue code and business logic. However, continuing to do this will inevitably result in the Model View Controller turning into a Massive View Controller, and the code will rot until no one can touch it. The original | address

The biggest challenge for a project with an MVC architecture is maintaining controllers. The biggest challenge of having a well-maintained Controller is maintaining good test coverage. Because View Controllers tend to have a lot of state and a lot of asynchronous operations and user-triggered events, testing a Controller is never easy.

This is also true for some other similar architectures. MVVM or VIPER, for example, are MVC in a broad sense, but use a View Model or Presenter as a Controller. Their corresponding controllers are still responsible for coordinating the Model and View.

In this article, I will first implement a very common MVC architecture, then abstract and refactor the state and the parts where the state changes, and end up with a purely functional and easily testable View Controller class. I hope this example can give you some inspiration or help in the daily maintenance of View Controller.

Some of the methods in this article may be familiar to you if you know anything about React and Redux. But even if you don’t know them, that doesn’t prevent you from understanding this article. I’m not going to go into the conceptual stuff, but I’m going to start with an example that everyone knows, so don’t worry about it. You may need some familiarity with Swift, and this article covers some basic differences between value types and reference types.

I have the entire sample project on GitHub, and you can find the source code in each branch.

Traditional MVC implementation

Let’s use a classic ToDo application as an example. This project loads to-do items from the network, which we add by typing in text, or delete by clicking on the corresponding item:

Note a few details:

  1. Loading the existing to-do list after opening the app took some time. Normally, we would load it from a network request, which should be an asynchronous operation. In the sample project, we won’t actually make the network request, but use a local store to simulate the process.
  2. The number in the title bar represents the current backlog, which changes as the backlog increases or decreases.
  3. You can enter using the first cell and add a to-do with the plus sign in the upper right corner. We want the title of the to-do to be at least three characters long, and the Add button will not work if it is not.

This isn’t too hard to do, and a newcomer to iOS should be able to do it without any stress. Let’s start by implementing simulated asynchronous fetching of the existing backlog. Create a new file todoStore. swift:

import Foundation let dummy = [ "Buy the milk", "Take my dog", "Rent a car" ] struct ToDoStore { static let shared = ToDoStore() func getToDoItems(completionHandler: (([String]) -> Void)?) { DispatchQueue.main.asyncAfter(deadline: .now() + 2) { completionHandler? (dummy) } } }Copy the code

For simplicity, we use a simple String to represent a to-do. Here we wait two seconds before invoking a callback that returns a predefined set of to-do items.

Since the entire interface is a TableView, we create a UITableViewController subclass to implement the requirements. In TableViewController. Swift, we define an attribute todos to store need to display in the list of to-do lists, then load in viewDidLoad from ToDoStore and refresh tableView:

class TableViewController: UITableViewController {

    var todos: [String] = []
    
    override func viewDidLoad() {
        super.viewDidLoad()

        ToDoStore.shared.getToDoItems { (data) in
            self.todos += data
            self.title = "TODO - ((self.todos.count))"
            self.tableView.reloadData()
        }
    }
}
Copy the code

Of course, we now need to provide methods related to the UITableViewDataSource. First, our Table View has two sections, one for entering new to-do items and the other for displaying existing items. To make the code self-explanatory, I chose to embed a Section enumeration in the TableViewController:

class TableViewController: UITableViewController {
    enum Section: Int {
        case input = 0, todos, max
    }
    
    //...
}
Copy the code

Now we can implement the methods required by UITableViewDataSource:

class TableViewController: UITableViewController { override func numberOfSections(in tableView: UITableView) -> Int { return Section.max.rawValue } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { guard let section = Section(rawValue: section) else { fatalError() } switch section { case .input: return 1 case .todos: return todos.count case .max: fatalError() } } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { guard let section = Section(rawValue: IndexPath. Section) else {fatalError()} switch section {case.input: // Return input cell case.todos: / / return the todo item cell let cell = tableView. DequeueReusableCell (withIdentifier: todoCellReuseId, for: indexPath) cell.textLabel?.text = todos[indexPath.row] return cell default: fatalError() } } }Copy the code

In the todos case it’s easy, we’ll just use the standard UITableViewCell. For.input, we need to embed a UITextField in the cell and tell the TableViewController when the text in it changes. We can do this using the traditional delegate mode. Here’s the TableViewInputCell. Swift:

protocol TableViewInputCellDelegate: class { func inputChanged(cell: TableViewInputCell, text: String) } class TableViewInputCell: UITableViewCell { weak var delegate: TableViewInputCellDelegate? @IBOutlet weak var textField: UITextField! @objc @IBAction func textFieldValueChanged(_ sender: UITextField) { delegate? .inputChanged(cell: self, text: sender.text ?? "")}}Copy the code

We create the corresponding Table View and cell in our Storyboard, and bind the.EditingChanged event of the Text field inside to textFieldValueChanged. The delegate method is called every time the user makes an input.

In the TableViewController, you can now return the.input cell and set the corresponding proxy method to update the add button:

class TableViewController: UITableViewController { override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { guard let section = Section(rawValue: indexPath.section) else { fatalError() } switch section { case .input: let cell = tableView.dequeueReusableCell(withIdentifier: inputCellReuseId, for: indexPath) as! TableViewInputCell cell.delegate = self return cell //... } } } extension TableViewController: TableViewInputCellDelegate { func inputChanged(cell: TableViewInputCell, text: String) { let isItemLengthEnough = text.count >= 3 navigationItem.rightBarButtonItem? .isEnabled = isItemLengthEnough } }Copy the code

Now, after running the program and waiting for a while, the to-do items read in can be displayed. Next, adding and removing to-do sections is easy:

Class TableViewController: UITableViewController {IBAction func addButtonPressed(_ sender: Any) { let inputIndexPath = IndexPath(row: 0, section: Section.input.rawValue) guard let inputCell = tableView.cellForRow(at: inputIndexPath) as? TableViewInputCell, let text = inputCell.textField.text else { return } todos.insert(text, at: 0) inputCell.textField.text = "" title = "TODO - ((todos.count))" tableView.reloadData() tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { guard indexPath.section == Section.todos.rawValue else { return } todos.remove(at: indexPath.row) title = "TODO - ((todos.count))" tableView.reloadData() } }Copy the code

To keep things simple, we’re going to go directly to tableView.reloadData(). It’s probably better to just insert or remove the parts that change, but for simplicity’s sake, we’ll just reload the entire table view.

Okay, this is a very simple View Controller that’s less than a hundred lines long, and probably something we write every day, so we’re not going to tout it as “clean” or “clean and simple.” You and I both know that’s just an illusion when View Controllers were small. Let’s get straight to the potential problems:

  1. Ui-related code scattered all over the place – reloadtableViewAnd set uptitleThe code appears three times, set the top right button’sisEnabledThe code exists in extension. When adding a new project, we first get the input cell and then read the text in the cell. These scattered UI operations are a danger because you can manipulate them in any part of the code, and their state becomes “erratic” as the code gets more complex.
  2. The complex state of 1 makes it difficult to test the View Controller – for example, if you want to test ittitleWith the text correct, you may need to add a to-do list to the hand movement list, which involves callingaddButtonPressedAnd this method needs to readinputCellYou may also need to set the cell firstUITextField 的 textValue. Of course, you can also use dependency injectionaddMethod a text argument, or willtodos.insertAnd what follows is extracted as a new method, but in any case, there is no separation between working on the Model and updating the UI (because, after all, we are writing glue code). This is the main reason why you find View Controller difficult to test.
  3. Because 2 is difficult to test, it finally makes View Controller difficult to refactor – increasing state and UI complexity often results in multiple UI operations maintaining the same variable, or multiple state variables updating the same UI element. Either way, it makes testing nearly impossible, and it makes subsequent developers (often yourself!) It is difficult to proceed properly in the face of complexity. Massive View Controllers often end up being completely different, and even a small change can take a lot of time to verify, and no one can say for sure. This will bog down the project.

As a result, such a View Controller is difficult to scaling. By the time it fills up to a thousand or two thousand lines of code, the View Controller will be completely “dead” and difficult to maintain and change.

You can find the corresponding code in the Basic branch of the GitHub repo.

State-based View Controller

Unify UI operations by extracting State

The above three problems are really linked, and if we can centralize the UI-related code and manage it in a single state, we can make the View Controller much less complex. Let’s try it!

In this simple interface, uI-related models include todos for to-do items (to organize the table View and update the title bar) and text for input (to determine whether to add enable for buttons and what to add when todo is added). We simply encapsulate the two variables and add an embedded State structure to the TableViewController:

class TableViewController: UITableViewController {
    
    struct State {
        let todos: [String]
        let text: String
    }
    
    var state = State(todos: [], text: "")
}
Copy the code

This way, we have a unified place to update the UI by state. Use state’s didSet:

var state = State(todos: [], text: "") { didSet { if oldValue.todos ! = state.todos { tableView.reloadData() title = "TODO - ((state.todos.count))" } if (oldValue.text ! = state.text) { let isItemLengthEnough = state.text.count >= 3 navigationItem.rightBarButtonItem? .isEnabled = isItemLengthEnough let inputIndexPath = IndexPath(row: 0, section: Section.input.rawValue) let inputCell = tableView.cellForRow(at: inputIndexPath) as? TableViewInputCell inputCell? .textField.text = state.text } } }Copy the code

Here we compare the new value with the old value to avoid unnecessary UI updates. Now, we can change the UI that we used to do in the TableViewController to state.

For example, in viewDidLoad:

/ / change the former ToDoStore. Shared. GetToDoItems {(data) in the self. Todos + = data self. The title = "TODO - ((self. Todos. Count))" Self. TableView. ReloadData ()} / / change after ToDoStore. Shared. GetToDoItems {(data) in the self. State = state (todos: self.state.todos + data, text: self.state.text) }Copy the code

Click cell to remove to-do:

// Override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { guard indexPath.section == Section.todos.rawValue else { return } todos.remove(at: Indexpa.row) title = "TODO - ((todos.count))" tableview.reloadData ()} UITableView, didSelectRowAt indexPath: IndexPath) { guard indexPath.section == Section.todos.rawValue else { return } let newTodos = Array(state.todos[..<indexPath.row] + state.todos[(indexPath.row + 1)...] ) state = State(todos: newTodos, text: state.text) }Copy the code

When typing text in the input box:

Func inputChanged(cell: TableViewInputCell, text: String) { let isItemLengthEnough = text.count >= 3 navigationItem.rightBarButtonItem? . IsEnabled = isItemLengthEnough} // Func inputChanged(cell: TableViewInputCell, text: String) { state = State(todos: state.todos, text: text) }Copy the code

Also, perhaps most notable are the code changes when adding a backlog. As you can see, the code is much simpler and clearer with the introduction of uniform state changes:

@ibAction func addButtonPressed(_ sender: Any) {let inputIndexPath = IndexPath(row: 0, section: Section.input.rawValue) guard let inputCell = tableView.cellForRow(at: inputIndexPath) as? TableViewInputCell, let text = inputCell.textField.text else { return } todos.insert(text, at: 0) inputCell.textField.text = "" title = "TODO - ((todos.count))" tableView.reloadData() addButtonPressed(_ sender: Any) { state = State(todos: [state.text] + state.todos, text: "") }Copy the code

If you’re familiar with React, you can see some similar ideas here. React we pass Props from top to bottom and manage state in the Component itself through setState. All Components are based on the Props passed in and their own State. The difference in View Controller is that React updates the UI (virtual DOM) in a more descriptive way, whereas now we might need to implement it ourselves in a procedural language. In addition, the TableViewController that uses State works much like the React Component.

Test the State View Controller

In a state-based implementation, user actions are unified as State changes, and State changes uniformly update the current UI. This makes testing the View Controller a lot easier. We can separate out behavior that might otherwise be jumbled together: first, test state changes can result in the correct UI; The test user input can then result in the correct state change, which can override the View Controller test.

Let’s first test UI changes caused by state changes, in unit tests:

Func testSettingState () {/ / initial state XCTAssertEqual (controller. The tableView. NumberOfRows (inSection: TableViewController.Section.todos.rawValue), 0) XCTAssertEqual(controller.title, "TODO - (0)") XCTAssertFalse(controller.navigationItem.rightBarButtonItem! .isEnabled) // ([], "") -> (["1", "2", "3"], "abc") controller.state = TableViewController.State(todos: ["1", "2", "3"], text: "abc") XCTAssertEqual(controller.tableView.numberOfRows(inSection: TableViewController.Section.todos.rawValue), 3) XCTAssertEqual(controller.tableView.cellForRow(at: todoItemIndexPath(row: 1))? .textLabel? .text, "2") XCTAssertEqual(controller.title, "TODO - (3)") XCTAssertTrue(controller.navigationItem.rightBarButtonItem! .isEnabled) // (["1", "2", "3"], "abc") -> ([], "") controller.state = TableViewController.State(todos: [], text: "") XCTAssertEqual(controller.tableView.numberOfRows(inSection: TableViewController.Section.todos.rawValue), 0) XCTAssertEqual(controller.title, "TODO - (0)") XCTAssertFalse(controller.navigationItem.rightBarButtonItem! .isEnabled) }Copy the code

The initial state here is the UI that we set up in the Storyboard or the corresponding viewDidLoad or whatever. We’ll talk more about this state later.

Next, we can test the state change caused by the user’s interaction:

func testAdding() {
    let testItem = "Test Item"

    let originalTodos = controller.state.todos
    controller.state = TableViewController.State(todos: originalTodos, text: testItem)
    controller.addButtonPressed(self)
    XCTAssertEqual(controller.state.todos, [testItem] + originalTodos)
    XCTAssertEqual(controller.state.text, "")
}
    
func testRemoving() {
    controller.state = TableViewController.State(todos: ["1", "2", "3"], text: "")
    controller.tableView(controller.tableView, didSelectRowAt: todoItemIndexPath(row: 1))
    XCTAssertEqual(controller.state.todos, ["1", "3"])
}
    
func testInputChanged() {
    controller.inputChanged(cell: TableViewInputCell(), text: "Hello")
    XCTAssertEqual(controller.state.text, "Hello")
}
Copy the code

It looks great, our unit tests covered all kinds of user interactions, and with the UI changes caused by the state changes, we’re almost sure the View Controller will work as we want it to!

I’ve only posted a few key changes above, but you can refer to the State branch of the GitHub repo for configuration of the test and other details.

Problem with State View Controller

Although this state-based View Controller is much better than the original one, there are still some problems and a lot of room for improvement. Here are a few major concerns:

  1. The UI at initialization — we said above, the UI at initialization is what we have in our Storyboard or whateverviewDidLoadOr something like that. This leads to a problem, which is that we can’t pass the SettingsstateProperty to set the initial UI. becausestate 的 didSetWill not be called on the first assignment in controller initialization, so if we’re inviewDidLoadThe UI will not be updated because the new state is the same as the original state:
Override func viewDidLoad() {super.viewdidLoad () // UI updates will be skipped because the state is the same as the initial value state = state (todos: [], text: "")}Copy the code

This is fine if the initial UI is set up correctly. But if the STATE of the UI is wrong, then the rest of the UI will be wrong. At a higher level, the control of the STATE property over the UI not only depends on the new state, but also on the existing state value. This leads to some extra complexity that we want to avoid. Ideally, UI updates should be input dependent, not current state dependent (i.e., “purely functional”, which we’ll cover later).

  1. StateDifficult to scale – nowStateThere are only two variables intodos 和 textIf we need any other variables in the View Controller, we can continue to add them toStateIn the structure. But in practice it would be very difficult because we would need to update everythingstatePart of the assignment. For example, if we add aloadingTo indicate that a backlog is being loaded:
 struct State {
     let todos: [String]
     let text: String
     let loading: Bool
 }
    
 override func viewDidLoad() {
     super.viewDidLoad()
        
     state = State(todos: self.state.todos + data, text: self.state.text, loading: true)
     ToDoStore.shared.getToDoItems { (data) in
         self.state = State(todos: self.state.todos + data, text: self.state.text, loading: false)
     }
 }
Copy the code

In addition, we need to add loading parameters to the original initialization method where state is assigned, such as adding and deleting to-do. If we added a variable later, we would have to maintain all these places again, which is obviously unacceptable.

Of course, since State is a value type, we can change the variable declaration in State from let to var, so that we can set the properties in State directly, for example:

 state.todos = state.todos + data
 state.loading = true
Copy the code

In this case, State’s didSet will be called multiple times, which is uncomfortable, but not too much of a problem. More importantly, we’re breaking up state maintenance in places. This gets us into trouble when there are more and more variables in the state, and the states themselves depend on each other. We also need to note that if State contains a reference type, it loses full value semantics, that is, if you change a variable in a reference type in State, State’s didSet will not be called. This makes it very difficult to use and debug when this happens.

  1. Data Source reuse – There is an opportunity to extract the Data Source portion of the Table View and reuse it in different View Controllers. But now the new onesstateTo prevent that possibility. If we want to reusedataSource, we need to putstate.todosSeparate it out, or find a way to do itdataSourceTo synchronize the to-do list with the model.
  2. Tests for asynchronous operations – inTableViewControllerOne area that we didn’t cover in our test wasviewDidLoadIs used to load to-do itemsToDoStore.shared.getToDoItems. It would be difficult to test such asynchronous operations without introducing stubs, but the introduction of stubs itself doesn’t seem particularly convenient right now. Is there a good way to test asynchronous operations in View Controller?

We can solve these problems by introducing some changes to make the UI part of the TableViewController a purely functional implementation and driving the ViewController with one-way data flow.

Further modifications to View Controller

Before embarking on a major overhaul of the code, I want to introduce some basic concepts.

What is a pure function

A Pure Function is a Function that produces the same output if it has the same input. In other words, the action of a function does not depend on states such as external variables, and once the input is given, the output is uniquely determined. For apps, we are always dealing with a certain amount of user input, and will inevitably need to update the UI as the “output” based on the user’s input and known state. So in apps, and especially in View Controllers, where you manipulate the UI, I tend to define a “pure function” as a function that gives you a certain UI given certain inputs.

The State above gives us a solid step towards creating a View Controller that is purely functional, but it is not yet. For any new state, the output UI will depend to some extent on the original state. However, we can solve this problem by extracting the original state and replacing it with a pure function for updating the UI. The new function signature will look something like this:

func updateViews(state: State, previousState: State?)
Copy the code

This way, when we give the original state and the present state, we will get the determined UI, and we will look at the implementation of this method later.

Unidirectional data flow

Another improvement we want to make to the State View Controller is to simplify and unify State maintenance. We know that any new state is achieved by some changes to the original state. For example, in the to-do demo, adding a new to-do means receiving the user’s added behavior from the original state of state.todos, then adding the to-do to the array and printing the new state:

if userWantToAddItem {
    state.todos = state.todos + [item]
}
Copy the code

The same is true for all other operations. By abstracting this equation, we can get the following formula:

New state = F (old state, user behavior)Copy the code

Or to use Swift, this is:

func reducer(state: State, userAction: Action) -> State
Copy the code

If you know anything about functional programming, it should be easy to see that this is a Transformer for the Reduce function, which takes an existing State and an input Action, applies the Action to the State, and gives the new State. Combined with the functional signature of Reduce in the Swift library, we can easily see the connection between the two:

func reduce<Result>(_ initialResult: Result, 
                    _ nextPartialResult: (Result, Element) throws -> Result) rethrows -> Result
Copy the code

Reducer corresponds to the nextPartialResult part of Reduce, which is why we call it reducer.

With reducer(state: state, userAction: Action) -> state, we can now abstract the user actions as actions and centralize all status updates. To make this process general, we will use a single Store type to Store the state and update the state in it by sending actions to the Store. Objects that want to receive status updates (in this case, the TableViewController instance) can subscribe to state changes to update the UI. Subscribers do not participate directly in changing the state, but simply send actions that may change the state, and then accept the state changes and update the UI, thus creating a one-way flow of data. And because the code to update the UI will be purely functional, the View Controller’s UI will be predictable and testable.

Asynchronous state

Like the ToDoStore. Shared. GetToDoItems such asynchronous operations, we also hope to be able to into Action and reducer system. An immediate change to state by an asynchronous operation (such as setting state.loading and displaying a loading Indicator) can be achieved by adding members to state. To trigger this asynchronous operation, we can add a new Action to it that, as opposed to just changing the state of a regular Action, we want it to have the “side effect” of actually triggering the asynchronous operation in the subscriber. This requires us to slightly update the reducer definition. In addition to returning the new State, we also want to return an additional Command for asynchronous operations:

func reducer(state: State, userAction: Action) -> (State, Command?)
Copy the code

Command is only a means of triggering asynchronous operations and should not be associated with state changes, so it does not appear on the input side of the reducer. It doesn’t matter if you don’t understand it now, just remember the function signature, and we’ll see how this works in more detail in future examples.

Putting these together, the View Controller architecture we will implement looks something like the following:

Improved View Controller using unidirectional data flow and Reducer

Enough preparation, let’s improve on the State View Controller.

To be as generic as possible, let’s define a few protocols:

protocol ActionType {}
protocol StateType {}
protocol CommandType {}
Copy the code

The protocols above have no special meaning other than to restrict protocol types. Next, we define the corresponding Action, State, and Command in the TableViewController:

class TableViewController: UITableViewController {
    
    struct State: StateType {
        var dataSource = TableViewControllerDataSource(todos: [], owner: nil)
        var text: String = ""
    }
    
    enum Action: ActionType {
        case updateText(text: String)
        case addToDos(items: [String])
        case removeToDo(index: Int)
        case loadToDos
    }
    
    enum Command: CommandType {
        case loadToDos(completion: ([String]) -> Void )
    }
    
    
    //...
}
Copy the code

To extract the dataSource, we replace the original todos with the entire dataSource in State. UITableViewDataSource TableViewControllerDataSource is standard, it contains todos and used as inputCell owner to set the delegate. Basically, the Data Source part of the original TableViewController is moved over. Some key codes are as follows:

class TableViewControllerDataSource: NSObject, UITableViewDataSource {

    var todos: [String]
    weak var owner: TableViewController?
    
    init(todos: [String], owner: TableViewController?) {
        self.todos = todos
        self.owner = owner
    }
    
    //...
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        //...
            let cell = tableView.dequeueReusableCell(withIdentifier: inputCellReuseId, for: indexPath) as! TableViewInputCell
            cell.delegate = owner
            return cell
    }

}
Copy the code

This is the basic way to separate the Data Source from the View Controller, which is simple in itself and not the focus of this article.

Note the loadToDos member included in Command, which is associated with a method as a closing callback in which we will later send the.addtodos Action to store.

With the necessary types in place, we can reduce the core:

lazy var reducer: (State, Action) -> (state: State, command: Command?) = { [weak self] (state: State, action: Action) in var state = state var command: Command? = nil switch action { case .updateText(let text): state.text = text case .addToDos(let items): state.dataSource = TableViewControllerDataSource(todos: items + state.dataSource.todos, owner: state.dataSource.owner) case .removeToDo(let index): let oldTodos = state.dataSource.todos state.dataSource = TableViewControllerDataSource(todos: Array(oldTodos[..<index] + oldTodos[(index + 1)...] ), owner: state.dataSource.owner) case .loadToDos: LoadToDos {data in // send extra.addtodos}} return (state, command)}Copy the code

To avoid memory leaks caused by the Reducer holding self, we implement a lazy Reducer member here. Self is marked as a weak reference, so we don’t need to worry about reference rings between the Store, View Controller, and reducer.

For.updatetext,.addtodos, and.removetodo, we simply derived new states from existing states. The only notable one is.loadtodos, which will have the Reducer function return a non-empty Command.

Next we need a type to Store the state and response Action, which we’ll call Store:

class Store<A: ActionType, S: StateType, C: CommandType> { let reducer: (_ state: S, _ action: A) -> (S, C?) var subscriber: ((_ state: S, _ previousState: S, _ command: C?) -> Void)? var state: S init(reducer: @escaping (S, A) -> (S, C?) , initialState: S) { self.reducer = reducer self.state = initialState } func dispatch(_ action: A) { let previousState = state let (nextState, command) = reducer(state, action) state = nextState subscriber? (state, previousState, command) } func subscribe(_ handler: @escaping (S, S, C?) -> Void) { self.subscriber = handler } func unsubscribe() { self.subscriber = nil } }Copy the code

Don’t be intimidated by these generics, they are very simple. The Store takes a reducer and an initialState, initialState, as input. It provides a Dispatch method, and the type holding the store can send actions to it via dispatch, and the store will generate new states and necessary commands as provided by the Reducer, and then notify its subscribers.

Add a store variable to TableViewController and initialize it in viewDidLoad:

class TableViewController: UITableViewController { var store: Store<Action, State, Command>! override func viewDidLoad() { super.viewDidLoad() let dataSource = TableViewControllerDataSource(todos: [], owner: self) store = Store<Action, State, Command>(reducer: reducer, initialState: State(dataSource: dataSource, text: Subscribe {[weak self] state, previousState, command in self? .stateDidChanged(state: state, previousState: previousState, command: command)} Store. State, previousState: nil, command: nil) // Start async loading ToDos store. Dispatch (.loadtodos)} //... }Copy the code

After adding stateDidChanged to store.subscribe, stateDidChanged will be called every time the store state changes. We haven’t implemented this method yet. It looks like this:

func stateDidChanged(state: State, previousState: State? , command: Command?) { if let command = command { switch command { case .loadToDos(let handler): ToDoStore.shared.getToDoItems(completionHandler: handler) } } if previousState == nil || previousState! .dataSource.todos ! = state.dataSource.todos { let dataSource = state.dataSource tableView.dataSource = dataSource tableView.reloadData() title = "TODO - ((dataSource.todos.count))" } if previousState == nil || previousState! .text ! = state.text { let isItemLengthEnough = state.text.count >= 3 navigationItem.rightBarButtonItem? .isEnabled = isItemLengthEnough let inputIndexPath = IndexPath(row: 0, section: TableViewControllerDataSource.Section.input.rawValue) let inputCell = tableView.cellForRow(at: inputIndexPath) as? TableViewInputCell inputCell? .textField.text = state.text } }Copy the code

At the same time, we can complete the previous callback from Command. LoadTodos:

lazy var reducer: (State, Action) -> (state: State, command: Command?) = { [weak self] (state: State, action: Action) in var state = state var command: Command? = nil switch action { // ... LoadToDos: command = command. LoadToDos {data in // send extra.addtodos self? .store.dispatch(.addToDos(items: data)) } } return (state, command) }Copy the code

StateDidChanged is now a purely functional UI update method whose output (UI) depends only on the state and previousState of the input. The other input Command is responsible for triggering “side effects” that do not affect the output. In practice, in addition to asynchronous operations such as sending requests, View Controller transitions, pop-ups and other interactions can be done through Command. Command itself should not affect the State transition; it needs to change the State by sending the Action again in order to affect the UI.

At this point, we pretty much have all the pieces. The finishing touches are fairly easy, replacing the previous direct state change code with an event send:

override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    guard indexPath.section == TableViewControllerDataSource.Section.todos.rawValue else { return }
    store.dispatch(.removeToDo(index: indexPath.row))
}
    
@IBAction func addButtonPressed(_ sender: Any) {
    store.dispatch(.addToDos(items: [store.state.text]))
    store.dispatch(.updateText(text: ""))
}

func inputChanged(cell: TableViewInputCell, text: String) {
    store.dispatch(.updateText(text: text))
}
Copy the code

Test a purely functional View Controller

At the end of the day, what we really want is a highly testable View Controller. With high testability, we can have high maintainability. StateDidChanged is now a pure function, independent of the current state of the Controller, and testing it should be easy:

func testUpdateView() { let state1 = TableViewController.State( dataSource:TableViewControllerDataSource(todos: [], the owner: nil), the text: "") / / converting from nil state state1 controller. StateDidChanged (state: state1 previousState: nil, command: nil) XCTAssertEqual(controller.title, "TODO - (0)") XCTAssertEqual(controller.tableView.numberOfRows(inSection: TableViewControllerDataSource.Section.todos.rawValue), 0) XCTAssertFalse(controller.navigationItem.rightBarButtonItem! .isEnabled) let state2 = TableViewController.State( dataSource:TableViewControllerDataSource(todos: ["1", "3"], owner: Nil), the text: "Hello"), / / from converting state1 state state2 controller. StateDidChanged (state: state2 previousState: state1, command: nil) XCTAssertEqual(controller.title, "TODO - (2)") XCTAssertEqual(controller.tableView.numberOfRows(inSection: TableViewControllerDataSource.Section.todos.rawValue), 2) XCTAssertEqual(controller.tableView.cellForRow(at: todoItemIndexPath(row: 1))? .textLabel? .text, "3") XCTAssertTrue(controller.navigationItem.rightBarButtonItem! .isEnabled) }Copy the code

As a unit test, covering production code means covering the vast majority of usage cases. In addition, if you wish, you can write transitions between various states, covering as many boundary cases as possible. This ensures that your code does not degrade due to new changes.

Although we didn’t say it explicitly, the other important function in The TableViewController reducer is also pure. Testing it is equally simple, such as:

func testReducerUpdateTextFromEmpty() {
    let initState = TableViewController.State()
    let state = controller.reducer(initState, .updateText(text: "123")).state
    XCTAssertEqual(state.text, "123")
}
Copy the code

The output state is only relevant to the input initState and action; it has nothing to do with the state of the View Controller. The tests of the other methods in the Reducer are the same and will not be repeated here.

Finally, let’s look at the load part of the State View Controller that is not being tested. Since loading a new to-do is now also triggered by an Action, we can confirm the load by checking the Command returned by the reducer:

func testLoadToDos() { let initState = TableViewController.State() let (_, command) = controller.reducer(initState, .loadToDos) XCTAssertNotNil(command) switch command! { case .loadToDos(let handler): handler(["2", "3"]) XCTAssertEqual(controller.store.state.dataSource.todos, ["2", "3"]) // Now Command has only a.loadtodos Command. // XCTFail("The Command should be.loadtodos ")}}Copy the code

We check that the returned command is.loadtodos, and that the.loadtodos handler acts as a natural stub. By making a call with dummy data ([“2”, “3”]), we can check whether the state in the store is as expected, and thus test the asynchronous loading process synchronously!

May have a reunion in doubt, don’t think there are any test ToDoStore. Shared. GetToDoItems. But remember, we’re testing the View Controller here, not the network layer. Testing for ToDoStore should be done in a separate place.

You can find this section in the Reducer branch of the GitHub repo.

conclusion

You’ve probably seen similar one-way data flows, such as Redux, or the older Flux. Even in Swift, there is ReSwift that implements a similar idea. In this article, we kept the basic MVC architecture and used this approach to improve the design of the View Controller.

In our example, our Store is in the View Controller. In fact, as long as there are state changes, this method can be applied anywhere. You can introduce stores at other levels. As long as there is a one-way flow of data and complete state change coverage testing, this approach scales well.

This small change is easier to explore and practice on a daily basis than sweeping changes or new design patterns. It has no external dependencies and can be used directly in new View Controllers, or you can modify existing classes over time. After all, most iOS developers probably spend a lot of time on View Controllers, so writing a View Controller that is easy to test and easy to maintain will determine how happy an iOS developer is. So spending some time figuring out how to write a Good View Controller should be a required course in every iOSer.

Some recommended references

  1. I personally recommend reading up on React if you want to learn more about similar architectural approaches, especially how to think like React.
  2. If you can afford it, even if you’re still developing CocoaTouch Native every day, try building some projects with React Native. I believe you will broaden your horizons and gain new insights in the process.
  3. As well as some technical information on iOS