fbpx

Blogs from the Ranch

< Back to Our Blog

Agile Software Development: Architecture Patterns for Responding to Change – Part 3

Avatar

John Daub

This article series explores a coding approach we use at Big Nerd Ranch that enables us to more easily respond to change. In Part 1, I presented the example of a simplified Contacts app and how it might traditionally be implemented, along with disqualifying three common approaches used to respond to change. In Part 2, I introduced the first step in how code can be architected to be better positioned to respond to change. Here in Part 3, I’ll complete the approach.

Flows, Coordinators, and Delegates… oh my!

The approach presented in Part 2 is a good start, but where does PersonsViewControllerDelegate get implemented? Exactly how and where the delegate protocol is implemented can vary, just like any delegate implementation in typical iOS/Cocoa development. But if we take a fundamental Model-View-Controller (MVC) approach, it would be common for the Controller to implement the delegate. But now we’re getting into terminology overlap, so we’ve taken a slightly different approach with Coordinators, specifically a FlowCoordinator.

FlowCoordinator does what the name says: it coordinates flows within the app. The app has numerous stand-alone ViewControllers for each screen in the app, and the FlowCoordinator stitches them together along with helping to manage not just the UI flow but also the data flow. Consider a login flow (LoginFlowCoordinator): the login screen, which could flow to a “forgot password” screen or a sign-up screen, and finally landing on the main screen of the app after successful login. Or a Settings flow (SettingsFlowCoordinator), which navigates the user in and out of the various settings screens and helping to manage the data flow of the settings. Let’s rework the “show persons and their detail” part of the app to use a FlowCoordinator:

protocol PersonsViewControllerDelegate: AnyObject {
    func didSelect(person: Person, in viewController: PersonsViewController)
}

/// Shows a master list of Persons.
class PersonsViewController: UITableViewController {
    private var persons: [Person] = []
    private weak var delegate: PersonsViewControllerDelegate?

    func configure(persons: [Person], delegate: PersonsViewControllerDelegate) {
        self.persons = persons
        self.delegate = delegate
    }
      
    override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        let selectedPerson = persons[indexPath.row]
        delegate?.didSelect(person: selectedPerson, in: self)
    } 
}

// -----

protocol FlowCoordinatorDelegate: AnyObject { }

protocol FlowCoordinator {
    associatedtype DelegateType
    var delegate: DelegateType? { get set }
    var rootViewController: UIViewController { get }
}

// -----
 
protocol ShowPersonsFlowCoordinatorDelegate: FlowCoordinatorDelegate {
    // nothing, yet.
}

class ShowPersonsFlowCoordinator: FlowCoordinator {
    weak var delegate: ShowPersonsFlowCoordinatorDelegate?
    var rootViewController: UIViewController {
        return navigationController
    }
    private var navigationController: UINavigationController!
    private let persons = [
        Person(name: "Fred"),
        Person(name: "Barney"),
        Person(name: "Wilma"),
        Person(name: "Betty")
    ]
    
    init(delegate: ShowPersonsFlowCoordinatorDelegate) {
        self.delegate = delegate
    }
    
    func start() {
        let personsVC = PersonsViewController.instantiateFromStoryboard()
        personsVC.configure(persons: persons, delegate: self)
        navigationController = UINavigationController(rootViewController: personsVC)
    }   
}

extension ShowPersonsFlowCoordinator: PersonsViewControllerDelegate {
    func didSelect(person: Person, in viewController: PersonsViewController) {
      let personVC = PersonViewController.instantiateFromStoryboard()
      personVC.configure(person: person)
      navigationController.pushViewController(personVC, animated: true)
    } 
}

FlowCoordinator protocol defines a typical base structure for a Flow Coordinator. It provides a means to get the rootViewController, and also a delegate of its own. The FlowCoordinator pattern does not demand a delegate, but experience has proven it a handy construct in the event the FlowCoordinator needs to pass information out (e.g. back to its parent FlowCoordinator).

ShowPersonsFlowCoordinator.start()s by creating the initial ViewController: a PersonsViewController. It is of some debate if initial FlowCoordinator state should be established within init() or a separate function like start(); there are pros and cons to each approach. You can see here we also now have the FlowCoordinator owning the data source (the array of Persons), which is a more correct setup. Then the data to display and delegate are injected into the PersonsViewController immediately after instantiation and before the view loads. Now when a user views a PersonsViewCoordinator and selects a Person, its PersonsViewControllerDelegate is invoked. As ShowPersonsFlowCoordinator is the delegate, it implements the instantiation of and navigation (flow) to the PersonViewController to show the Person in detail.

To implement the other tab, create a ShowGroupsFlowCoordinator. It start()s by instantiating the PersonsViewController, and the delegate didSelect can push the GroupsViewController. We’re done. We’ve made the PersonsViewController have a single responsibility, unaware of its surroundings, with dependencies injected, messages and actions delegated. This creates a thoughtful architecture, delivering quicker, with less complication, and a more robust, reusable codebase.

