swift-bridge
swift-bridge copied to clipboard
Cross language frontend patterns
You've mentioned several times that you maintain an iOS app rendered by SwiftUI, with all of your data derived from Rust. Im very interested in how you accomplish this, and how performant it ends up being. Do you use something like your app-world
crate for reducer-style state management, and do you delta encode changes?
I hope this issue is ok, you said here that you might be willing to share some info. I appreciate any answer you can give.
Hey! I don't delta encode changes.
I do use app-world
.
This issue is totally fine!
There's a lot that goes into it, so I'm not sure when I'll sit down to write an example application.
Iterated quite a bit over a year before landing on something that felt elegant. (At first I was using cbindgen
and writing the FFI layer by hand. That got pretty tiring, so I wrote swift-bridge
.
Here are some very, very quick notes:
-
All of the application logic is in Rust and then the views (SwiftUI, web, etc) get UI data from Rust. So, for example, I might have a
struct OrdersPageUiData { .. }
in Rust that gets bridged over to Swift. -
All Swift views have their own 100% Swift
ViewData
data structures.UiData
from Rust gets immediately converted into the relevantViewData
. Then thatViewData
gets passed down into the views. The SwiftUI views literally know nothing about Rust. So, for example, we'd immediately convertstruct OrdersPageUiData
intoOrdersPageViewData
. This pattern evolved over time for reasons that I'll hopefully eventually write about. -
The Swift views truly know just about nothing. For example, we'll even bridge a
FooBarUiEvents { .. }
where there is animpl FooBarUiEvents { fn msg_issue_refund(&self) { ... } }
which sends a message toapp-world
. On the SwiftUI side this eventually becomes amsgIssueRefund: () -> ()
which will get called when some button is pressed. -
Rust UIData creation is heavily tested. Able to test heavily UIs without even looking at them Swift. By the time we run the application on iOS it's just to.
-
Our application is cross platform so everything is designed around writing logic once and easily using UI data across multiple platforms with native UIs.
There's way more than this.. And to be thorough would require me to sit down and do a proper write up.. But hopefully that gives you a non-zero amount of help in the meantime...
Honestly .. all I did was dive in and start .. things were mehhh at first .. then over time things became very nice .. So .. I'd encourage you to just dive in and mess around (until I hopefully maybe eventually write something proper on this.. maybe..)
Sorry for the jumbled notes. Feel free to ask more questions.
Interesting. Thank you so much for the detailed response. What other languages are you bridging to besides Swift?
For the purposes of discussion I'll detail what I've played around with:
-
Building a multiple-frontend, cross platform application. I took a different direction than
swift-bridge
, and wanted my FFI layer to be the same for all platforms. I ended up building some procmacros on top offlatbuffers
, which I later switched out tobincode
over a single method C FFI. -
Similar to what you said, my Swift code knows nothing of the Rust types. When using
flatbuffers
, I was using Sourcery to generate corresponding Swift types and the transformations. Withbincode
, I useserde_generate
to generate the corresponding serialization types and code on the Swift side. -
My use case includes some large lists with potentially hundreds of (small) items in them, all rendered at once. I very quickly start to see performance issues passing that data back and forth, which is why I asked you about delta encoding. This starts to really complicate the pattern, as delta encoding starts to make it feel like you're maintaining two versions of state.
-
Not really a function of the bridge, but subscriptions to your reducer are somewhat difficult to reason about when they're across the FFI. I haven't arrived at what a "good" pattern should look like (I also haven't looked at how
app-world
handles store updates). -
It's been a while since I've worked on it, but overall my approach feels very delicate, and significantly more difficult than building the logic in the native UI's language. I'm wondering if you've encountered similar issues, and if you think your solution ended up being the correct path forward?
Questions:
-
What other languages are you using besides Swift + Rust, and how have you found that experience? Have you built a basically identical system to
swift-bridge
for the other languages/environments? -
Have you encountered performance issues at any point (lots of back and forth with a particular model, large lists/dictionaries, frequent, fast changes)?
-
Is your Rust logic the sole source of truth, or is it split with your UI code (e.g. when a user is typing into a text box, the UI code is the source of truth for that string, and at some point it's committed to the Rust backend OR as user types characters, those characters are sent to Rust which sends an updated model to the UI)?
-
Have you implemented infinite scrolling (another scenario where you might want the UI to keep some state, separate from Rust)?
-
-
Do you believe developing this system for cross-platform development was worth it? If you had to do it over again, would you stick with Rust + native UI language?
What other languages are you bridging to besides Swift?
Right now just Rust
and Swift
.
Swift for iOS views, Rust + wasm-bindgen
for web views (I use the percy-dom
crate).
In the future we'll add Kotlin
for Android. Not yet sure what we'll do for Windows and Linux.
I ended up building some procmacros on top of flatbuffers, which I later switched out to bincode over a single method C FFI.
Serializing and deserializing comes with overhead.
flatbuffers
and bincode
are mostly useful for serializing/deserializing data that you want to store or transmit out of
the process (i.e. to send over a network or store in a file.)
I wouldn't build a high-performance UI on top of serializing/deserializing.
I was actually confused why you asked me about delta encoding but now I understand.
To be clear, swift-bridge does not serialize/deserialize anything, so there is nothing to delta encode.
Here's how Vecs are sent from Rust
to Swift
.
https://github.com/chinedufn/swift-bridge/blob/c1fd7177b5d3a8918e34b6bc96ae29437cfc1487/crates/swift-bridge-ir/src/codegen/codegen_tests/vec_codegen_tests.rs#L173-L204
Right now it's a single 24 byte
allocation (std::mem::size_of::<Vec<T>>
on a 64 bit system). In the future we'll get that down to 0 using std::mem::drop
.
I'd imagine that your serialization setup has a lot more overhead than that (not sure, don't know your full setup. For all I know the serialization has no overhead)
Not really a function of the bridge, but subscriptions to your reducer are somewhat difficult to reason about when they're across the FFI
Whenever app-world
state changes I modify an @EnvironmentObject
on the Swift side which makes the app re-render. Works for our application.
It's been a while since I've worked on it, but overall my approach feels very delicate, and significantly more difficult than building the logic in the native UI's language. I'm wondering if you've encountered similar issues, and if you think your solution ended up being the correct path forward?
There were some bumps at first, especially when I was hand writing FFI bindings.
These days it's pretty streamlined.
At first it wasn't great, but now I wouldn't Not my experience, but
Is your Rust logic the sole source of truth, or is it split with your UI code
Rust is the sole source of truth. In cases where we're using a Binding
(i.e. for a SwiftUI TextField
) we use an on change handler to immediately
send that value to Swift.
Essentially things are built such that logic doesn't need to be repeated across target platforms (iOS, web, etc).
Have you encountered performance issues at any point
Nope, since as mentioned above swift-bridge
doesn't do any serialization. Most things are zero or almost zero overhead.
Have you implemented infinite scrolling
I haven't. But if we needed to keep some state on the SwiftUI side for performance reasons that doesn't sound like an issue.
Do you believe developing this system for cross-platform development was worth it? If you had to do it over again, would you stick with Rust + native UI language?
Definitely. I love our current setup.
But, as mentioned, it took a fair bit of time and iteration before I landed on something that felt crisp.
To be fair one of my favorite parts is that since I find it pretty easy to test in Rust I've found it really easy to test our UIs (since testing our UI data is like 90% of testing our UIs..).
I haven't thought about whether I'd feel this way if we were building a smaller project where testing wasn't as valuable.
For a large, cross platform project I definitely would not switch away from our current setup approach (unless I discovered some new super, groundbreaking, super compelling technique or something...)
@chinedufn thank you for your awesome library!
I have a question.
Whenever app-world state changes I modify an @EnvironmentObject on the Swift side which makes the app re-render. Works for our application.
How exactly do you do this? I know how to do this on the Swift side, i.e. you can use objectWillChange
or some such thing. However, how do you know when the state updates from Rust to Swift? Let's say I had a long running background operation in Rust, and updated a progress integer somewhere. How could swift know when this integer changes, in order to call objectWillChange
?
- Wrap your state in a wrapper that only exposes a single way to get a mutable reference to state
- Whenever a mutable reference is retrieved, call Swift
I use app-world
for this, but here are a couple of example ways to approach the problem without using a library:
Using a message enum
struct World {
state: State,
swift: SomeSwiftObject,
}
struct State {
count: i32
}
enum Msg {
Increment(i32),
Decrement(i32),
}
impl World {
fn msg(&mut self, msg: Msg) {
match msg {
Msg::Increment(inc) => self.state.count += 1,
Msg::Decrement(dec) => self.state.count -= 1,
};
self.swift.handle_state_changed();
}
}
Using a callback
struct MyWrapperType<State> {
state: State,
swift: SomeSwiftObject
}
impl<State> MyWrapperType<State> {
fn mutate_state(&mut self, callback: impl FnOnce(&mut State)) {
callback(&mut self.state);
self.swift.handle_state_changed();
}
}
Using the guard pattern
struct MyGuardType<State> {
state: &'a mut State,
swift: &'a SomeSwiftObject
}
impl<State> DerefMut<State> for MyGuardType {
fn deref_mut(&mut self) -> &mut State {
self.state
}
}
impl<State> Drop for MyGuardType<State> {
fn drop(&mut self) {
self.swift.handle_state_changed();
}
}
Thank you, that's exactly what I was looking for! I really appreciate your help.
No problem. Note that I just edited my comment above with a third example "Using a message enum".
Closing this out.