Published properties in Swift
Discover page available: CombineReactive programming has become increasingly popular within the Apple developer community over the last few years, and the introduction of Apple’s own Combine framework during WWDC 2019 is likely going to further accelerate that growth in popularity for years to come.
Part of the magic of Combine is that it isn’t just another reactive programming framework. While it does use patterns and APIs that are very similar to other reactive frameworks, such as RxSwift and ReactiveSwift, it also makes heavy use of several new Swift features (as well as a dash of compiler magic) to make reactive programming more approachable in a few key ways.
However, using Combine requires us to drop support for Apple’s previous OS versions, which is many cases might be somewhat of a “deal breaker”. So this week, let’s take a look at one of Combine’s more interesting aspects — published properties — and how it’s definitely possible to adopt that pattern even without access to Combine itself.
The magic of observable objects
Apart from being a stand-alone framework, Combine also plays a very important role in the declarative machinery that powers SwiftUI — especially when it comes to how the system can automatically re-render parts of our UI when its underlying data changes.
A key part of that system is the ObservableObject
protocol, which enables us to mark any class as being observable. That, along with the @Published
property wrapper, lets us easily construct types that emit signals whenever some of their properties were changed.
For example, here’s how we could use those two tools to define a ProfileViewModel
which notifies its observers whenever its state
was modified:
class ProfileViewModel: ObservableObject {
enum State {
case isLoading
case failed(Error)
case loaded(User)
}
// Simply marking a property with the @Published property wrapper
// is enough to make the system emit observable events whenever
// a new value was assigned to it.
@Published private(set) var state = State.isLoading
...
}
The above is really all that it takes to make an object observable through Combine — which is quite remarkable — as the compiler will automatically synthesize an objectWillChange
publisher (a Combine object which can be observed), and all of the code needed to bind our @Published
-marked properties to that publisher.
When using SwiftUI, we can then use another property wrapper, @ObservedObject
, to in turn bind any ObservableObject
to our UI — which will make SwiftUI update our view on every change to that object’s published properties:
struct ProfileView: View {
@ObservedObject var viewModel: ProfileViewModel
var body: some View {
// Construct our UI based on the current state
...
}
}
However, since Combine isn’t just a part of SwiftUI, but also a completely stand-alone framework, we can also use it within other contexts as well — for example when using UIKit or AppKit to build our UI.
While we won’t get those nice declarative data bindings for free outside of SwiftUI, we can still use the power of Combine itself by subscribing to any observable object’s objectWillChange
publisher directly — and then update our UI accordingly, like this:
class ProfileViewController: UIViewController {
private let viewModel: ProfileViewModel
private var cancellable: AnyCancellable?
...
override func viewDidLoad() {
super.viewDidLoad()
cancellable = viewModel.objectWillChange.sink { [weak self] in
self?.render()
}
}
private func render() {
switch viewModel.state {
case .isLoading:
// Show loading spinner
...
case .failed(let error):
// Show error view
...
case .loaded(let user):
// Show user's profile
...
}
}
}
Note that we need to keep track of the cancellable object that Combine returns when we start our subscription using sink
, since that subscription will only remain valid for as long as the returned AnyCancellable
instance is retained.
However, there is one major issue with our above implementation — and that’s that our observation will be triggered before our view model is updated, given that we’re subscribing to its objectWillChange
publisher. That means that we’ll always render our view model’s previous state, rather than the new one, which isn’t great.
Thankfully, there is one more built-in way that we can observe our view model, without having to write any custom observation code — and that’s by attaching our subscription to the state
property itself.
To do that, we’re going to access the @Published
property wrapper’s projected value (by prefixing its name with $
), which gives us access to a Combine publisher just for that property. We can then subscribe to that publisher using the same sink
API as before — only this time our closure will get passed the property’s new value, which makes our rendering code work as we’d expect:
class ProfileViewController: UIViewController {
...
override func viewDidLoad() {
super.viewDidLoad()
cancellable = viewModel.$state.sink { [weak self] state in
self?.render(state)
}
}
private func render(_ state: ProfileViewModel.State) {
...
}
}
So Combine can be an incredibly useful tool even outside the realm of SwiftUI — as it enables us to set up custom data flows and bindings, while still taking advantage of ObservableObject
and the very lightweight way in which it enables us to make our models (and other types) observable.
Just a backport away
However, the issue remains that Combine is only available on the (at the time of writing) latest major versions of Apple’s various operating systems. On one hand, that’s definitely to be expected, as that’s always the case for frameworks that ship as part of the operating systems themselves. But on the other hand, it’s a bit of a shame that we’ll potentially have to wait years until we can start adopting Combine’s various patterns. Or do we?
After all, Combine is just Swift code (at least on the surface level), the property wrappers feature that @Published
is implemented with is a standard Swift language feature that any code can use — and since we’ve established that ObservableObject
(and the slight bit of magic that it employs to automatically bind our properties to its objectWillChange
publisher) is mostly useful within the context of SwiftUI — is there really anything stopping us from reimplementing part of that system ourselves?
Let’s give it a try! We’ll start with a quite bare-bones @propertyWrapper
implementation, which projects itself as its projectedValue
, and keeps track of a list of observation closures using a MutableReference
(which will later enable us to insert and remove observations using reference semantics), like this:
@propertyWrapper
struct Published<Value> {
var projectedValue: Published { self }
var wrappedValue: Value { didSet { valueDidChange() } }
private var observations = MutableReference(
value: List<(Value) -> Void>()
)
init(wrappedValue: Value) {
self.wrappedValue = wrappedValue
}
}
The above List
type was borrowed from “Picking the right data structure in Swift”, and the MutableReference
type comes from “Combining value and reference types in Swift”.
While we could’ve used a built-in data structure (such as Array
) to store our observations, using a linked list gives us simple O(1)
insertions and removals (while preserving the order of our elements), which in turn should prevent our new property wrapper from becoming a bottleneck when dealing with a large number of observations.
Next, let’s implement the valueDidChange
method that’s being called whenever our property wrapper’s wrappedValue
was modified — by simply iterating over all of our observation closures and calling them with our new value:
private extension Published {
func valueDidChange() {
for closure in observations.value {
closure(wrappedValue)
}
}
}
Now, before we implement our actual observation code, we’ll need to decide how we want to invalidate each observation (so that they get deallocated along with the objects that triggered them). While there are a number of different approaches that we can take here, let’s mimmic Combine’s approach and use cancellation tokens which automatically cancel their observation when deallocated.
Here’s how such a token type could be implemented:
class Cancellable {
private var closure: (() -> Void)?
init(closure: @escaping () -> Void) {
self.closure = closure
}
deinit {
cancel()
}
func cancel() {
closure?()
closure = nil
}
}
Finally, let’s give our new implementation of @Published
a closure-based observation API, which will use the above Cancellable
type to invalidate observations once cancelled:
extension Published {
func observe(with closure: @escaping (Value) -> Void) -> Cancellable {
// To further mimmic Combine's behaviors, we'll call
// each observation closure as soon as it's attached to
// our property:
closure(wrappedValue)
let node = observations.value.append(closure)
return Cancellable { [weak observations] in
observations?.value.remove(node)
}
}
}
With the above in place, we can now go back to our ProfileViewController
and (with a few minor tweaks) achieve the exact same reactive UI implementation as we had before — only this time it’s fully compatible with iOS 12 and below:
class ProfileViewController: UIViewController {
private let viewModel: ProfileViewModel
private var cancellable: Cancellable?
...
override func viewDidLoad() {
super.viewDidLoad()
cancellable = viewModel.$state.observe { [weak self] state in
self?.render(state)
}
}
private func render(_ state: ProfileViewModel.State) {
...
}
}
Now all that we need to do is to make ProfileViewModel
backward compatible as well, which can simply be done by removing its conformance to ObseravableObject
(or at least making it conditional using the @available
attribute), and everything else will keep working the exact same way as before.
Remaining reactive with RxSwift
While our above @Published
implementation can act as a great starting point if we wish to adopt some of Combine’s patterns in a backward compatible way, it doesn’t really enable us to fully embrace reactive programming (unless we keep extending it with new capabilities). On the other hand, reactive programming is not universally adopted, and perhaps our current closure-based API is more than enough to cover our needs.
But let’s also explore what a fully reactive version of our new property wrapper might look like. To do that we’re going to enlist the help of the popular RxSwift framework, and implement our observations using its PublishSubject
type. We’ll also return that subject (as a read-only Observable<Value>
) as our property wrapper’s projectedValue
— like this:
import RxSwift
@propertyWrapper
struct Published<Value> {
var projectedValue: Observable<Value> { subject }
var wrappedValue: Value { didSet { valueDidChange() } }
private let subject = PublishSubject<Value>()
init(wrappedValue: Value) {
self.wrappedValue = wrappedValue
}
private func valueDidChange() {
subject.on(.next(wrappedValue))
}
}
With the above implementation in place, we can now use RxSwift’s many different APIs and reactive operators to transform and subscribe to our @Published
values, for example like this:
import RxSwift
class ProfileViewController: UIViewController {
private let viewModel: ProfileViewModel
private var disposeBag = DisposeBag()
...
override func viewDidLoad() {
super.viewDidLoad()
viewModel.$state
.subscribe(onNext: { [weak self] state in
self?.render(state)
})
.disposed(by: disposeBag)
}
...
}
Note that RxSwift ships with its own cancellation token system, called “disposables”, which means that we no longer need our own Cancellable
type when going this route.
There’s of course no right or wrong option when it comes to whether we should opt for the closure-based version or the one using RxSwift — both approaches have their own set of tradeoffs. Like with all technology decisions, it all comes down to what our requirements are, and whether either of the above two approaches will give us more benefits than what we’re paying to build and maintain them.
Conclusion
While Combine is a complex and powerful framework with a ton of different APIs and capabilities, the @Published
property wrapper is one of its core aspects when it comes to UI development specifically — as it lets us easily set up reactive data bindings between our models and our UI.
Combine might be limited to the latest versions of Apple’s operating systems, but we could still implement our own version of the @Published
property wrapper with support for either closure-based observations, frameworks like RxSwift, or something else. At the end of the day, even Apple’s own frameworks are implemented using the same kind of code that both you and I write on a daily basis — it’s just a matter of whether that’s code that we’re willing to maintain ourselves until we’re able to adopt the first party solution.
What do you think? Does the pattern of published properties appeal to you, and what do you think about the @Published
implementations presented in this article? Let me know — along with your questions, comments and feedback — either via Twitter or email.
Thanks for reading! 🚀