[RIBs] Modernizing RIBs: Swift Concurrency Support and Strict Isolation (POC)
Migrating to the new Swift Concurrency rules is challenging, especially when updating a framework others rely on. I've created a POC for modernizing RIBs to support Swift Concurrency's actor isolation
I’m continuing to work on modernizing the RIBs framework for iOS. Last weekend, I opened a PR with a proof of concept (POC) showing how Swift Concurrency and the new Swift 6.2 strict concurrency rules can be integrated into RIBs.
Before diving into my proposed POC (which is still a work in progress and might change!), I’ll give a quick overview of the RIBs architecture, its current state on iOS, and how it handles concurrency today.
RIBs Architecture
Overall, RIBs architecture allows you to model your application as a tree of business logic scopes, where each scope contains at least the 3 core things: Router, Interactor, and Builder. That’s where the acronym RIBs is coming from.
Each RIB scopes business logic and can have an optional view. Because of this, RIBs do not necessarily correlate with screens that your application has, but for a lot of them, that’s the case.
And a RIB has additional things that it utilizes based on the concrete needs and implementation of your application, like: Services, Managers, Storages, Streams, Use Cases, etc.
By its nature, RIBs architecture is business logic driven rather than UI driven. This is by design so that asynchronous background work and async reactive data streams in the business logic can be made the centerpiece of the application rather than fickle whims of UI/UX design changes.
Another key concept is that there is a unidirectional data flow in RIBs. The data flows from the top parent RIBs down in a one-to-many broadcasting fashion, and data flowing up from children to parents goes via a one-to-one relationship. This allows for very effective state management that helps avoid data races and other data issues in the architecture.
Concurrency with RxSwift
The Role of Reactive Streams
To achieve concurrency and handle reactive data streams and asynchronous work in RIBs, RxSwift is traditionally used for its rich set of high-order functions, observables, subjects, and other reactive data stream implementations. RxSwift has the advantage of being a well-established and battle-tested reactive streams implementation standard that was ported, with minimal deviation, to every popular programming language.
Practically, when you work with RIBs, this doesn’t mean that you have to use RxSwift, but doing so will give you convenient helpers and integrations with RIB’s lifecycle to help you manage your observable streams and async work. You could, of course, roll out your own using Combine, Swift Concurrency’s async/await, or something else. You’d just have to manage your streams of data and async jobs yourself, making them work with the RIB’s lifecycle and ensuring they cancel when a RIB is detached.
Best Practice: Decoupling UI from Streams
Day to day, I found it to be the best practice in RIBs to have a clear separation and standard on how you use RxSwift in your applications: use streams/observables for async work everywhere in the business logic of the app but completely separate and keep it out of the UI view layer of the application.
In practical terms, this means:
If you need to do a one-off async job (like a network request), have a service object produce a Single that wraps the request.
The Interactor (the center of the business logic) injects the service, launches/subscribes to these streams, and binds them to its own lifecycle for data cleanup on detachment.
When it comes to updating the UI based on the data the streams/async jobs produce, instead of passing those streams down to the UI, you instead call methods on your RIB’s Presenter to present the received data. This way, you achieve a clean separation of concerns, avoid cluttering the view layer with Rx subscription logic, and decouple the async operation’s lifecycle from the UI’s lifecycle.
But I digress... I can be talking about this for hours and this warrants its own post (which I will write in the future), but this is overall how asynchronous work is implemented in RIBs.
The Problem
The problem today is two-fold:
No Native Swift Concurrency Helpers: There is currently no helper or integration to work with Swift Concurrency’s async/await directly with RIBs. If you want to call async functions from your Interactor or its dependencies, you’d have to manage wrapping them yourself into a Task and then managing its cancellation with the RIB’s lifecycle (e.g., wrapping the Task into an RxSingle or similar).
Swift 6.2 Strict Concurrency Violations: With the new Swift 6.2 strict concurrency rules enabled, several compiler warnings and errors related to actor isolation appear when using RIBs.
While both issues are fixable by adjusting your codebase and jumping through a lot of hoops to make the compiler happy, it’s not optimal. The amount of changes required for existing RIBs projects could be overwhelming and impractical.
The Solution
I outlined the POC of my solution in the PR I opened: https://github.com/uber/RIBs-iOS/pull/45. I’ll try to give it more context, detail, and a summary here.
Keep in mind this is still a proof of concept, and the proposed solution could dramatically change. There is a chance that I have a fundamental flaw or an irreconcilable issue with the way I’m thinking of implementing it, and I might have to redo the approach completely from scratch. But, as of today, I do have a working prototype and am looking for feedback on it!
Unfortunately, it is unlikely that breaking changes to existing codebases using RIBs can be avoided, but I think I was able to get them to a minimum.
Fundamentally, there are just two things that need to be done to satisfy the new Swift Concurrency strict rules:
UI/View Layer is MainActor Isolated: Everything that touches the UI/view layer (
Presenter
,ViewableRouter
,ViewController
, or parts of them) must be explicitly marked with@MainActor
to indicate that they call methods to manipulate and re-render the UI on the main thread.Business Logic is Nonisolated: Everything else is inherently asynchronous and needs to be marked as such with
nonisolated
(or other directives/protocols/keywords).
Along with those two changes, I added a couple of helper methods to the PresentableInteractor
and the ViewableRouter
to automate some of the boilerplate related to calling presenter methods or UI navigation methods on the main actor.
One thing that I haven’t added yet, but will likely add soon to this PR, is a convenience/automation/helper method/s to run async functions in the interactor and automatically create a wrapping Task
and an Rx observable for it.
Looking for Feedback!
I’m looking for feedback on my PR. You don’t have to already work with RIBs or be an expert in Swift Concurrency, but I’d appreciate a second pair of eyes from someone who spent more time using Swift Concurrency than me. Perhaps I missed something critical there...
Also, how are the ergonomics of creating a new Swift 6.2 project and integrating this POC of the framework into it? How are the ergonomics and the experience updating an existing project using RIBs?
In the coming weeks, I’ll be testing all of that myself in new sample projects, in one of my personal projects, and in production at scale at work.
Original PR Submission
Here I’m posting the content of my POC PR submission that might add a bit more of a context and detail to the changes I made.
Swift Concurrency/Isolation Support
This is a draft PR to introduce Swift Concurrency Isolation support in RIBs. It is a functioning prototype but it needs thorough testing real codebases. I’ll be testing it in one of our production codebases and also in all of my personal projects. But, I’d appreciate volunteers to try this out and give us feedback. You can find sample usage of this change pointing to my fork that constitutes this PR here https://github.com/alexvbush/RIBsSample1
This change is related to https://github.com/uber/RIBs-iOS/issues/6 and https://github.com/uber/RIBs-iOS/issues/43
Overall, I’m still not able to make it work without breaking changes, but I believe I brought them down to a minimum.
This is still a WIP and I am open for feedback to make changes to this solution.
The Isolation and Threading Issues
Fundamentally, everything in RIBs can be async and run on different threads, and you as a developer need to manage concurrent access to the data in RIBs on your own. Historically it’s been done via streams RxSwift Observables, Subjects, etc. and in combination with the helper methods such as func disposeOnDeactivate(interactor: Interactor) -> Disposable it works pretty well out of the box. The only thing you’d really need to worry about was running your stream/observable observation callbacks on the main thread when you’d need to render the UI, aka call methods on your RIB’s presenter. This would normally be achieved by adding .observe(on: MainScheduler.instance) call onto observables so that their subscribe next callback block executes on the main thread. It works fine but requires developer discipline to remember to add.
The same applies to controlling multi-threaded access to data across your RIBs app - it is up to you how to implement it, but RxSwift that comes out of the box gives you a lot of tools and flexibility to do it.
With the new Swift Concurrency rules and compiler errors and warnings a lot of these things are either more explicit or attempted to be inferred and resolved at compile time.
If RIBs are used in projects with strict swift concurrency isolation enabled, then as it stands today one would experience a lot of compiler warnings about breaking isolation constraints and related issues such as these:
Ultimately the compiler is trying to be helpful by enforcing stricter concurrency but it doesn’t know/can’t infer the RIBs ways of ensuring safe concurrent access to data via streams and Rx observables. This leads to warnings and compiler issues that are either excessive or incorrect/not-needed in the context of RIBs.
Isolation Changes
In order to fix the compiler issues with the new strict swift concurrency we have to modify and explicitly state what kind of access each class or interface in RIBs should have.
Almost every class or protocol was marked as nonisolated and everything else that touches the UI aka the main thread was marked with @MainActor:
Presentable protocol and by extension Presenter class and its subclasses
LaunchRouting and its func launch(from window: UIWindow)
ViewControllable since its the one referring to UIViewController instances
Everything else, such as components, interactors, builders, routers, etc., was marked as nonisolated since it’s explicitly up to the developer to deal with concurrency there.
Overall those are all the fundamental the changes. But they have some implications and breaking changes.
What are the implications of all of these changes?
Well, this now means that calls to presenter methods, either directly or in callback blocks from Rx or async/await tasks, will result in the following error: Call to main actor-isolated instance method ‘presentStuff()’ in a synchronous nonisolated context.
This is a good thing and is what strict swift concurrency rules intend to help with. This will warn you if you are trying to call presenter methods from potentially not the main thread, this is forcing you to explicitly call them on the main thread which would reduce a lot of potential runtime errors.
The same type of warning occurs when we try to call methods on UIViewController instances while navigating the UI during viewable RIBs routing. Since router is nonisolated, and routing can actually be triggered from different threads, especially for headless routers, the only thing that really needs to be executed on the main thread is only the UI manipulation part which is the navigation between view controllers.
How should we call presenter methods now?
In the current incarnation (and keep in mind, I’m still exploring the solutions here) you have two options to properly call presenter methods on the main thread:
declare the presenter as nonisolated(unsafe) let presenter = self.presenter inline and then call your presenter methods wrapped in a Task { @MainActor in }.
use a new convenience method on PresentableInteractor that automates this for you.
Technically to make it work you could manually set up presenter inline as nonisolated(unsafe) local variable and then call it methods on it in Task { @MainActor in }. Since this would quickly become tedious, I added a helper method presentOnMainThread on PresentableInteractor that will do this for you. It can be called safely from an RxSwift observable callback or from a Task.
What should we do in routes now?
Same as with the presenter, routing now has to slightly adjust to explicitly call things on the main thread when necessary.
Just like with the presenter you can either do it manually:
Or you can use a new automation built into the ViewableRouter:
Other Implications
There are also other minor implications of these changes, such as Presentable interfaces having an associatedtype Listener and a nonisolated var listener: Listener? { get set } property declaration. The change is minor but breaking unfortunately, I am still exploring the ways to avoid this.
Feedback Needed!!!
This is an early work in progress exploration of how Swift Concurrency can work seamlessly with RIBs. There are a few more things that need to be done such as Task/async/await helper methods support that I’ll add a bit later.
In the meantime I am looking for feedback - please pull this PR or use the branch in my fork of this repo https://github.com/alexvbush/RIBs-iOS/tree/swift-concurrency-isolation and test out these changes in your own projects. Let me know what works and what doesn’t. There will be compiler issues and you’d have to tweak and migrate a few things but I hope it’s not too many. Let me know what you had to migrate and I’ll try to add helpful tutorial/readmes based on your input. And also checkout my sample project where I applied my fork’s branch to a sample RIBs app: https://github.com/alexvbush/RIBsSample1