I’ve been spending the last several days discussing the differences between RxSwift and Apple’s new Combine framework:
- How do we get to
Observables
? - What is RxSwift anyway?
- Where are UIKit bindings in Combine?
- What’s new in Seed 2? Can KVO save us?
- When do we specify how things can go wrong?
Today, we’ll discuss backpressure.
An Illustrated Example
Do you remember this famous scene from I Love Lucy?
If you’re one of the six people on the planet who hasn’t seen it, Lucy and Ethel are attempting to wrap chocolates as they come down a conveyor belt. Before long, the chocolates come far faster than the women can handle, and things get interesting. And hilarious.
This short video is actually a phenomenal example of backpressure.
Backpressure
In the video above, the chocolates coming down the conveyor belt
are basically an Observable
/Producer
. The chocolates were
being emitted at whatever speed they wanted to be, and that was
that.
The conveyor/Observable
/Producer
operating at whatever speed
it sees fit makes for great comedy. However, it can make for some
complicated circumstances in code.
Let’s suppose you’re writing a banking iOS app. Perhaps somewhere in your app you have a mechanism for depositing a check by taking a picture of it. At the end of that process is a button which the user taps to actually commit the deposit.
A nefarious user wants to see if they can get some free money. They decide to mash down on that button a zillion times in a row, hoping your app won’t be smart enough to handle it appropriately. They hope that instead you’ll repeat the deposit a zillion times, and effectively make it rain for them. Suddenly, we have an I Love Lucy scenario: the button taps are coming down the conveyor belt far faster than we can handle them.
(Naturally there a zillion other ways to handle this — most notably immediately disabling the button — but just roll with me on this, m’kay?)
What Lucy needed, and what we need in this contrived example, is a way to say “I’ll take just one pleaseandthankyou”. We need a way to throttle the speed with which chocolates are sent down the conveyor belt, and clicks are sent down that stream.
I’ve Got Your Backpressure Right Here
RxSwift takes an interesting approach to backpressure.
🚨🚨🚨🚨🚨🚨🚨
RxSwift does not include any affordances
for dealing with backpressure.
🚨🚨🚨🚨🚨🚨🚨
In RxSwift, we would have been no better off than Lucy. Those chocolates would have kept coming, whether or not we could handle them. Some of the projects under the ReactiveX umbrella do handle backpressure, but RxSwift is not one of them. For more, you can read the official ReactiveX entry on backpressure. In short, it pretty much says “good luck”.
Combining Flow and Pressure
Likely unsurprisingly by now, Combine takes a different approach to backpressure: it’s built into the system.
Look at the [slightly simplified] definition for protocol Subscriber
:
protocol Subscriber {
associatedtype Input
associatedtype Failure : Error
// Notifies the subscriber that it has successfully subscribed
func receive(subscription: Subscription)
// Notifies the subscriber that there is a new element; the
// equivalent of RxSwift's onNext()
func receive(_ input: Self.Input) -> Subscribers.Demand
// Notifies the subscriber that it has completed; the
// equivalent of both RxSwift's onCompleted() and onError()
func receive(completion:)
}
Wait a second. In RxSwift’s Observer
, things looked a little different:
protocol Observer {
func onNext(Element)
func onComplete()
func onError(Error)
}
Do you see the difference there? I don’t mean the splitting out of
onError()
and onComplete()
. Look at the return types. Specifically,
look at the return types for receive(input:)
and onNext(Element)
:
// Combine
func receive(_ input: Self.Input) -> Subscribers.Demand
// RxSwift
func onNext(Element)
RxSwift’s onNext()
doesn’t return anything, whereas Combine’s
receive(input:)
does. So what the hell is Subscribers.Demand
‽
Simplified, here it is:
public enum Demand {
case unlimited
case max(Int)
}
There’s your backpressure.
When a Subscriber
is notified by a Producer
that there is a new
element available in Combine, the Subscriber
is expected to return a
Subscribers.Demand
. By doing so, the Subscriber
is indicating to
the Producer
how many more elements it’s willing to accept.
✅ ✅ ✅ ✅ ✅ ✅ ✅
Combine accounts for backpressure at its core.
✅ ✅ ✅ ✅ ✅ ✅ ✅
The number of elements a subscriber is willing to accept can be
effectively infinite (.unlimited
), or a specific number
(.max(1)
). In Lucy’s case, she may return .max(3)
, knowing she
can do about three chocolates at a time. In the case of our deposit
button handler, we may return .max(1)
, thereby preventing more
than one deposit.
[Still the] Same as It Ever Was
Just like the difference in error handling, there’s not really a clearly right or wrong answer between RxSwift’s and Combine’s approaches. Both projects have made design decisions, all of which are completely reasonable. To me, this is what makes engineering fun: balancing the pros and cons to different approaches and coming up with a solution that makes the best possible trade offs.
As with the discussion on error handling, the Combine approach leads to a little bit more bookkeeping, but a more robust solution.
In my experience, I can’t say I’ve had many occasions where I’ve
thought “oh man, I wish RxSwift had backpressure”. However, I can
pretty easily eliminate backpressure from Combine by simply using
Demand.unlimited
whenever I’m asked for a Demand
. Thus, this
design decision I find less bothersome than the choices Combine
makes about error handling. Handling backpressure is far less of
a bookkeeping burden.
Wrapping Up
In my next post, I’ll summarize the differences between the projects, and give a[n initial] ruling on what I plan to do in Vignette, and other projects going forward.