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

Download the example project NewspaperExample. 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

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. Right-click the controller group and use New file... to create a View (XIB) for StoryViewController: 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. Configure the controller class for the file owner: Connect the File owners outlet to the text view and the view property to the top level view in the XIB: 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 } } } 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 ) } } 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" ) // ... } Remove the Main.storyboard from the project. Remove the storyboard from the target configuration: 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 } } // ... } 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

Rename the AppDelegate class to NewspaperApp to make clear that this class is responsible for representing the app and creating/holding application-wide objects. 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 } // ... } Remove the singleton property default from NewsService.

Use protocols to make the implementation interchangable

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

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 ]) } } 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 } }

Example project