The new async/await Swift concurrency API released together with Swift 5.5 was finally unveiled during the WWDC 21 last June. This new architecture fulfilled all the expectations that we iOS developers had, who were eagerly waiting for this since the Swift first release in 2014, and whose hopes got boosted in 2017 with the Swift Concurrency Manifesto by Chris Lattner himself.
From all the new APIs and tools that were introduced, the new async/await syntax, Actors, and Tasks attracted most of the attention from the Swift community. And rightly so, these new additions improved considerably the readability of asynchronous code and the easiness when ensuring thread safety in our apps.
However, there was another mechanism introduced then that didn’t capture so much consideration even though they are very powerful when working together with async/await: Continuations.
Subscribe to get access
Read more of this content when you subscribe today.
Our iOS Team at WELT got recently very good news, we could set our App iOS Deployment Target to 13.0. That means not only that we should not support old versions anymore, but also that we can start using the cool APIs that were introduced in that version, especially SwiftUI and Combine.
With Combine we can write functional reactive code to process values over time through a declarative Swift API. It can be compared to other takes on this approach for Swift, such as RxSwift and ReactiveSwift.
Tip: If your app still cannot fully support Combine because the minimum deployment target is lower than iOS 13.0 don’t worry, you can start migrating it by using OpenCombine. According to their docs: The main goal of this project is to provide a compatible, reliable and efficient implementation which can be used on Apple’s operating systems before macOS 10.15 and iOS 13, as well as Linux and Windows.
That way, given that their API is exactly the same as Combine, it will ease the migration process to Combine in the future; ideally you will just have to replaceimport OpenCombine with import Combine.
Coming back to Combine, for now two concepts are essential for those familiar with the RxSwift syntax or similar:
Observables are Publishers in Combine. They expose values that can change.
Observers are Subscribers in Combine. They subscribe to receive all these updates.
With this in mind, let’s see one example where we can use it.
Combine and MVVM
Combine, as any other reactive framework, is especially adequate to implement a MVVM architecture: the View Model encapsulates the processing and exposes the UI data, that can change over time. The View subscribes to the View Model to receive these updates and react accordingly, refreshing the UI and showing the new values to the user. Together with all the chaining operators that the reactive frameworks provide, it makes the code more declarative and thus readable.
In this example, we want to show the user a sport event result. As it changes over time, reactive programming is ideal for our case.
The first version of our ResultViewModel with Combine would be something like this:
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Our View Model listens to the result updates from the fetcher implementing the delegate method, used for simplicity sake in our example (we could very well be using reactive programming in this layer as well). Once a new result has arrived, we use a PassthroughSubjectthat broadcasts new data to downstream subscribers, in this case the view, so it can accordingly refresh the UI:
Here, we subscribe to the View Model updates on the result, react refreshing the label when a new value is received always on the main thread, and store the AnyCancellable object in a Set, so we do not lose the reference.
The example above works fine, we are implementing the business logic as required: every time the result changes it is reflected in the UI. But correct as it is, the code is still very faulty.
Why? because with PassthroughSubject we are exposing the implementation details of our operation. That is bad by reason of generating a dependance between the client, in this case the View, and the implementation, making it easy to break by any change or modification in the latter. Imagine for instance that the View Model needs to keep a buffer of the most recently published element for tracking purposes. In that case we cannot use the PassthroughSubject, but CurrentValueSubject instead. The View Model API will change.
But more importantly, with PassthroughSubject, we give the power to the view to send events through it, something that is clearly not its task. It should just listen, not update it. Our code isn’t safe.
This is when AnyPublisher comes on the scene. According to the Apple docs:
Use AnyPublisher to wrap a publisher whose type has details you don’t want to expose across API boundaries, such as different modules. Wrapping a Subject with AnyPublisher also prevents callers from accessing its send(_:) method. When you use type erasure this way, you can change the underlying publisher implementation over time without affecting existing clients.
This is exactly what we want:
Hide type details
Prevent callers from accessing send(_:)
Be able to change the publisher implementation over time without affecting clients
Oh, but wait, we can still push it a little bit forward into the Abstraction Realm right? We can create a protocol for the View Model, thus hiding more details (the Data Fetcher for instance) and enhancing testability by replacing it easily with a mock when unit testing it. For more info about Protocol-Oriented Programming in Swift, check this link.
Consequently, we can already implement our new version of the View Model:
This way we are just exposing an AnyPublisher object, so the clients can subscribe but not update as intended. With eraseToAnyPublisher() we achieve exactly that, to expose an instance of AnyPublisher to the downstream subscriber rather than this publisher’s actual type, an action that we call Type Erasure: we hide implementation details by exposing a more abstract type, a technique that can be applied thanks to polymorphism. Now the View has the right access level to the Publisher.
Another Turn on The Screw?
As Henry Miller in his superb novella, you might still want to push it a little bit forward. Why not using Publisher instead of AnyPublisher? Isn’t it even more abstract? At least it sounds so …
The answer is no, Publisher and AnyPublisher are different things, with different purposes: with Publisher we cannot achieve Type Erasure as with AnyPublisher. The former is a protocol with associated types, therefore it can be used when you define a function that has generics as part of the definition, such as a Protocol Extension, e.g. when creating a custom Combine operator. An example from here:
Now that iOS 14 is released, it is more likely that you will be able to drop older iOS version, thus being able to migrate to (or start using) Combine. When doing that, we should as always be conscious of writing a readable, maintainable and safe code that uses Abstraction when possible. Apple goes in the same direction, and adds Type Erasure to our toolset. There is no excuse to avoid ensuring Abstraction when writing your implementation with Combine.
To recapitulate, in this article we have seen:
How we can ease the future reactive migration process to Combine by using OpenCombine with earlier iOS versions
How we can use Combine to implement a simple MVVM solution
How we can support Abstraction and Protocol-Oriented Programming with Combine
Why we cannot use Publisher in the place of AnyPublisher
I would like to thank my colleague Ivan Lisovyi for his splendid insight and contribution about this topic, without which this story would not have been possible. As always, if you have a question or contribution please drop a message below.