Migrating Asynchronous Code to Combine
Allow me to set the stage before we jump in.
Let's say we have an app that shows a list of cells displaying an animal name and two buttons: one to show the animal emoji and the other to make the sound of that animal.
Designer was paid in equity 😛
Just like any real world app, we have similar features that have been implemented differently because everyone on the team seems to think their way is best 🤡
/// AnimalsViewController.swift
class AnimalsViewController: UITableViewController {
...
func getAnimals() {
NetworkingService.getAnimals { [weak self] (result) in
switch result {
case .success(let animals):
self?.animals = animals
self?.tableView.reloadData()
case .failure(let error):
print(error)
}
}
}
...
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
...
animalCell?.delegate = self
animalCell?.shouldMakeNoiseForAnimal = { [weak self] animal in
self?.makeNoise(for: animal)
}
...
}
...
}
extension AnimalsViewController: AnimalCellDelegate {
func shouldShowEmoji(for animal: Animal) {
showEmoji(for: animal)
}
}
This TableViewController
is responsible for retrieving the Animal
's from a server and implementing the logic for when a user taps on either the "Show Emoji" button or the "Make Noise" button.
/// NetworkService.swift
enum NetworkingService {
static func getAnimals(completion: @escaping (Result<[Animal], Error>) -> Void) {
let animals: [Animal] = [.dog, .cat, .frog, .panda, .lion]
completion(.success(animals))
}
}
When calling the networking service, we pass in a closure that acts as a callback for when the server has returned the Animal
data.
/// AnimalCell.swift
protocol AnimalCellDelegate: AnyObject {
func shouldShowEmoji(for animal: Animal)
}
class AnimalCell: UITableViewCell {
...
weak var delegate: AnimalCellDelegate?
var shouldMakeNoiseForAnimal: ((Animal) -> Void)?
@IBAction func didTapShowEmojiButton() {
delegate?.shouldShowEmoji(for: animal)
}
@IBAction func didTapMakeNoiseButton() {
shouldMakeNoiseForAnimal?(animal)
}
...
}
The AnimalCell
is where we have two different ways to do the same thing; pass the Animal
object when a button is tapped. The first way is in didTapShowEmojiButton
which is using the delegate pattern to send the Animal
object to our AnimalsViewController
. The other is in didTapMakeNoiseButton
which is using a callback to send the animal to AnimalsViewController
.
So as we can see, we have a few examples of asynchronous code in this app. One important difference to understand between these types of communication is that the "networking request" that we are performing is only going to give us a single result. Either we will end up with a success and get our array of Animal
's, or we will get an error, and that's it.
If the server is down, and sends an error, but one second later it is up and can send the array of Animal
's, it wont do so until another request is made. It is in these circumstances where we are only expecting a single result to come back, that we want to work with Future
.
Futures
Future
is a publisher that is designed to return the result of a Promise
. A Promise
is almost identical to a callback with a Swift.Result
, you write some control flow, then return a Promise
by doing something like promise(.success(myObject))
. Future
is initialized with a Promise
closure and then is treated like any other Publisher
where you must use a sink
to get the value.
Let's take a look at this in action by replacing our getAnimals
method in NetworkingService.swift
:
... // enum NetworkingService {
static func getAnimals() -> Future<[Animal], Error> {
return Future { promise in
let animals: [Animal] = [.dog, .cat, .frog, .panda, .lion]
promise(.success(animals))
}
}
... // NetworkingService closing }
There is clearly a lot of similarity between our former function signature with a callback and our new function signature that returns a Future
. We created our Future
by passing in a closure that takes a Promise
, and we pass either a success
or a failure
to our promise once we have determined the correct result to return; in this case, success(animals)
.
Back in our AnimalsViewController
we are going to start getting errors since the function signature of NetworkingService.getAnimals
has changed. To fix these errors, we simply handle our newly returned Future
just like we would handle any other Publisher
.
...
var getAnimalsToken: AnyCancellable?
func getAnimals() {
getAnimalsToken = NetworkingService.getAnimals()
.sink(
receiveCompletion: { completion in
switch completion {
case .finished:
print("Subscription finished")
case .failure(let error):
print("Error getting animals -", error)
}
},
receiveValue: { [weak self] animals in
self?.animals = animals
self?.tableView.reloadData()
}
)
}
...
So now we have a sink
for our Future
Publisher
which takes in two closures: receiveCompletion
and receiveValue
. We use receiveCompletion
to handle any errors that may have occured as well as do any additional logic once the subscription has finished. The receiveValue
block is more straightforward by simply passing in the expected value; in our case, an array of Animal
objects.
Another thing we need to keep in mind is that sink
's will fall out of memory if we don't keep a reference to them. That's where our little buddy getAnimalsToken
comes in.
PassthroughSubjects
The difference between the "networking request" and the button taps coming from our cell is that there can be an infinite number of taps/values that are passed down stream from our buttons. When we are working with multiple results and don't know if/when they will stop coming, we need to use PassthroughSubject
.
Unlike Future
, the PassthroughSubject
subscription will not finish after the first result is sent, but instead requires that a Completion<Failure>
is manually sent down the stream if we want it to finish.
Let's take a look at this in action by replacing our delegate and callback code in AnimalCell.swift
with PassthroughSubject
.
...
var showEmojiPublisher = PassthroughSubject<Animal, Never>()
var makeNoisePublisher = PassthroughSubject<Animal, Never>()
@IBAction func didTapShowEmojiButton() {
showEmojiPublisher.send(animal)
}
@IBAction func didTapMakeNoiseButton() {
makeNoisePublisher.send(animal)
}
...
It's a pretty straightforward swap. We replace the delegate
and shouldMakeNoiseForAnimal
properties with PassthroughSubject
's that can be subscribed to through sink
's at the call site. Then we simply send the Animal
through the respective Publisher
whenever either button is tapped.
While replacing our two original forms of communication with two PassthroughSubject
's would work, it is less than ideal because we would need to hang onto a reference for both sink
's in every cell. It's also kinda smelly cuz they're the same type of Publisher
with different names.
Let's create a nested Action
enum that we can send to the Publisher
while still maintaining context as to what should be happening when each Action
comes down stream.
...
enum Action {
case showEmoji(Animal)
case makeNoise(Animal)
}
var actionPublisher = PassthroughSubject<Action, Never>()
...
@IBAction func didTapShowEmojiButton() {
actionPublisher.send(.showEmoji(animal))
}
@IBAction func didTapMakeNoiseButton() {
actionPublisher.send(.makeNoise(animal))
}
...
Our new actionPublisher
will publish AnimalCell.Action
's and now we only have one sink
to hang onto for each cell in our table view.
Time to update cellForRowAt indexPath
in AnimalsViewController.swift
:
...
// 1
var cellTokens = [IndexPath: AnyCancellable]()
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
...
// 2
cellTokens[indexPath] = animalCell?.actionPublisher
.sink { [weak self] action in
// 3
switch action {
case .showEmoji(let animal):
self?.showEmoji(for: animal)
case .makeNoise(let animal):
self?.makeNoise(for: animal)
}
}
...
}
...
cellTokens
will allow us to keep a reference to eachsink
so it stays alive and can observe values.- We set the resulting
sink
to the associatedIndexPath
. - Switching on the
action
allows us to handle both ourAnimalCell.Action
's, and acts as a reminder to update oursink
's logic if we do add anotherAction
in the future.
Our app should now continue to work as it did before, and now our way is best.
Welcome to 10x Engineer status 🙌🏽
Conclusion
Combine is pretty slick, and offers us an opportunity to consolidate all of our communication patterns into a single pattern. Also, everybody and their mom is using reactive frameworks, so might as well get comfortable with the first party version as it is likely to become the standard for native iOS development.
Now go out there, convince your boss that you need to refactor all your code, and do so passionately 😘