The bigger picture

Stepping back and looking at the application as a whole, there are additional improvements that can be made to help with factoring, flow, and coordination.

Too often, AppDelegate gets overloaded with application-level tasks, instead of purely UIApplicationDelegate tasks. Having an AppCoordinator avoids the “massive App Delegate” problem, enables the AppDelegate to remain focused on UIApplicationDelegate-level matters, factoring application-specific handling into the Coordinator. If you’re adopting UIScene/UISceneDelegate, you can adopt a similar approach. The AppCoordinator could own shared resources, such as data sources, as well as owning and establishing the top-level UI and Flows. It might be implemented like this:

@UIApplicationMain
class AppDelegate: UIResponder {
    var window: UIWindow? = {
        UIWindow(frame: UIScreen.main.bounds)
    }()

    private lazy var appCoordinator: AppCoordinator = {
        AppCoordinator(window: self.window!)
    }()
}

extension AppDelegate: UIApplicationDelegate {
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        appCoordinator.start()
        return true
    }

    func applicationWillResignActive(_ application: UIApplication) { }
    func applicationDidEnterBackground(_ application: UIApplication) { }
    func applicationWillEnterForeground(_ application: UIApplication) { }
    func applicationDidBecomeActive(_ application: UIApplication) { }
    func applicationWillTerminate(_ application: UIApplication) { }
}

// -----

class AppCoordinator: FlowCoordinator {
    weak var delegate: FlowCoordinatorDelegate?  // protocol conformance; the AppCoordinator is top-most and does not have a delegate.
    private let window: UIWindow
    
    var rootViewController: UIViewController {
        guard let rootVC = window.rootViewController else {
            fatalError("unable to obtain the window's rootViewController")
        }
        return rootVC
    }
    
    private var personDataSource: PersonDataSourceable!
    private var showPersonsFlowCoordinator: ShowPersonsFlowCoordinator!
    private var showGroupsFlowCoordinator: ShowGroupsFlowCoordinator!
    
    init(window: UIWindow) {
        self.delegate = nil // emphasize that we do not have a delegate
        self.window = window
        establish()
    }
    
    func start() {
        // Typically a FlowCoordinator will install their first ViewController here, but
        // since this is the app's coordinator, we need to ensure the root/initial UI is
        // established at a prior time.
        //
        // Still, having this here is useful for convention, as well as giving a clear
        // point of instantiation and "starting" the AppCoordinator, even if the implementation
        // is currently empty. Your implementation may have tasks to start.
    }
    
    private func establish() {
        establishLogging()
        loadConfiguration()
        
        personDataSource = PersonDataSource() // shared data resource

        showPersonsFlowCoordinator = ShowPersonsFlowCoordinator(dataSource: personDataSource, delegate: self)
        showPersonsFlowCoordinator.start()
        
        showGroupsFlowCoordinator: ShowGroupsFlowCoordinator(dataSource: personDataSource, delegate, self)
        showGroupsFlowCoordinator.start()

        // abbreviated code, for illustration.
        let tabBarController = UITabBarController(...)
        tabBarController.setViewControllers([showPersonsFlowCoordinator.rootViewController,
                                            showGroupsFlowCoordinator.rootViewController], animated: false)
        window.rootViewController = tabBarController
        window.makeKeyAndVisible()
    }
}

extension AppCoordinator: ShowPersonsFlowCoordinatorDelegate { }
extension AppCoordinator: ShowGroupsFlowCoordinatorDelegate { }

Other Tidbits

Storyboard Segues

Storyboard segues create tight couplings: in the storyboard file itself, in the prepare(for:sender:) function since it must exist within the ViewController being transitioned from. We are striving to create loose couplings with flexible routing. Thus, segues generally are avoided with this approach.

Singletons

The use of dependency injection – that typically the Coordinator might own a resource and then “pass the baton” in via configure() and data out via delegation – all of this tends to avoid the use of singletons and the issues they can bring.

I’m not anti-singleton. They must be used carefully, as they can complicate unit testing and make modularity difficult.

That said, I have encountered times using this design where the baton passing was heavy-handed. Some nested child Coordinator was the only thing that needed some resource, and that resource was owned somewhere at the top of the chain. Then all things in between had to be modified, just to pass the baton down. Such is the trade-off; and is more exception than rule.

Coda

This isn’t a perfect solution to all things (as you can see, there’s some variance and adaptability allowed). However, it’s a solution that has worked well for us across a great many projects at Big Nerd Ranch.

As development on Apple platforms evolves, due to technologies like Combine and SwiftUI, we’ll evolve our approaches to enable us to leverage new technology while maintaining strong foundational principles of software development.

Hopefully, it can work well for you and your projects.

Avatar

John Daub

Not Happy with Your Current App, or Digital Product?

Submit your event

Let's Discuss Your Project

Let's Discuss Your Project