Apple introduced their Combine framework at the 2019 WWDC, alongside iOS 13, macOS 10.15, and, maybe most notably, SwiftUI. At the time, it seemed like there was quite a bit of excitement that Apple was giving us a first-party reactive programming solution.
A year later, in October 2020, the first Swift Concurrency roadmaps appeared in the Swift forums. And, at the following WWDC in 2021, we had the first full Swift Concurrency release…and no meaningful updates to Combine. Fast forward another year to WWDC 2022 and, yet again, no real Combine updates. During this whole saga, developers began (and continue to) speculate about whether Combine is dead, abandoned by Apple and replaced by Swift Concurrency.
So, is Combine dead? We don’t think so!
Are we using more and more Combine in our codebase? Yes!
Are we using Swift Concurrency as well? Yes!
Is this going to lead to architectural problems that we’ll regret? Hopefully not!
Let’s start with what Combine is built for (and good at): providing a system for responding to changes over time. It makes it easy to guarantee that you won’t “miss” an update by forgetting to notify a delegate somewhere. It is also great at manipulating that stream of data over time, doing things like debouncing, removing duplicates, combining or merging values, and more.
What is it not good at? Maybe its weakest point is how it integrates with other solutions — in particular, Combine doesn’t play particularly well with Swift Concurrency. But, by knowing when to use which tool (more on this later), it’s easy to avoid conflicts between the systems and have them play nicely together.
So, why don’t we think it’s dead and why do we think it’s safe to invest in? We aren’t concerned with the lack of updates to the framework for a few reasons:
It’s a mostly-feature-complete library. If you look at the standard API footprint of Rx-style libraries, Combine has this covered already. The standard Rx operators are there and have been there since its introduction.
It is heavily tied into SwiftUI, which Apple has made clear is the optimal way to build new apps (yes, this is controversial… that’s probably a topic for a different blog post).
It solves scenarios that Swift Concurrency doesn’t, such as having more than one subscriber to a stream of data, merging streams together, etc.
Responding to changes is not a new problem for programmers and there have been countless systems for dealing with this over time. In the Apple ecosystem, the longest-running and most notable is KVO. In terms of non-first-party solutions, a more recent framework is RxSwift (which our codebase also uses…and is also moving away from).
Our code, before a recent refactor, was riddled with KVO. In particular it was used to watch our reference-type model layer and respond to updates on the UI layer.
Here’s a sample of what that looked like:
So, why did we want to get rid of this? After all, it was (for the most part) working. One of the motivations was to have fewer reliances on the Objective-C runtime and the data types that it prefers. To use KVO, your models must be reference types, and, if they’re written in Swift, end up being annotated with lots of @objc. Swift tends to prefer value-type data structures and, while we aren’t doing a transition in the immediate future, this KVO reliance would be a roadblock. Also, while Remotion is mostly an AppKit app, as we add increasingly more SwiftUI, the NSObject and @objc annotations do nothing to help us in the world of SwiftUI. But, even if we don’t have struct-based models, having @Published Combine-y models are very usable in SwiftUI.
So, what does our code look like now?
Is it better? In fact, in some ways, it actually looks more complicated! But, it depends how you define it — the sample doesn’t show all of the @objc and NSObject and KVO callbacks that we got to remove. Overall, the PR to replace KVO with Combine in our app was +2000/-2400. It’s always nice to have a reduction in lines of code! Also, note that .removeDuplicates line — now there’s also sorts of interesting things that we can do with additional operators in this chain (like throttle, for another example), which is a huge win.
We did hit a couple of gotchas along the way, the biggest being Combine’s use of willSet on @Published properties vs. KVO’s use of didSet.
To illustrate this, let’s consider the following scenario:
Note that in this scenario, in order for the UI to be in-sync with the model, model.isActive must be set before updateUI is called.
But, what if we do this?
Well, because @Published properties fire on willSet instead of didSet, by the time updateUI runs and checks the value of model.isActive, it may still be the *previous* value. What are the solutions to this? For one, you can pass in dependent state into the downstream functions, like this:
Another solution is to use a Combine operator to delay the sink until the next run loop:
The receive(on:) operator will end up waiting until the next run loop, even if we’re already on the main queue. Because the Publisher was fired on willSet, by the end of the previous run loop, the value will be set, and we’re safe to refer to model.isActive in updateUI again.
Does this all sound complicated? It is! Is it easy to fall into traps where your UI is out of sync with your model? Maybe. One of the dangers is that this relies on developers remembering to write code in certain ways and not a static check from the compiler. As we move more towards SwiftUI, we’re moving towards what we think is a more guaranteed model/UI state hookup:
Here, we don’t have to worry at all about willSet vs didSet — we know that if our model has a certain state, our view will reflect it.
Gotchas like the above aside, we’ve been moving forward to add Combine in more and more areas of our codebase (in fact, it’s become a running joke with one another engineer on the team — last week I found myself saying “You know, eventually we’ll have a bug that I won’t fix with Combine). In particular, we are replacing more and more infrastructure that broadcasts or consumes ‘state’ of any type. For example, did you happen to catch our post on modulating music volume during conversations? It’s built with Combine.
Another area, which we’ve just begun to experiment with, is to translate our nested-reference-type model structure into something that is easier to read from our UI layer, which, as I mentioned earlier, has increasingly more SwiftUI. Here’s an example of what some of our current model layer looks like (a very abstract representation):
This presents some interesting challenges to monitor this effectively, especially because some of the nested properties (like Conversation and ConversationMedia) are Optionals (These objects, by the way, before our transition away from KVO, would’ve been NSObjects with properties monitored with KVO). SwiftUI doesn’t do well with this structure. Even though everything is an ObservableObject, because nested ObservableObjects don’t work without manually hooking up objectWillChange, things like this don’t work out of the box:
But, if all of the models were structs instead of objects, it would work. So, what do we need to do to make that translation happen and guarantee that it’s updated? Something like this:
This is certainly not trivial to implement or keep up, but it allows us to get a simple looking state model for the UI. It also makes the separate components relatively testable! I’ll note that the above scenario, by the way, is something that Swift Concurrency absolutely does not solve — this is a problem that fits perfectly into Combine’s wheelhouse.
We don’t think Combine is going away. We think that it’s a fantastic tool to solve certain types of problems. Are you using Combine in your projects? We’d love to hear your experiences! Tweet @jnpdx or @remotionco with your thoughts.