swift-bridge icon indicating copy to clipboard operation
swift-bridge copied to clipboard

Cross language frontend patterns

Open agg23 opened this issue 2 years ago • 3 comments

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.

agg23 avatar Jan 24 '23 06:01 agg23

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 relevant ViewData. Then that ViewData gets passed down into the views. The SwiftUI views literally know nothing about Rust. So, for example, we'd immediately convert struct OrdersPageUiData into OrdersPageViewData. 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 an impl FooBarUiEvents { fn msg_issue_refund(&self) { ... } } which sends a message to app-world. On the SwiftUI side this eventually becomes a msgIssueRefund: () -> () 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.

chinedufn avatar Feb 13 '23 23:02 chinedufn

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 of flatbuffers, which I later switched out to bincode 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. With bincode, I use serde_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:

  1. 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?

  2. Have you encountered performance issues at any point (lots of back and forth with a particular model, large lists/dictionaries, frequent, fast changes)?

    1. 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)?

    2. Have you implemented infinite scrolling (another scenario where you might want the UI to keep some state, separate from Rust)?

  3. 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?

agg23 avatar Feb 14 '23 17:02 agg23

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 avatar Feb 14 '23 19:02 chinedufn

@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?

xbjfk avatar May 26 '24 05:05 xbjfk

  1. Wrap your state in a wrapper that only exposes a single way to get a mutable reference to state
  2. 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();
    }
}

chinedufn avatar May 26 '24 17:05 chinedufn

Thank you, that's exactly what I was looking for! I really appreciate your help.

xbjfk avatar May 27 '24 22:05 xbjfk

No problem. Note that I just edited my comment above with a third example "Using a message enum".

Closing this out.

chinedufn avatar May 27 '24 22:05 chinedufn