The article is from collated notes on objccn. IO/related books. “Thank you objC.io and its writers for selflessly sharing their knowledge with the world.”

❗️❗️ ️ Then read ❗️❗️ ️ ️

❗️❗️ ️❗… ❗ ️ ❗ ️ ❗ ️

The Model-View-Controller (MVC) pattern is the baseline for all other patterns.

The core idea of MVC is that the Controller layer is responsible for bringing the Model layer and view layer together. The Controller builds and configures the other two layers and coordinates two-way communication between model objects and View objects. Therefore, in an MVC APP, the Controller layer serves as the core to participate in forming the feedback loop of the app:

MVC is based on classic object-oriented principles: objects manage their behavior and state internally and communicate through interfaces of classes and protocols; View objects are typically self-contained and reusable; The Model object is independent of the representation and avoids dependencies on other parts of the program. It is the controller layer’s responsibility to combine the other two parts into a complete program.

Apple describes MVC as a collection of three distinct subpatterns:

  1. The composit-view is assembled into a hierarchy, which is divided into groups and managed by controller objects.
  2. The Policy pattern-Controller object is responsible for coordinating the View and model and managing the behavior of reusable, app-independent views within the app.
  3. Observer pattern – Objects that depend on Model data must subscribe to and receive updates.

The INTERPRETATION of the MVC pattern is usually quite loose, and these subpatterns – especially the observer pattern – are often not strictly followed.

The MVC pattern has some well-known flaws, starting with the controller layer having too many responsibilities. MVC also faces problems that are difficult to test, especially unit tests and interface tests that are difficult or even impossible to implement. Despite these drawbacks, MVC is the simplest pattern to use in iOS apps.

To explore the implementation

create

The Cocoa MVC process for creating a program broadly follows the default startup process provided by the Cocoa framework. The main goal of this process is to ensure the creation of three objects: the UIApplication object, the Application Delegate, and the root View Controller for the main window. The configuration of this procedure is distributed in three files with the default names info.plist, Appdelegate. swift, and main.storyboard.

All three objects belong to the Controller hierarchy and provide a place where the subsequent startup process can be configured, so the Controller layer takes care of all creation.

Connect the View to the initial data

Views in MVC Apps don’t refer directly to Model objects; The View will remain independently reusable. Instead, the Model objects are stored in the View Controller, which makes the View Controller a non-reusable class, but that’s exactly what the View Controller is for: it communicates specific knowledge about the app to other parts of the program.

The Model object stored in the View Controller gives the View Controller identity (it lets the View Controller know where it is in the program and how to talk to the Model layer). The View Controller extracts the relevant property values of the Model object, transforms them, and then sets the deformed values to the View it holds.

How do I set this identity object for the View Controller? In the recording app, there are two different ways to set an initial model value on the View Controller:

  1. Directly access a global Model object by determining the controller’s position on the Controller hierarchy and the controller’s type.
  2. Start by setting the reference to the Model object to nil and leave everything blank until another controller provides a non-nil value.

The third option is to pass the Model object as a parameter when the Controller is initialized (that is, dependency injection), which should be chosen when possible. However, the storyboard build process generally does not allow arguments to be passed to view Controllers when they are initialized.

FolderViewController is an example of the first strategy. For each folder, the View Controller’s initial Folder properties are set as follows:

var folder: Folder = Store.shared.rootFolder {
// ...
}
Copy the code

That is, at the time of the initial build, each folder View Controller assumes that it represents the root directory. If this is a view controller representing a subdirectory, its parent folder view controller will set its folder value to something else when perform(segue:). This ensures that the Folder property in the folder View Controller is not optional, so the code does not need to include a conditional test to check for the existence of the folder object, since the property will at least be the root folder.

In the recording app, the Model object store.shared is a delay-built singleton. The first folder view Controller accesses store.shared. rootFolder when the shared Store instance is built.

The PlayViewController (the detail view in the topmost split view) uses an optional value reference to a Model object that starts out nil, following the second strategy for setting identity references.

var recording: Recording? { 
// ...
}
Copy the code

When recording is nil, PlayViewController displays a blank page (” No recordings selected “). Here nil is an expected state. Recording properties can be set from the outside, either through the folder View Controller or through the state recovery process. In either case, once the main Model object is set, the Controller responds by updating the View.

The folder View Controller is another example of a controller responding to a main Model change. The navigation title (the folder name displayed in the navigation bar at the top of the screen) needs to be updated when the Folder is set:

var folder: Folder = Store.shared.rootFolder { 
    didSet {
        tableView.reloadData()
        if folder = = = folder.store?.rootFolder {
            title = .recordings 
        } else {
            title = folder.name 
        }
    } 
}
Copy the code

As a rule, whenever the initial Model data is read, it must also be observed to change it. In the viewDidLoad folder view Controller, add the View Controller as the observer of the Model notification:

override func viewDidLoad(a) { 
    super.viewDidLoad()
    // ... 
    NotificationCenter.default.addObserver(self,
        selector: #selector(handleChangeNotification(_:)),
        name: Store.changedNotiifcation, 
        object: nil)}Copy the code

