DEV Community

Cover image for Migrating Asynchronous Code to Combine
Kilo Loco
Kilo Loco

Posted on • Originally published at kiloloco.com

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.

App Screenshot

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

    ...
}

...
  1. cellTokens will allow us to keep a reference to each sink so it stays alive and can observe values.
  2. We set the resulting sink to the associated IndexPath.
  3. Switching on the action allows us to handle both our AnimalCell.Action's, and acts as a reminder to update our sink's logic if we do add another Action 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 😘

Latest comments (0)