Managing controller and component dependencies in iOS projects

Today I want to show you how to manage application-wide components of an iOS application like services to fetch data and how to configure controllers with such components. This allows to use mock objects when writing unit tests and thus enables testability of controller classes.

The goal is to provide controller objects with their dependent objects by using initializer parameters. For this the Main.storyboard of the example project will be replaced with XIB files. A lightweight and very practical way to provide controllers with their dependencies without hurting testability will be demonstrated. The whole approach is inspired by the dependency injection principle without introducing the complexity of using a framework.

Example project

  1. Download the example project NewspaperExample.

  2. Make yourself familiar with the implementation of the NewsService and the two controller classes StoriesTableViewController and StoryViewController.

Use XIBs instead of storyboards to pass dependencies using initializer arguments

  1. The problem with storyboards: Controllers are created internally by UIKit when loading controllers from the storyboard - this breaks the proven concept of passing objects their dependencies when they are constructed.

  2. Right-click the controller group and use New file... to create a View (XIB) for StoryViewController:

    New File » View

  3. To convert existing view from a storyboard to the XIB file, open the XIB file in the assistent editor besides the storyboard and drag the views over. You can use this here to create the text view or just create a new Text View.

  4. Configure the controller class for the file owner:

    Xib File Owner Class
  5. Connect the File owners outlet to the text view and the view property to the top level view in the XIB:

    Xib File Owner: Connect Outlets
  6. Define an initializer for the StoryViewController to pass in the ID of the article and the newsService. Also add the required init?(coder:) initializer:

    class StoryViewController : UIViewController {
    
            let articleId : Int
    let newsService : NewsService
        
            init(articleId : Int, newsService : NewsService) {
                self.articleId = articleId
                self.newsService = newsService
                super.init(nibName: nil, bundle: nil)
        }
    
            required init?(coder aDecoder: NSCoder) {
            fatalError("Not supported")
    }
    
            // ...
    
            override func viewDidLoad() {
                    super.viewDidLoad()
    
                    newsService.getArticle(id: self.articleId) { article in
                            self.article = article
                    }
            }
    
    }
  7. In StoriesTableViewController remove the prepare(for segue:) method and implement the UITableViewDelegate tableView(didSelectRowAt:) method. Initialize and push the controller here:

    class StoriesTableViewController: UITableViewController {
    
        // ...
    
        // MARK: - protocol UITableViewDelegate
    
        override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        let data = headlines[indexPath.row]
        let controller = StoryViewController(articleId: data.id, newsService: NewsService.default)
        self.navigationController?.pushViewController(controller, animated: true)
    }
    
    }
  8. In StoriesTableViewController register the UITableViewCell for the table view cell reuse identifier LabelCell so the controller class works without the storyboard:

    override func viewDidLoad() {
        super.viewDidLoad()
    
        self.tableView.register(UITableViewCell.self, forCellReuseIdentifier: "LabelCell")
    
        // ...
    }
  9. Remove the Main.storyboard from the project.

  10. Remove the storyboard from the target configuration:

    Main Interface Target
  11. Add an initializer to pass in the newsService in StoriesTableViewController:

    class StoriesTableViewController: UITableViewController {
    
        var newsService : NewsService
        
        init(newsService : NewsService) {
        self.newsService = newsService
        super.init(nibName: nil, bundle: nil)
    }
    
        required init?(coder aDecoder: NSCoder) {
        fatalError("Not supported")
    }
    
        // ...
    
        override func viewDidLoad() {
            // ...
    
            newsService.getHeadlines { headlines in
                self.headlines = headlines
            }
        }
        
        // ...
    
    }
  12. Create a method createWindow and use it in the AppDelegate didFinishLaunching-method to initialize the application window:

    class AppDelegate: UIResponder, UIApplicationDelegate {
        var window: UIWindow?
    
        func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    
            self.window = createWindow()
    
            return true
        }
    
        private func createWindow() -> UIWindow {
        let window = UIWindow(frame: UIScreen.main.bounds)
        let controller = StoriesTableViewController(newsService: NewsService.default)
        let navController = UINavigationController(rootViewController: controller)
        window.rootViewController = navController
        window.makeKeyAndVisible()
        return window
    }
    
    }

Create an App object to hold app-wide components

  1. Rename the AppDelegate class to NewspaperApp to make clear that this class is responsible for representing the app and creating/holding application-wide objects.

  2. Create a NewsService instance in NewspaperApp and use it instead of the singleton property:

    @UIApplicationMain
    class NewspaperApp: UIResponder, UIApplicationDelegate {
        var window: UIWindow?
    
        private var newsService = NewsService()
        
        // ...
        
        private func createWindow() -> UIWindow {
            let window = UIWindow(frame: UIScreen.main.bounds)
            let controller = StoriesTableViewController(newsService: self.newsService)
            let navController = UINavigationController(rootViewController: stories())
            window.rootViewController = navController
            window.makeKeyAndVisible()
            return window
        }
    
        // ...
        
    
    }
  3. Remove the singleton property default from NewsService.

Use protocols to make the implementation interchangable

  1. Create a protocol for NewsService, rename the implementation to ExampleNewsService and make it conform to the protocol:

    protocol NewsService {
        func getHeadlines(resultHandler : ([Headline]) -> Void)
        func getArticle(id : Int, resultHandler : (Article) -> Void)
    }
    
    class ExampleNewsService : NewsService {
    
    // ...
    
    }

Make the app object instead of the controllers responsible for the flow between controllers

  1. In the StoriesTableViewController create an event handling block property for the selection of the headline:

    class StoriesTableViewController: UITableViewController {
    
        let newsService : NewsService
        var onHeadlineSelected : ((Headline) -> Void)?
    
        // ...
    
        // MARK: - protocol UITableViewDelegate
    
        override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
            onHeadlineSelected?(headlines[indexPath.row])
        }
    
    }
  2. Create dedicated factory methods in NewspaperApp to create the controllers and to bring them together. Also keep the navigationController to manage the navigation of the application:

    @UIApplicationMain
    class NewspaperApp: UIResponder, UIApplicationDelegate {
    
        var window: UIWindow?
    
        private var newsService = ExampleNewsService()
        private var navigationController : UINavigationController!
    
        func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    
            self.window = createWindow()
    
            return true
        }
    
        private func createWindow() -> UIWindow {
            let window = UIWindow(frame: UIScreen.main.bounds)
            let controller = controllerForStories()
            let navController = UINavigationController(rootViewController: controller)
            self.navigationController = navController
            window.rootViewController = navController
            window.makeKeyAndVisible()
            return window
        }
    
        private func controllerForStories() -> StoriesTableViewController {
        let controller = StoriesTableViewController(newsService: self.newsService)
        controller.onHeadlineSelected = { (headline) in
            self.navigationController?.pushViewController(self.controller(forArticleId: headline.id), animated: true)
        }
        return controller
    }
        
        private func controller(forArticleId id : Int) -> StoryViewController {
        let controller = StoryViewController(articleId: id, newsService: self.newsService)
        return controller
    }
    }
Btn download 3c20f11b8e Download the finished example project
Btn read 3c0e607615 iOS Developer Blog
Btn subscribe 930758687e Subscribe: Email · Twitter
Btn training bbbdf557d2 Next iOS training: 25. Februar - 01. März 2019, Stuttgart
Btn about 5378472193 About me · Contact