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!