External Data Sources + Composition [Advanced iOS | Swift 5]

Today, we’re going to continue our work against the Massive View Controller problem.

We’ve all seen view controllers that implement tons and tons of protocols which is an obvious sign your code is doing too much. Today, we’ll focus on how we can remove the data source logic out of our view controller and into their own separate components.

This will alleviate a lot of the burden from our view controllers and allow us to create data source components that can be reused throughout our app and can be easily tested.

The Typical Approach

Here’s a typical ViewController that displays some albums in a list. We’re fetching a list of albums from JSON placeholder and converting to an [Album].

struct Album: Codable {
    let title: String
}

class AlbumsListService {
    func fetchData(completion: @escaping (([Album]) -> Void)) {
        guard let url = URL(string: "https://jsonplaceholder.typicode.com/albums") else { return }
        URLSession.shared.dataTask(with: url) { (data, response, error) in
            if let error = error {
                print("An unknown error occured: \(error.localizedDescription)")
            }
            
            if let data = data, let albums = try? JSONDecoder().decode([Album].self, from: data) {
                completion(albums)
            }
        }.resume()
    }
}

class AlbumsListDataSource: NSObject, UITableViewDataSource {
    private var dataSource = [Album]()

    func updateAlbums(_ albums: [Album]) {
        self.dataSource = albums
    }

    func numberOfSections(in tableView: UITableView) -> Int {
        1
    }

    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        dataSource.count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        guard let reuseableCell = tableView.dequeueReusableCell(withIdentifier: "CellIdentifier") else {
            return UITableViewCell()
        }
        reuseableCell.textLabel?.text = dataSource[indexPath.row].title
        return reuseableCell
    }
}

It’s a pretty typical implementation – nothing crazy so far.

But, what if we want to show this same list in different locations throughout our app?

With this approach, we’d have to effectively duplicate this data source implementation everywhere else we intended to show this list. That’s clearly going to be hard to maintain at scale and we can see that we’d be violating the SRP (Single Responsibility Principle) and the DRY principle (Don’t Repeat Yourself).

So, regardless of whether we want to use the same data source in other locations in our app or whether adding this logic to our view controller adds too much responsibility, we’ll benefit from extracting this logic into a custom UITableViewDataSource object.

Creating An External Data Source

So, the first thing we’re going to do is create a new class and have it inherit from both NSObject and UITableViewDataSource.

class AlbumsListDataSource: NSObject, UITableViewDataSource {

}

Now, we can bring over our original data source implementation from the view controller. We’ll also need to remove override because we’re no longer inheriting from a UITableViewController.

class AlbumsListDataSource: NSObject, UITableViewDataSource {
    private var dataSource = [Album]()
    
    func updateAlbums(_ albums: [Album]) {
        self.dataSource = albums
    }
    
    func numberOfSections(in tableView: UITableView) -> Int {
        1
    }
    
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        dataSource.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        guard let reuseableCell = tableView.dequeueReusableCell(withIdentifier: "CellIdentifier") else {
            return UITableViewCell()
        }
        reuseableCell.textLabel?.text = dataSource[indexPath.row].title
        return reuseableCell
    }
}

Then, in our AlbumsListViewController we can assign the table view’s data source to our new AlbumsListDataSource object.

class AlbumsListViewController: UITableViewController {
    // TODO: Should be introduced through dependency injection
    private var service = AlbumsListService()
    private var dataSource = AlbumsListDataSource()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        tableView.dataSource = dataSource
        service.fetchData { [weak self] albums in
            DispatchQueue.main.async {
                self?.dataSource.updateAlbums(albums)
                self?.tableView.reloadData()
            }
        }
    }
}

Now, there’s one important part of this implementation to call out.

A UITableView and UICollectionView only store a weak reference to their dataSource which means that it might be freed early. So, we’ll need to maintain a strong reference to it and provide that reference to the dataSource instead.

When we run our app, we’ll see that everything works as expected:

With this approach, we could have just as easily passed in an OfflineAlbumsDataSource or FavoriteAlbumsDataSource instead. Our view controller would continue to behave correctly without changing any of its internal code – now it is way more reusable than before.

Combining Data Sources

Now, these are pretty simple view controllers, what would we do if we had a UITableView with multiple sections in it?

In an app like Spotify, we might have a UITableView for showing our favorite songs, our queued songs, and our recommended songs respectively. However, we could just as easily imagine a UITableView that shows all of this same information as distinct sections inside of one table instead. Typically, we would handle this by having a fairly complicated dataSource and cellForRowAt() that specifies what cell to show in each section.

