Adopting Reactive programming in iOS, finally
I have had strong reservations about Functional Reactive Programming on iOS since I first looked at it 4-5 years ago. But things have changed recently: I have been using RxSwift for a few months now. This post lays out the reasoning for my switch.
(Please excuse my use of the terms Functional Reactive Programming, Rx, RxSwift, and Reactive Extensions somewhat interchangeably. Rx is Reactive Extensions, and RxSwift is one of its implementations. FRP is a more evolved — and the original — idea that includes behaviors among other things, with an implementation or two in Haskell I believe. In this post I use FRP to include non-Rx implementations that are in the same vein as Rx, but don't follow its vocabulary, like ReactiveSwift).
Background🔗
Some reasons that I can remember for not relying on an iOS FRP library:
- Its usage seeps through the project codebase. Not only is it a glue layer, but also holds business logic. Sure, you can adopt it piecemeal and use it for binding or callbacks, but the real advantage of FRP is in hooking up disparate sources and "sinks" with logic that defines your app's behavior. For simpler use cases, you might as well use Promises. I had misgivings about giving up so much control to a 3rd party library.
- You can build the abstractions that FRP provides without using FRP. Using a discipline of design patterns, you can wrangle your codebase into a coherent structure. Old Cocoa developers like me phrase this as "you can achieve the same result with what Apple gave you", "this is over-engineering" or something that boils down to "it's all Turing complete, you know".
- FRP is hard. The mindset is very different; it is at a higher level of abstraction than the callback-based programming as I knew it. I tried learning it several times but gave up beyond simply using them as Promises.
- I keep waiting on Apple to do something about this problem. I am a fan of MVC, but surely they feel the pain of not having of a standard way of solving problems at a higher abstraction. Swift helped on the language front, now how about a nice framework?
- I am a debugging-with-a-debugger kind of guy. Wrapping my hand around async programming with all the
flatMap
s andcombineLatest
, and a stack trace that's stacked crazy high, made me throw up my hands when faced with "why is there no event in this subscription". Or "what's sending this event"? - What about performance? Reactive libraries have to regenerate objects and do some bookkeeping every time you
map
over the stream, and a lot more work is needed for more complex operators. - I keep wondering about the reliability and longevity of the library. Knowing what I know about async programming and thread safety, writing an FRP library over UIKit sounds like a daunting challenge. Locks, GCD, and KVO must make for some really complex code underneath the nice API we get from the library.
So, why the change of heart? Why did I adopt it for my side projects and at work.
A few reasons to start with, and then I'll explain in detail in the next sections.
- It's really hard to write code to maintain complex state logic. When you have a
DashboardViewController
with multiple child view controllers and view models that all need to communicate their states with each other, and with the business logic layer underneath, writing sensible code needs a lot of discipline. It's even harder to make sense of the logic spread across these objects when you review the code later. The functional part of FRP helps here. - Asynchronous logic is tough. Not in the sense of thread-safety, but more for questions such as: "will the cache be invalidated at the right time?", "are we sure we won't be making any bad network requests during logout?", "I want to refresh the users list from the API when the button is tapped or when the refresh control activates, using the latest keywords from the API, and the search text entered, but the controller should be using the cached version if available, and to not re-refresh before 15 minutes . These decisions need to be made across time, which makes it not just hard to write, but also to localize the logic in a single function.
- I have been gravitating towards functional programming. FRP libraries support writing your logic as chaining functions and using declarative programming.
- I am not that smart. Stateful, asynchronous programming puts a lot of strain on me. Trying to maintain multiple flags to juggle state doesn't come easy. I would rather pay the price of learning a higher-level abstraction like FRP once, rather than implement and maintain stateful logic from scratch every time, in every project. I want all the related high-level logic (e.g., for a single view's input/output) in one place rather than skipping through 3 files and 20 functions. I envy people who can keep all of the state in their head when there are more than 2 flags involved, that change over time. I can't.
- I started building applications in technologies like React, Clojure/Clojurescript, and Go, and dabbled in Elm and Haskell. Even though they all weren't addressing the same space as FRP, they helped me see the advantages of a higher-level abstraction than MVC. Clojure's core/async and the Reagent wrapper around React for CLJS were particularly enlightening.
RxSwift
is a very well-maintained library. It has a solid philosophy that derives from a well-proven pedigree of Rx implementations. It has a large user base, very thorough documentation, and plenty of relevant blog posts. Overall, I have become quite comfortable about its quality and longevity.- Debugging is a challenge with Rx, just like it always is. Part of is shifting the mindset to a stream-based programming. Another is knowing where to set the breakpoints. You can whittle down the stack trace visually, find the emitting/transforming closure, and put your breakpoint or
print
statements there. There is adebug
function for tracing, and another for reference counting. More than anything, you just need enough experience and mistakes, which will eventually give you the intuition to know where the problem is and to not make the mistakes in the first place. - Performance problems are tougher to handle. Profiling a stack trace that involves RxSwift via Instruments feels impossible. I try to gather a general feel from the profile results, and then try to pore over the code to figure out how I can reduce the work (
distinctUntilChanged
andshare
are often involved). I am building some intuition around this now.
After a few months of work in Rx at work and also a side project, I am convinced that the tradeoffs are in its favor.
What I get out of Reactive Programming🔗
Now to the meat of the post: why is FRP a genuinely meaningful addition to your iOS development toolkit.
Single communication paradigm🔗
- In FRP, communication happens via a stream. UI Events, data values, network responses, DB changes, delegate responses: they can all be seen as a sequence of information, including errors. There is so much of Computer Science that is effectively modeled and implemented as a sequence: all of networking, the graphics pipeline, computer hardware connected via buses, and code execution itself. Thinking of your architecture as a stream can be very intuitive, compared to callbacks.
- In a sequence, there is a publisher (what produces the sequence), and a subscriber (what consumes the sequence).
- Adopting a single pattern for communication simplifies the situation vastly. Compare this to using key-value observing, delegates,
NSNotification
s, callbacks, etc.
Even though Rx squishes the communication paradigm to a single concept "horizontally", it expands the concept "vertically", as in depth of the same concept: you can have many kinds of sequences (immediately available, empty, non-erroring, etc.), many kinds of publishers (cold/hot, replaying, etc.), and many kinds of observations (throttled, etc.) But this cost is worth paying: it's directly related to being able to build more sophisticated features with cleaner code. I knew just the bare minimum, and am still taking my time learning and applying them.
Clear abstractions for what the app does:🔗
The mechanics of an application can be reduced to:
- action (user input, database changes, network responses)
- modeled in FRP by publishing on a stream, which is exposed as an observable.
- reaction (transform data, save data, display data, transform UI)
- modeled as transforming an observable, and subscribing to it.
Rx gives easy abstractions to model actions and reactions.
For e.g.,
button.rx.tap
and Project.activeUser
are observables for button taps and a user being streamed from the database. These are things that you can then pass around.
Two examples of simple subscriptions are
button.rx.tap
.subscribe(onNext: { doSomethingWithTap() }
and
Project.activeUser
.map { $0.name }
.drive(label.text)
By giving declarative nouns to the Actions, Reactions and their transformations and compositions, building behavior is much clearer than in the imperative, non-reactive world. Reading this code and modifying it to add new behavior, is much easier.
Note that the input of actions, and output of reactions eventually have to talk to APIs outside your app's core. This means that you will have to "leave the monad" at this point, i.e, perform a side effect. For e.g., pushing a JSON response into an Observable exposed from APIClient
(input), or writing a transformed value from a subscription to the database (output). You have to take care of these at the extremities, but can leave your core logic as declarative FRP code.
Async abstraction🔗
Async programming is hard. Using lower-level primitives like locks and threads does not explain the logic of what's happening (and could possibly happen) over time. Queues and Operations help, but they don't rise too much above these primitives. For example, trying to set up dependencies across NSOperation
s takes a lot of complex code, and by the end you are not sure what to do with the spaghetti.
The idea of a Stream in FRP is tied to the representation of values over time & the transforming operators describe changes over time. This provides the abstraction over asynchrony that we want. Since a stream is a sequence of events, your intuition around other sequences such as arrays and sets, carries over. Subscribing to these events then makes sense. Furthermore, you can can transform and operate over the sequence over time. filter
, map
, and reduce
make sense (with the added dimension of time).
Transforming streams🔗
In functional programming you write code declaratively, with functions chained together:
fibonacci // a regular sequence
.filter { $0.isEven }
.take(20)
.map(double)
.reduce(+)
FRP provides the same abstraction over time for transforming a stream:
logEvents // a stream
.filter { $0.isSensitive }
.map { $0.dbRepresentable }
Transforming a stream like this is powerful. Even now I am delighted by the ease with which I can think of how I want to consume a stream, and then write it out in a line or two of declarative code.
One common set of scenarios is when I don't want to consume every value in the stream. A very obvious one is when some values don't match a criterion; use filter
then. Or if I don't want duplicates; I use distinctUntilChanged
. To only subscribe on the main thread, add .subscribeOn(MainScheduler.instance)
to the chain. I may want to combine the values over time; I would use scan
or reduce
. Other scenarios are handled by debounce
, take
, skip
, throttle
, and their variants.
Powerful composition🔗
This to me is the biggest deal.
Synchronous and asynchronous computations can be combined in RxSwift, in the same declarative fashion as the transformations:
logEvents
.filter { ... }
.map { ... }
.flatMap { $0.writeToDB } // composing events and write streams
Here writeToDB
is itself an observable stream. So the event stream is being redirected to the persistence stream.
I use withLatestFrom
quite a bit. If you have a currentValue
observable streaming values whenever some value is changed (maybe from the database, or user entry), and you want to generate an API request from this value, when the user taps a fetch button, you could do this:
fetchButtonTapped.rx.tap
.withLatestFrom(cachedValue)
.flatMapLatest { value in
request(for: value)
}
This will only fire when the button is tapped, but will give you the latest observed value in cache to form the request(for: )
stream.
combineLatest
offers a similar composition. For one of my applications, I am using Rx for handling some complex state around animations. One part of it is where the user is panning a view, and we want to update the view state with every pan. But we want to also include the initial offset of the view, which itself keeps changing, based on the last resting place of the view. What I think is what I write:
Observable.combineLatest(
initialOffset,
pannedPoint
)
Hopefully, it's obvious how a distinctUntilChanged
and map
would be following the combineLatest
above, before I subscribe
and finally update the view state.
The ability to compose lets me write complex asynchronous code in a single chain, localized. It's declarative so it's describing the complex code. The internals of the chain (closures that are given to the transformers and composers) can be factored out to pure, testable functions. The localized and declarative code aid in understanding and modification.
Simpler dependency handling🔗
The dependencies throughout your code are much easier to handle. You can pass an Observable from one layer of your app to another as an object. As it passes through these layers you can add transformations and compositions, to represent the changing abstractions and responsibilities.
As an example:
let allProjects = Project.all // Observable<[Project]>
is an observable vended by the model layer. When it is passed to the Dashboard view model, you filter out archived projects because your Dashboard doesn't need to show them. Then you combine it with the currentFilter
Observable
which is an observable of filters that can be changed by the user via some filter UI:
let projects = allProjects
.filter { !$0.isArchived }
.withLatestFrom(currentFilter) { (projects, filter) in
filter.findMatching(from: projects)
}
When it's passed to the view controller, we only pass the titles:
let projectTitles = projects
.map { $0.title }
Then the view controller could pass the projectTitles
observable to a child view controller (maybe with further transformations and compositions).
This is a much saner way of layering abstractions than what we do with protocols and delegates.
You can Rx-ify (almost) everything🔗
Using Rx is the easiest way to make a "value" reactive, so you can publish and subscribe to the changes.
let value = PublishSubject(value: 3)
value.onNext(10)
value.onNext(20)
value.onNext(30)
value.subscribe(onNext: { print($0) })
Most UI related work is already supported by Rx extensions. Table and collection data sources, UI events, gesture recognizers, and so on can be written in Rx. Otherwise, it's pretty easy to write your own extension to turn a callback or delegate based event handling into Rx streams.
Databases are well covered. There are extensions for Core Data and Realm. I have really enjoyed the Rx extension for the SQLite wrapper GRDB, which has been my go-to library for persistence. This makes SQL-backed SELECT
statements reactive. For example, I can observe for all book titles: SQLRequest("SELECT title FROM book").rx.fetchAll
and then bind them to a table view with a couple more chained functions. Combining the observables is easy:
refreshObs
.withLatestFrom(
Observable.combineLatest(
Project.orderByPrimaryKey().rx.fetchAll,
Task.orderByPrimaryKey().rx.fetchAll,
Run.orderByPrimaryKey().rx.fetchCount,
) { projects, tasks, runsCount in
// ... do some data munging
}
)
The Cons🔗
It is not all roses.
Hard learning curve🔗
The mind shift takes a while. It takes time to learn the variety of observables and producers. It takes longer to know the best practices around how to combine operators in different situations. I made a
lot of mistakes, and sometimes still do. I still use combineLatest
when I need zip
, and vice versa. However, this usually reveals and clarifies the behavior that you really need to model.
Eventually I got to a point where I have a decent intuition about 60% or so of the library. I started by using a small subset, and have been slowly expanding that subset. There is a lot of good documentation, and several high-quality open source projects that use Rx in non-trivial ways.
Do prepare to have a tough time convincing teammates. There are a lot of talks and words written that share the benefits of Rx. But you can't make people read them. The best way to convince people is to show your own transformation, rather than quoting from a list of pros and cons.
Commitment to an "unofficial" pattern🔗
The library seeps through your whole app. There is no guarantee that you or your team would be converts. If you do commit to it, ripping it out will be painful. I once got frustrated with how much time I was sinking in learning and using the library, and decided to take it out. I practically had to rewrite the controller and model logic. I believe I am past that point, but it was hard to make the leap of faith.
I am hoping that Apple Sherlocks the scene. I can see this happening, but not before 2021 (Update: I was wrong. Apple introduced Combine a few months after this post).
Although I wrote this primarily to teach myself what I already knew, hopefully this will give you more food for thought as well.
I would love to chat about your experience or answer any questions on Twitter.