back

State recovery in MVC requires the use of the Storyboard system, which acts as a Controller. To implement this system, you must implement the following methods in an AppDelegate:

func application(_ application: UIApplication.shouldSaveApplicationState coder: NSCoder) -> Bool {
    return true
}
func application(_ application: UIApplication.shouldRestoreApplicationState coder: NSCoder) -> Bool {
    return true
}
Copy the code

When these two methods are implemented, the Storyboard system takes over. Configure a recovery ID for view Controllers that should be automatically saved and restored by the storyboard system. For example, the recovery ID of the root View Controller of the recording app is splitController, which can be set via the Identity panel of the Storyboard editor. For the recording app, the recovery ID is configured for every scene except the RecordViewController, which is intentionally unsaveable.

Although the Storyboard system ensures the existence of these View Controllers, additional work must be done to ensure that the Model data stored in each View Controller is the same as before exiting the app. To store these additional states, each view controller must implement encodeRestorableState(with:) and decodeRestorableState(with:). In FolderViewController it is implemented as follows:

override func encodeRestorableState(with coder: NSCoder) { 
    super.encodeRestorableState(with: coder) 
    coder.encode(folder.uuidPath, forKey: .uuidPathKey)
}
Copy the code

The FolderViewController stores the uuidPath used to identify Foldermodel objects. The decoding part is a little more complicated:

override func decodeRestorableState(with coder: NSCoder) { 
    super.decodeRestorableState(with: coder)
    if let uuidPath = coder.decodeObject(forKey: .uuidPathKey) as? [UUID].let folder = Store.shared.item(atUUIDPath: uuidPath) as? Folder {
            self.folder = folder 
        } else {
            if let index = navigationController?.viewControllers.index(of: self), 
               index ! = 0 {
                navigationController?.viewControllers.remove(at: index)
        } 
    }
}
Copy the code

After decoding the uuidPath, FolderViewController must check that the entry still exists in the store before it can set the Folder property to that entry. If the item is no longer in the store, the FolderViewController must attempt to remove itself from the ViewController list of the navigation Controller.

To change the Model

In the broadest interpretation of MVC, there are no details of how the Model is implemented, how the Model should change, or how the View should respond to changes. In the earliest versions of macOS, an earlier document-view model was followed, allowing controller objects like NSWindowController or NSDocument to change model directly in response to view actions. And it is quite common to update the model directly in the same function.

In MVC implementations, it is considered that the behavior of model changes should not occur in the same function as the behavior of view hierarchy changes. These behaviors should be unaffected by model State in any way. At the end of the construction phase, changes to the View hierarchy should follow the observer pattern part of MVC and only occur in the observation callback.

The observer pattern is the key to maintaining the separation of Model and View in MVC. The advantage of this approach is that no matter where the change originated (for example, view events, background tasks, or networks), you can be sure that the UI is in sync with the Model data. Furthermore, the model will have the opportunity to reject or modify the request when it encounters a change request:

The steps required to remove an item from a folder:

In the example app, the Table View’s data source is set by the storyboard to the folder View Controller. To handle the delete button click, the Table View calls the tableView on its data source (_: COMMIT :forRowAt:).

Step 2: The View Controller changes the implementation of the Model tableView(_:commit:forRowAt:) to look (based on index path) for the item that should be deleted and ask the parent folder to remove it:

override func tableView(_ tableView: UITableView.commit editingStyle: UITableViewCellEditingStyle.forRowAt indexPath: IndexPath)
{
    folder.remove(folder.contents[indexPath.row])
}
Copy the code

The corresponding cell is not removed directly from the table view. This action occurs only when a Model change is observed.

The remove method on Folder notifies the item that it has been deleted by calling item.deleted(). It then removes the entry from the contents of the folder. It then tells the Store that the data should be saved, including details of the change just made:

func remove(_ item: Item) {
    guard let index = contents.index(where: { $0 = = = item }) else { return } 
    item.deleted()
    contents.remove(at: index)
    store?.save(item, userInfo: [
        Item.changeReasonKey: Item.removed, 
        Item.oldValueKey: index, 
        Item.parentFolderKey: self])}Copy the code

If the removed item is a recording, item.deleted() will delete the associated file from the file system. If it is a folder, it makes a recursive call to remove all subfolders and recordings.

Persisting the Model object and sending change notifications occurs in the Store’s Save method:

func save(_ notifying: Item.userInfo: [AnyHashable: Any]) {
    if let url = baseURL, let data = try? JSONEncoder().encode(rootFolder) {
        try! data.write(to: url.appendingPathComponent(.storeLocation))
    // Error handling was skipped
    }
    NotificationCenter.default.post(name: Store. ChangedNoti 􏰕cation, object: notifying, userInfo: userInfo)}Copy the code

Step 3: View Controller watch Model changes folder View Controller has set up watch in viewDidLoad for notification of store changes, and in response, This observation will trigger and call handleChangeNotification.