But now, with our new approach, we can easily composite existing UITableViewDataSources together instead. I’ve gone ahead and created a few additional data sources that just return hard-coded arrays for now. Then, in our AlbumsListDataSource, I compose all of them together.

class OfflineAlbumsListDataSource: NSObject, UITableViewDataSource {
    private var dataSource = [Album(title: "Offline Album 1"), Album(title: "Offline Album 2"), Album(title: "Offline Album 1")]

    func numberOfSections(in tableView: UITableView) -> Int {
        1
    }

    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        dataSource.count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        guard let reuseableCell = tableView.dequeueReusableCell(withIdentifier: "CellIdentifier") else {
            return UITableViewCell()
        }
        reuseableCell.textLabel?.text = dataSource[indexPath.row].title
        return reuseableCell
    }
    
    func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
        "Offline"
    }
}

class FavoriteAlbumsListDataSource: NSObject, UITableViewDataSource {
    private var dataSource = [Album(title: "Favorite Album 1"), Album(title: "Favorite Album 2"), Album(title: "Favorite Album 1")]

    func numberOfSections(in tableView: UITableView) -> Int {
        1
    }

    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        dataSource.count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        guard let reuseableCell = tableView.dequeueReusableCell(withIdentifier: "CellIdentifier") else {
            return UITableViewCell()
        }
        reuseableCell.textLabel?.text = dataSource[indexPath.row].title
        return reuseableCell
    }
    
    func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
        "Favorite"
    }
}

class RecommendedAlbumsListDataSource: NSObject, UITableViewDataSource {
    private var dataSource = [Album(title: "Recommended Album 1"), Album(title: "Recommended Album 2"), Album(title: "Recommended Album 1")]

    func numberOfSections(in tableView: UITableView) -> Int {
        1
    }

    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        dataSource.count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        guard let reuseableCell = tableView.dequeueReusableCell(withIdentifier: "CellIdentifier") else {
            return UITableViewCell()
        }
        reuseableCell.textLabel?.text = dataSource[indexPath.row].title
        return reuseableCell
    }
    
    func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
        "Recommended"
    }
}

class AlbumsListDataSource: NSObject, UITableViewDataSource {
   
    private var dataSource: [UITableViewDataSource] = [OfflineAlbumsListDataSource(), FavoriteAlbumsListDataSource(), RecommendedAlbumsListDataSource()]
    
    func numberOfSections(in tableView: UITableView) -> Int {
        dataSource.count
    }
    
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        dataSource[section].tableView(tableView, numberOfRowsInSection: 0)
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        dataSource[indexPath.section].tableView(tableView, cellForRowAt: IndexPath(row: indexPath.row, section: 0))
    }
    
    func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
        dataSource[section].tableView?(tableView, titleForHeaderInSection: 0)
    }
}

class AlbumsListViewController: UITableViewController {
    // TODO: Should be introduced through dependency injection
    private var service = AlbumsListService()
    private var dataSource = AlbumsListDataSource()
    
    override func viewDidLoad() {
        super.viewDidLoad()
                
        tableView.dataSource = dataSource
        service.fetchData { [weak self] albums in
            DispatchQueue.main.async {
                self?.tableView.reloadData()
            }
        }
    }
}

Here’s the final output:

Preferably, AlbumsListDataSource would receive its child data source objects via dependency injection.

In this example, we’re still only returning standard UITableViewCells, but we could just as easily return custom cells instead. We’d then be able to create a new UITableViewController that can not only composite data sources together, but can easily handle dequeuing many custom cells without much additional code.

We can see with this simple change in approach, our UITableViewController‘s implementation has been greatly reduced and it’s far more readable. Plus, we now have data sources that we can easily re-use anywhere else in the app – either as a standalone resource or as part of a more complicated data source implementation.

Testing

External data sources make testing far easier too.

In my testing target, I could easily create a fake data source that returns static data so I can easily write my unit tests against it.

For example, I could create a MockDataSource class and pass it into our ViewController through dependency injection which we covered in a previous post.

Main App Target

class FavoriteSongsDataSource: NSObject, UITableViewDataSource {...}

let viewController = SongsListViewController(dataSource: FavoriteSongsDataSource())

Testing Target

// FavoriteSongsMockDataSource would return a hardcoded set of values
class FavoriteSongsMockDataSource: NSObject, UITableViewDataSource {...}

let viewController = SongsListViewController(dataSource: FavoriteSongsMockDataSource())

XCTAssert(viewController.tableView.numberOfRows, 10)

I hope you found this article useful. If you’d like to see more content like this consider checking out my YouTube channel about iOS and Swift Development.

This article is an excerpt from my book on iOS & Swift Development. Practical Tips for iOS Developers is a summary of everything I wish I knew when I was starting out and it’s completely free!