Step 4: View Controller changes the View

When notification of Store changes arrives, handleChangeNotification of the View Controller is executed and the corresponding changes are made at the View hierarchy. Probably the simplest way to handle notifications is to reload the Table View data whenever any type of Model notification arrives. In general, proper handling of model notifications is based on a proper understanding of the data changes described in the notifications. Therefore, the nature of the Model change, that is, the index number that changed, and what kind of change happened, is sent in the implementation via the notification’s userInfo dictionary. In this case, the handling of the notification involves a call to TableView.deleterows (at:with:).

@objc func handleChangeNotification(_ notification: Notification) { 
    // ...
    if let changeReason = userInfo[Item.changeReasonKey] as? String { 
        let oldValue = userInfo[Item.newValueKey]
        let newValue = userInfo[Item.oldValueKey]
        switch (changeReason, newValue, oldValue) {
        case let (Item.removed, _, (oldIndex as Int)?):
            tableView.deleteRows(at: [IndexPath(row: oldIndex, section: 0)],with: .right) 
            // ...}}else {
        tableView.reloadData() 
    }
}
Copy the code

There’s one notable missing piece in this code: we haven’t updated any data for the View Controller itself. The View Controller’s Folder value is a shared reference value that directly uses objects in the Model layer, so it is already updated. After calling tableView.deleterows (at:with:) above, the tableView will call the data Source implementation on the view Controller folder, They return the latest state data by accessing the shared Folder reference.

If a value type is used, it will be copied on assignment, so changes to the Folder in the Store will not affect the Folder in the View Controller. As a result, we will need additional logic to synchronize the state of the two Folders.

This notification processing also relies on a shortcut: The Model stores items in the same order as they are displayed. This is not ideal, and the Model should not know how its data is displayed. Conceptually, a cleaner (but more code-demanding) implementation would require the Model to send information about changes to the set rather than the array, and the notification handler would need to use its own sorting, merge the state before and after the changes, and determine the index of the deleted items.

The “Change Model” event loop is now complete in MVC. Because you update the UI only in response to a Model change, rather than directly in the response to a View Action, the UI will update correctly even if the folder is removed from the Model in some other way (such as a network event), or if the Model rejects the change. This is a very robust way to ensure that the View layer is always in sync with the Model layer.

Change the View State

The MODEL layer of MVC stems from a typical document-based app: any state written to a document during a save operation is considered part of the Model. Other arbitrary states – including such things as navigation states, temporary search and sort values, feedback from asynchronous tasks, and uncommitted changes – are traditionally excluded from MVC’s model definition.

In MVC, these “other” states, collectively referred to as View states, are not included in the description of the pattern. According to traditional object-oriented principles, any object can have internal state, and these objects do not need to communicate changes in their internal state to the rest of the program.

Based on this internal processing, view State does not need to follow a clear path in any program. Any view or Controller can contain states that are updated by the View Action. View state processing is as local as possible: a view or view Controller can update its own view state in response to user events alone.

Most UIViews have internal state that can be updated in response to view actions without having to pass these changes along. For example, a UISwitch can switch from ON to OFF in response to a user click:

If the view itself cannot change its state, then the event loop becomes one step longer. For example, when a button’s label needs to change when it is clicked (such as a play button) or when the user clicks a cell in a list, a new View Controller needs to be pushed onto the navigation stack.

View state still exists in a particular View Controller and its views. However, instead of a view changing its state, you now have the opportunity to customize a view’s state change (such as the play button title change in the first example below) or to have the View State change across views (as in the second example below, Push a new folder, View Controller, for example.

Example 1: Update the play button

The Play button in the PlayViewController toggles its title between “Play”, “Pause”, and “Resume” depending on the state of Play. From the user’s perspective, when the button is represented as “Play”, pressing it changes the title to “Pause”; Pressing it again at the end of the play changes it to Resume.

  • Step 1: The button sends Action to the View Controller

The play button is connected to the Play method of the PlayViewController through the IBAction in the storyboard. Clicking this button calls the Play method:

@IBAction func play(a) { 
    // ...
}
Copy the code
  • Step 2: The View Controller changes its internal state

The first line of the play method updates the status of the audio player:

@IBAction func play(a) { 
    audioPlayer?.togglePlay() 
    updatePlayButton()
}
Copy the code
  • Step 3: View Controller update button

The second line of the Play method calls updatePlayButton, which sets the new title for the play button directly based on the state of the audioPlayer:

func updatePlayButton(a) {
    if audioPlayer?.isPlaying = = true {
        playButton?.setTitle(.pause, for: .normal)
    } else if audioPlayer?.isPaused = = true { 
        playButton?.setTitle(.resume, for: .normal)
    } else {
        playButton?.setTitle(.play, for: .normal)
    } 
}
Copy the code

.pause,.resume, and.play are static constants defined locally on a String.

The number of parts involved in this process is minimal: the button sends the event to the PlayViewController, which then sets the button’s new title.

Example 2: Push the folder View Controller

The Table View in the folder View Controller is responsible for presenting two types of objects: recordings and subfolders. When the user clicks on the child folder, the new folder view Controller needs to be configured and pushed onto the navigation stack. Because storyboards with segues are used to do this, the specific steps below only loosely relate the steps in the diagram above.

  • Step 1: Trigger the Segue

Because in storyboard, the cells of the subfolder are connected to the folder View Controller by a push segue, so clicking on the cells of the subfolder will trigger a showFolder segue. This will cause UIKit to create a new folder view Controller.

This step is a variation of the Target/Action mode. Behind the scenes UIKit does more work, but the result is that the source View Controller’s prepare(for: Sender 🙂 method is called.

  • Steps 2 & 3: Configure the new folder View Controller

Prepare (for:sender:) tells the current folder View Controller which segue is happening. After checking the segue identifier, configure the new folder View Controller:

override func prepare(for segue: UIStoryboardSegue.sender: Any?) { 
    guard let identifier = segue.identifier else { return }
    ifIdenti 􏰕 er= = .showFolder {
        guard
            let folderVC = segue.destination as? FolderViewController.let selectedFolder = selectedItem as? Folder else { fatalError() }
        folderVC.folder = selectedFolder
    }
    // ...
}
Copy the code

First check that the target View Controller has the correct type and that a folder is indeed selected. If both conditions are not met, then it should be a programming error that crashes the app. If everything is as expected, set the subfolders to the new folder view Controller.

The Storyboard mechanism will do the actual work of presenting the new View Controller. When the new ViewController is configured, UIKit pushes it onto the navigation stack (without calling pushViewController itself). Pushing the View Controller onto the navigation stack causes the navigation Controller to load the view of the pushed View Controller onto the view hierarchy.

Similar to the play button example above, UI events (selecting subfolder cells) are handled locally as much as possible. The segueing mechanism obscures the exact path of the event, but from the code’s point of view, nothing else is affected except the original View Controller. View state is implicitly represented by the associated View and View controller.

If you want to share view state across different parts of the View hierarchy, you need to find their common ancestor at the View (or View Controller) hierarchy and manage state there. For example, if we want to share play state between the play button TAB and the player, we can store the state in the PlayViewController. When we need a view state in almost every widget (for example, a Boolean that determines whether the app should be in dark mode), we need to put it in the top controller (for example, the App agent). In practice, however, it is not common to put view State in the top Controller object of the hierarchy, as this would require pipes of communication between each layer of the hierarchy. So, most people choose to use singletons instead.

test

Automated testing can take several different forms. From the smallest to the largest granularity, including:

  • Unit testing (isolating individual functions and testing their behavior).
  • Interface testing (using interface inputs and testing the results of interface outputs, which are usually functions).
  • Integration testing (testing the program as a whole or major parts of the program).

The Cocoa MVC pattern is more than 20 years old, so unit testing was not a consideration when it was created.

You can test the Model layer because it is separate from the rest of the application, but that doesn’t help you test user-facing state. You can use Xcode’s UI tests, which are automated scripts that run the entire program and try to read the screen using VoiceOver or an accessible API, but these tests are slow, time-consuming, and difficult to extract accurate results.

If you want to test MVC’s Controller and Model layers at the code level, the only viable option is to write integration tests. Integration testing involves building a self-contained version of the app, manipulating some parts of it, and then reading data from other parts to ensure that the results are passed between objects as expected.

For recording apps, this test requires a Store object, so you can create a Store (which exists only in memory) without a URL and add a test entry (folder and recording):

func constructTestingStore(a) -> Store { 
    let store = Store(url: nil)
    let folder1 = Folder(name: "Child 1", uuid: uuid1) 
    let folder2 = Folder(name: "Child 2", uuid: uuid2) 
    store.rootFolder.add(folder1) 
    folder1.add(folder2)
    let recording1 = Recording(name: "Recording 1", uuid: uuid3) 
    let recording2 = Recording(name: "Recording 2", uuid: uuid4) 
    store.rootFolder.add(recording1)
    folder1.add(recording2)
    store.placeholder = Bundle(for: FolderViewControllerTests.self).url(forResource: "empty", withExtension: "m4a")!
    return store 
}
Copy the code

Store.placeholder is a feature in store specifically for testing: if the URL is nil, this placeholder will be returned as the audio file of the fetched recording when store.fileurl (for:) is called. Once you’ve built a store, you need a view Controller hierarchy that uses the store:

func constructTestingViews(store: Store.navDelegate: UINavigationControllerDelegate)- > (UIStoryboard.AppDelegate.UISplitViewController.UINavigationController.FolderViewController) {
    let storyboard = UIStoryboard(name: "Main", bundle: Bundle.main) 
        as! UINavigationController 
    navigationController.delegate = navDelegate
    let rootFolderViewController = navigationController.viewControllers.first as! FolderViewController
    rootFolderViewController.folder = store.rootFolder 
    rootFolderViewController.loadViewIfNeeded()
    // ...
    window.makeKeyAndVisible()
    return (storyboard, appDelegate, splitViewController, navigationController, rootFolderViewController)
}
Copy the code

This shows how the main navigation Controller and root View Controller are created. The function goes on to build the secondary navigation Controller, Play View Controller, Split View Controller, window, and App delegate in a similar manner, which forms a complete user interface. After building all the way up to the window, you need to call window.ishidden = false, otherwise much of the animation and presentation behavior will not happen.

Pay attention to the navigation controller proxy Settings to navDelegate parameter, this parameter will be responsible for running test FolderViewControllerTests instance of a class, The Folder of the root View Controller is set as the rootFolder of the store for testing.

In the test setUp method calls the constructor, to initialize FolderViewControllerTests instance members. When this is done, you can start writing tests.

Integration testing usually requires some degree of configuration of the initial environment to execute an action on it and measure results. In app integration testing, measuring the results can be simple (such as accessing readable properties on an object in a configured environment) or very difficult (such as multiple asynchronous interactions in a Cocoa framework).

A relatively simple test example is to test the commitEditing behavior while deleting a row from a list:

func testCommitEditing(a) {
    // Verify that the behavior to be invoked is connected
    let dataSource = rootFolderViewController.tableView.dataSource
    as? FolderViewController XCTAssertEqual(dataSource, rootFolderViewController)
    // Verify that the entry exists before deletion
    XCTAssertNotNil(store.item(atUUIDPath: [store.rootFolder.uuid, uuid3])) 
    // Perform the delete action
    rootFolderViewController.tableView(rootFolderViewController.tableView,
    commit: .delete, forRowAt: IndexPath(row: 1, section: 0))
    // Confirm that the entry has been deleted
    XCTAssertNil(store.item(atUUIDPath: [store.rootFolder.uuid, uuid3])) 
}
Copy the code

The above test verifies that the root View Controller is configured correctly as the data source and calls the tableView(_: COMMIT :forRowAt:) directly on the data Source. Finally, verify that this action deletes the item from the Model.

Testing is more complicated when it comes to animations or potentially emulator-dependent changes. In the recording app, the most complex test is to select a recording entry in the controller folder and verify that the recording is displayed correctly in the detail View side of the Split View. This test handles the difference between the split View Controller’s folded and unfolded states, and it waits for the navigation Controller’s push behavior to finish:

func testSelectedRecording(a) {
    // Select a row so that 'prepare(for:sender:)' can read the selected row
    rootFolderViewController.tableView.selectRow(at: IndexPath(row: 1, section: 0),
animated: false, scrollPosition: .none)
    // Handle split View controller folded and unfolded states
    if self.splitViewController.viewControllers.count = = 1 {
        ex = expectation(description: "Wait for segue")
        // Trigger transitionRootFolderViewController. PerformSegue (withIdenti 􏰕 er:"showPlayer", sender: nil)
        // Wait for the navigation Controller to push the folded Detail View into waitForExpectations(timeout: 5.0)
        // Move to 'PlayViewController'
        let collapsedNC = navigationController.viewControllers.last
        as? UINavigationController
        let playVC = collapsedNC?.viewControllers.last as? PlayViewController // Test results
        XCTAssertEqual(playVC?.recording?.uuid, uuid3)
    } else {
        // Handle the non-collapsed state}}Copy the code

The created expectation (EX) will be taken up when the main navigation Controller pushes the new View Controller onto the navigation stack. This change to the main navigation Controller occurs when the Detail View is folded into the Master View (that is, in all iPhone compact display modes except landscape mode on the iPhone Plus):

func navigationController(_ navigationController: UINavigationController.didShow viewController: UIViewController.animated: Bool) { ex?.fulfill()
    ex = nil
}
Copy the code

The tests I wrote for the folder View Controller covered about 80% of the lines of code and tested all the important behavior, and while these integration tests did the job, the testSelectedRecording tests showed that, Writing integration tests correctly requires a great deal of knowledge about how the Cocoa framework operates.

discuss

MVC has its merits and is the architectural pattern of least resistance in iOS development. Every class in Cocoa is tested under MVC conditions. Features like Storyboard, which are highly integrated with frameworks and classes, work more smoothly with applications that use MVC. Searching the Web, you can find more examples of MVC implementation than any other design pattern. Also, of all patterns, MVC is usually the one with the least amount of code and the least design overhead.

There are two most common problems with MVC.

Observer mode failure

For the basic MVC implementation, choose to use Foundation’s NotificationCenter to broadcast model notifications. The reason for this choice was the desire to use basic Cocoa for implementation, rather than frameworks or other abstractions.

However, such an implementation requires that a value be updated correctly in many places at once. For example, the folder in view Controller is set in the prepare(for:sender:) method of the parent view Controller:

guard let folderVC = segue.destination as? FolderViewController.let selectedFolder = selectedItem as? Folder
else { fatalError() } 
folderVC.folder = selectedFolder
Copy the code

Later, the folder Controller will watch the Model’s notifications to see how the value changes. In the notification processing section, update the folder if the notification object is the current folder:

@objc func handleChangeNotification(_ notification: Noti􏰕 cation) { // Handle changes to the current folder
    if let item = notification.object as? Folder, item = = = folder {
        let reason = notification.userInfo?[Item.changeReasonKey] as? String 
        if reason = = Item.removed, let nc = navigationController {
            nc.setViewControllers(nc.viewControllers.filter { $0 ! = = self }, animated: false)}else {
            folder = item
        }
    }
    // ...
Copy the code

It is best to have a way in which there is no time gap between the initial setup of the Folder and the creation of observers in viewDidLoad. It would be nice if you could unify the Settings for folders.

Using key-value observation (KVO) instead of notification is an option, but in most cases this usually requires observing multiple different key paths simultaneously (such as observing the children property of the parent folder as well as the current child folder), which makes KVO actually no more stable than notification. Because it requires every property being observed to be declared dynamic, it is also far less popular in Swift than it is in Objective-C.

The simplest improvement to the observer pattern is to encapsulate NotificationCenter and implement the initialization concepts contained in KVO for it. This concept sends an initial value when the observation is established, which allows the operation of setting the initial value and observing subsequent values to be combined into a single pipe.

Merge the code observed in viewDidLoad with the code in handleChangeNotification above into the following code:

observations + = Store.shared.addObserver(at: folder.uuidPath) { 
    [weak self] (folder: Folder?).in
    guard let strongSelf = self else { return }
    if let f = folder { / / change
        strongSelf.folder = f 
    } else { / / delete
    strongSelf.navigationController.map { 
        $0.setViewControllers($0.viewControllers.filter { $0 ! = = self },
            animated: false)}}}Copy the code

This doesn’t save much code, but the self.folder value is now set in only two places (in the observation callback, and in the prepare(for: Sender 🙂 of the parent folder Controller), reducing the number of paths in the code to make changes to it. In addition, dynamic casting is no longer required in the user code, and there is no gap between the initialization and the observation: the initialization is no longer the actual data, it is just an identifier, and the observation callback is the only way to access the actual data that is needed.

This type of addObserver can be implemented on top of an extension of the Store without making any changes to the Store itself, which is an advantage of this implementation:

extension Store {
    func addObserver<T: AnyObject> (at uuidPath: [UUID].callback: @escaping (T?). - > ())- > [NSObjectProtocol] {
        guard let item = item(atUUIDPath: uuidPath) as? T else {
            callback(nil)
            return[]}let o = NotificationCenter.default.addObserver( 
        forName: Store.changedNotification,
        object: item, 
        queue: nil) { notification in
            if let item = notification.object as? T, item = = = item {
                let reason = notification.userInfo?[Item.changeReasonKey] as? String
                if reason = = Item.removed {
                    return callback(nil)}else {
                    callback(item) 
                }
            } 
        }
        callback(item)
        return [o] 
    }
}
Copy the code

The Store still sends the same notification, but subscribes to it in a different way, handles the notification data (such as checking for the presence of item.ChangereasonKey), and handles some other template code so that the work required for each View Controller is simpler.

Fat View Controller problem

Very large View Controllers usually do irrelevant work outside of their main job (observing models, presenting views, feeding them data, and receiving view actions); They should either be broken up into multiple Controllers that each manage a smaller part of the View hierarchy; Or because interfaces and abstractions fail to encapsulate the complexity of a program, the View Controller does too much cleaning up.

In many cases, the best way to solve this problem is to proactively move as much functionality as possible into the Model layer. Methods such as sorting, data retrieval and processing are not part of the stored state of the app, so they are usually put in the Controller. But they are still relevant to the app’s data and proprietary logic, and would be a better choice to put them in the Model.

Take a quick look at some of GitHub’s popular iOS projects with large View Controllers.

Wikipedia 的 PlacesViewController

Github.com/wikimedia/w…

There are 2,326 lines in this file. The View Controller contains the following roles:

  1. Configure and manage a map view that displays location results (400 lines)
  2. Get user location via Location Manager (100 lines)
  3. Perform the search and collect the results (400 lines)
  4. Group the results and display them in the visible area of the map (250 lines)
  5. Populate and manage the search suggestions into the Table View (500 lines)
  6. Handling overlay layouts such as table View for search suggestions (300 lines)

This example illustrates the three main causes of view Controller fatness:

  1. Manage more than one main view (map view and search suggestion table view).
  2. Create and execute asynchronous tasks (such as getting the user’s location), although the View Controller is only interested in the result of the task (in this case, the user’s location).
  3. The Model/ proprietary logic (search and processing results) is manipulated at the Controller layer.

View dependencies for a scene can be simplified by splitting the main views into their own smaller controllers. They don’t even need to be instances of UIViewController, just child objects owned by the scene. All that remains in the parent View Controller is the integration and layout (and the complex layout can be refactored as the View Controller is simplified).

Utility classes can be created to perform asynchronous tasks such as retrieving user location information, and in the Controller, the only code required is to create the task and the callback closure.

From an app design architecture perspective, the biggest problem is the Model or proprietary logic in the Controller layer. The code lacks a model to actually perform the search and collect the search results. There is indeed a dataStore object in the View Controller, but the code doesn’t abstract around it in any way, so it doesn’t help by itself. The View Controller actually does all the searching and data processing itself, which should have been handled elsewhere. Even operations such as grouping results by visual area should be performed by the model or by transformation objects between the model and the View Controller.

Taking some of the code out of the View Controller does not inherently reduce the overall complexity of the program. However, doing so does reduce the complexity of the View Controller itself. When optimizing a View Controller, you can check the MVC behavior diagram to see if an action really fits into the View Controller. Moving out parts that are not directly related is the most straightforward, easy to implement, and effective step in refactoring.

WordPress AztecPostViewController

Github.com/wordpress-m…

This file has 2,703 lines and the view Controller’s responsibilities include:

  1. Create child View with code (300 lines)
  2. Automatic layout constraints and placement headers (200 lines)
  3. Managing the article publishing process (100 lines)
  4. Set up child View Controllers and process their results (600 lines)
  5. Coordinate, observe, and manage input to a Text View (300 lines)
  6. Display warning and warning content (200 lines)
  7. Tracking media file uploads (600 lines)

Media file upload is a dedicated service and should be easily moved to its own model-layer service. Meanwhile, 75 percent of the remaining code can be removed by improving the interfaces of other parts of the program.

None of the models, services, or Child View Controllers handled in AztecPostViewController can be used in a single line of code. The code that displays the pop-up warning appears several times in different places. The Child View Controller takes 20 lines to set up, and another 20 lines to process after it’s finished. Although the TextView is a custom Aztec.TextView, there are still hundreds of lines of code in the View Controller tweaking its behavior.

In these cases, none of the other parts can do their job, and the View Controller is always used to fix them. These behaviors should be integrated into their respective parts as much as possible. When you can’t change the behavior of a widget, you can write a wrapper around the widget instead of putting the logic into the View Controller.

Firefox BrowserViewController

Github.com/mozilla-mob…

The length of this file is 2,209 lines. This View Controller contains more than 1,000 lines of agent implementations.

BrowserViewController is the top-level view Controller of the entire program, and these proxy implementations represent lower-level View controllers that use BrowserViewController to relay behavior through the program.

Yes, the Controller layer does have responsibility for passing behavior between programs, so the problem with this example is not that responsibility for the model or proprietary logic has been placed in the wrong layer. However, many of these agents have nothing to do with the view managed by BrowserViewController (they just want access to other parts or states stored in BrowserViewController).

Instead of putting this responsibility on a View Controller that already has a lot of other responsibilities, These agent callbacks can all be relocated to a coordinator or abstract (non-View Controller) controller that is specifically used for relay processing at the Controller layer.

Use code instead of storyboards

Instead of using storyboards, you can choose to define the view hierarchy in code. Such a change gives you more control during the construction phase, the biggest advantage being better control over dependencies.

For example, when using the storyboard, there is no way to ensure that all the required view Controller properties are set in prepare(for: Sender :). Two different approaches to model passing have been used: one with a default value (such as FolderViewController), and one with optional value types (such as PlayViewController). Neither way guarantees that the object will be passed; If they forget this, the code will silently continue to run, but either the value is wrong or a blank value.

When you get rid of the storyboard, you gain more control over the build process and allow the compiler to ensure that the necessary parameters are passed correctly. When using the Instantiate method, the compiler will help and ensure that the Folder is provided. Ideally, instantiate should be an initialization method, but this is only possible if the storyboard is completely removed.

You can apply the same technique to the view build process by adding custom initialization methods to the View class, or by writing a function to build a specific view hierarchy. In general, you can take advantage of all of Swift’s language features without using storyboards: generics (for example, configuring a generic View Controller), first-class functions (for example, using functions to configure the view appearance or set callbacks), Enumerations with associated values (such as mutually exclusive states by model), and so on.

Code reuse in extensions

A common way to share code between view Controllers is to create a parent class that contains common functionality. The View Controller can then subclass those functions. This technique works, but it has a potential downside: you can only select a single parent for the new class. This approach also often results in a shared parent class containing all of the shared functionality in the project. Such classes often become very complex and difficult to maintain.

Another option to share code in view Controller is to use extensions. Methods that appear in more than one ViewController can sometimes be added to an extension of UIViewController. This way, all view Controllers can get this method. For example, you could add a simple way to display text warnings to UIViewController.

In order for an extension to be useful, it is often necessary for the View Controller to have some specific capabilities. For example, an extension might require the View Controller to have an activity indicator displayed, or to have a specific method available on the View Controller. We can secure those capabilities in the agreement. For example, you can share code that handles the keyboard, which scales the view when the keyboard is shown or hidden. If we use automatic layout, then we can specify that we need a scalable bottom constraint:

protocol ResizableContentView {
    var resizableConstraint: NSLayoutConstraint { get}}// Next, we can add an extension to each UIViewController that implements this protocol:
extension ResizableContentView where Self: UIViewController { 
    func addKeyboardObservers(a) {
    // ...}}Copy the code

Now, any View Controller that implements ResizableContentView also gets the addKeyboardObservers method. You can use the same technique in other cases where you want to share code but don’t want to introduce subclasses.

Use Child View Controller for code reuse

Child View Controller is another option for sharing code between View Controllers. For example, if you want to display a small player at the bottom of the view Controller folder, you can add a Child View Controller to the View Controller folder so that the logic of the player is included, And I don’t mess up the view Controller folder. This is much easier and more maintainable than repeating the relevant code in the folder View Controller.

If you have a single View Controller that contains two completely different states, you can also split it into two View Controllers (each controller manages one state). Use a container View Controller to switch between the two Child View Controllers. You can split the PlayViewController into two separate View Controllers: one that displays the text for “unselected recordings” and the other that displays the recordings. The container View Controller can switch between the two depending on the state. This approach has two advantages: First, the blank View Controller can be reused if the title (and some other properties) are written to be configurable. Second, the PlayViewController no longer has to handle the case when the recording is nil; Just create and display the recording when you have it.

To extract the object

Many large View Controllers have many roles and responsibilities. It’s not always easy to find a place to refactor, but often a role or responsibility can be extracted as a separate object. It makes sense to distinguish between Apple’s definition of a coordinating controller and a mediating controller. A coordination Controller is app specific and generally not reusable (for example, almost all View Controllers are coordination controllers).

A mediation controller is a reusable Controller object that can be configured to perform specific tasks. For example, the AppKit framework provides classes like NSArrayController or NSTreeController. In iOS, you can build something similar. Code that is usually used to comply with a protocol (such as the part of the folder view Controller that complies with the UITableViewDataSource) is better extracted as a mediation Controller. Separating the compliance code into separate objects can effectively reduce the amount of view Controller code. First, we can extract the data source of the Table View from the view Controller folder unchanged, and then, in the controller folder itself, Set the Table View’s data source to this new data source.

class FolderViewDataSource: NSObject.UITableViewDataSource { 
    var folder: Folder
    init(_ folder: Folder) { 
        self.folder = folder
    }
    func numberOfSections(in tableView: UITableView) -> Int { 
        return 1
    }
    // ...
 }
 
    lazy var dataSource = FolderViewDataSource(folder)
    override func viewDidLoad(a) { 
        super.viewDidLoad() tableView.dataSource = dataSource
        // ...
    }
Copy the code

You also need to observe the Folder in the View Controller and change the folder in the Data Source as it changes. At the expense of this extra communication, the View Controller can be divided into two parts. This separation does not impose much overhead, but when the two components are tightly coupled and need to communicate and share many states, the overhead can be significant, making things even more complicated.

If you have more than one similar object, you might be able to generalize them. In the FolderViewDataSource above, you can change the storage property from Folder to [Item] (an Item can be a Folder or a recording). Let the Data Source generically abstract the Element type and remove the item-related logic. Table cell configuration (via the configure parameter) and delete logic (via the Remove parameter) are now passed in from the outside

class ArrayDataSource<Element> :NSObject.UITableViewDataSource { 
// ...
    init(_ contents: [Element].identifier: @escaping (Element) - >String.remove: @escaping (_ at: Int) - > (),configure: @escaping (Element.UITableViewCell) - > ()) {
    // ...
    }
    // ...
}

// To configure it with our folders and recordings, we need the following code:
ArrayDataSource(folder.contents,
    identifier: { $0 is Recording ? "RecordingCell" : "FolderCell" }, 
    remove: { [weak self] index in
        guard let folder = self?.folder else { return }
        folder.remove(folder.contents[index]) }, 
    configure: { item, cell in
    cell.textLabel!.text = "\((item is Recording) ? "a" : "b" })
Copy the code

In the small sample app, generalizing the code didn’t bring much benefit. But in larger apps this technique can reduce duplicate code, allow cell reuse in a type-safe way, and make view Controllers simpler.

Simplify the View configuration code

If the View Controller needs to build and update a large number of views, it can be helpful to extract this part of the view configuration code. Especially for those cases where two-way communication is not required and “set up and forget about it”, this simplifies the View Controller. For example, when you have a very complex tableView(_:cellForRowAtIndexPath:), you can remove some of the code from the View Controller:

override func tableView(_ tableView: UITableView.cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let item = folder.contents[indexPath.row]
    let cell = tableView.dequeueReusableCell(withIdentifier: identifier,
for: indexPath)
    cell.configure(for: item) // The extracted code return cell
}

extension UITableViewCell { 
    func configure(for item: Item) {
        textLabel!.text = "\((item is Recording) ? "A" : "B") \(item.name)"}}Copy the code

You can also use this pattern to share configuration and layout code between different View Controllers. As you can easily see, configure(for:) now doesn’t depend on any state of the view controller. All states are passed in by parameters, making it easy to test the cell.