Rustling Up Cross-Platform Development
complexityclass
Posted on March 12, 2023
My experience with cross-platform mobile development lacks some important elements, such as Flutter or Xamarin. Therefore, this article is not a comprehensive analysis of tools in this space.
Over the years, I've tried a few different tools for cross-platform development, including PhoneGap (which, in hindsight, I probably should have avoided), React Native, Qt, and a bit of Kotlin Native. Generally speaking, I firmly believe that the UI should be native, and that tools like PhoneGap just don't cut it for anything more than a simple app. While React Native has its pros and cons, it hasn't won me over as a developer. Instead, I prefer the idea of having a cross-platform core and native UI. As someone who switched from Android to iOS around the time of KitKat (4.4? π€), As I'm naturally more inclined towards llvm based languages, C++ was my first choice for a cross-platform code. On iOS it's relatively easy to bridge C++ and Objective-C through a mix of both called Objective-C++. I've worked on some big projects that were heavily based on this idea, and I can attest that it's a working solution. However, Objective-C is becoming less popular every day, and Objective-C++ is an even scarier beast to work with. I can't say that I found writing it enjoyable. Furthermore, I can't see any compelling reason to write application-level code in C++. Perhaps for OS-level code, but that's a topic for another discussion. After a few attempts with C++, I tried Kotlin Native (KN), which had much better tooling and IDE support, even in the earliest versions. Kotlin is a fun language to read and write, and with the "Native" part, we can even rid ourselves of the JVM. So if you're already immersed in the Android ecosystem, love Kotlin, and enjoy working in Android Studio, then KN should be a good choice for you. However, in this article, I'd like to explore a more "rusty" perspective. Let's dive in.
I've dabbled with Rust on iOS a few times, and it seemed a lot like C++. You build a static library, use C headers as glue, and end up struggling with debugging. This approach is straightforward when you're only extracting a small piece of logic into a shared library and interacting with it through a thin interface. But what if you want to put most of the app logic into the shared lib? That's when things get tricky.
Recently, I stumbled upon a project at the Rust London conference that caught my eye. It's called Crux, and it's a library that helps you implement a functional core and imperative shell paradigm. In other words, it allows you to separate your app logic from your UI code, and share it between platforms.
Although the idea of a functional core and imperative shell might sound straightforward, the actual implementation can be tricky. As you start working on it, you'll inevitably run into obstacles and challenges, especially when it comes to separating the core logic from the user interface.
Second biggest challenges after "variable naming" is finding the appropriate architecture to use. Traditional MVC/MVP architectures may not always be the best fit, and I found it difficult to keep track of all the data flows in applications I used to work with. Additionally, real-world user interfaces can be complex and dynamic, which adds even more states and interactions to the UI layer.
This is where functional concept of free from side-effects Core comes in. Crux helps to build foundation. For me, it's been really helpful in figuring out how to structure my code and how to isolate the core logic in a way that's both ergonomic and easy to read. In a few hours I created a small app that interacts with the DALL-E APIs (pretty obvious, right?) and works on 3 platforms (actually 2.5 as I haven't finished web π ). In the following section, I'll share my initial impressions.
Setup
Since the project is in its very early stages, setting it up isn't quite as seamless as with React Native. However, it's not a big deal to contribute to the tooling in-house if you decide to go with this stack for a real project. In fact, most big projects, even single-platform ones, contain a zoo of different bash scripts and make files anyway. The book has a really good explanation of how it works and even provides example apps.
Personally, I found it better to set up the project from scratch using the book. That way, I was able to see all the places to look if something went wrong. It took me less than an hour to set up the core and iOS project, and the process was straightforward. Luckily, the core configuration is in .rs and toml files, which are very easy to follow.
For iOS, you need some bash scripts (oh, I hate writing bash). But in my case, copy-pasting was enough, and ChatGPT made life bearable even if some customisation in bash is needed. Long story short, you need to compile the core as a static library, generate UI languages bindings using the uniffi crate, and add these steps to the Xcode project so you don't need to rebuild and relink the core manually. The uniffi requires to write an IDL Interface Definition Language file describing the methods and data structures available to the targeted languages. I generated Swift/ Kotlin and TS for iOS/Android and Web respectively.
UDL looks like this:
namespace core {
sequence<u8> handle_event([ByRef] sequence<u8> msg);
sequence<u8> view();
};
At the end, the project structure looks like this (no Android and web on the screenshot):
Development
When it comes to development, you'll probably be splitting your time between Xcode/Android Studio and whatever you prefer for Rust and web development. I've seen some brave souls trying to do mobile development in Emacs, but at the end of the day, they were significantly slower than their teammates.
The good news is that it's quite convenient to work on the core first, crafting the interface and writing tests, and then switching to Xcode/Studio to polish bits of the Core in parallel. Personally, I use CLion for Rust and I don't dare to open more than 2 out of the 3 (CLion/Xcode/Android Studio) at once. Rust compiles quite slowly, which isn't a problem for me since my Swift/ObjC project at work took around 50 minutes for a clean build on a top configuration MacPro(not a MacBook π). However, for web developers, this might be a bit of a drag. But proper project modularization can help with this.
Writing code in Rust can be a bit challenging at first, but I found that a lot of the ideas are similar to Swift, so it's not like a completely different experience. Enums like in Swift, isn't it? π
#[derive(Serialize, Deserialize)]
pub enum Event {
Reset,
Ask(String),
Gen(String),
#[serde(skip)]
Set(Result<Response<gpt::ChatCompletion>>),
#[serde(skip)]
SetImage(Result<Response<gpt::PictureMetadata>>),
}
When it comes to debugging, you can use breakpoints through the lldb "breakpoint set" command to debug both the Swift and Rust code in your linked static library. It's not as convenient as debugging a pure Kotlin project in Android Studio, but it still gets the job done.
E.g missing .env variable error easily identifiable even from within Xcode.
Exact line in the logs:
However, I couldn't see any issues with debugging the core and shell separately. In fact, it can be quite helpful to be able to debug each component independently, as it can make it easier to pinpoint the source of any bugs or issues.
What about interop... I'm not going to lie, it's not ideal. In particular, interop between Rust and Swift isn't as seamless as it is between Swift/Objective-C and Kotlin/Java. For example, f64 can't be passed as is through the boundary ( which is logical, but still). However, there are some cheat sheets available to help make sense of the interop rules. For Swift, the following rules apply:
- Primitives map to their obvious Swift counterpart (e.g.
u32
becomesUInt32
,string
becomesString
, etc.). - An object interface declared as
interface T
is represented as a Swift protocolTProtocol
and a concrete Swift classT
that conforms to it. - An enum declared
enum T
or[Enum] interface T
is represented as a Swift enumT
with appropriate variants. - Optional types are represented using Swift's built-in optional type syntax
T?
. - Sequences are represented as Swift arrays, and maps as Swift dictionaries.
- Errors are represented as Swift enums that conform to the
Error
protocol. - Function calls that have an associated error type are marked with
throws
in Swift.
I remember similar rules for Kotlin Native. Actually, the interface between the core and shell should be laconic. I don't think these limitations are good, but they don't hurt too much either.
Architecture
Talking about architectural patterns. Have you seen mobile Eng who are not talking about patterns? Crux is inspired by Elm, there is quite good page in the book and also Elm docs worth reading, so letβs skip the description. In general I see movement to unidirectional and message passing architectures. They are clean and quite strict, which makes it easier to update code and not introduce inconsistency when one text field has three different states across layers. True that UIKit or Vanila android libraries are not the best fit (though still possible to reuse some ideas), but SwiftUI and Jetpack Compose fit quite nice. If you write gesture interaction and animation heavy UIs - this would be challenging. Like if you do some gesture driven transition, should you keep current state in UI or pass it to the core? Or UITableView (iOS) and RecyclerView (Android) have a bit different lifecycle for cells, hence for cell models, how core will be dealing with it. A bit challenging, but still possible, no silver bullets as always.
The part that I liked the most, though, was the capabilities feature. Capabilities provide a nice and clear way to deal with side effects, such as networking, databases, and system frameworks. Sure, you could write a single HTTP library in C and use it everywhere, and maybe you could even standardize persistence to use only SQLite. But there are so many different things to consider, such as audio/video, file systems, notifications, biometrics, or even peripherals like the Apple Pencil. And your system already has good libraries to deal with these things, which might even be optimized ( quality of service or URLSession configuration on iOS) to be more effective. That's where capabilities come in - they allow you to declare what you need, while keeping the implementation specifics for the platform code. It's a great way to keep your code modular and maintainable.
When core handles event that need to make an HTTP call, it's actually instructing Shell to do the call.
fn update(&self, event: Self::Event, model: &mut Self::Model, caps: &Self::Capabilities) {
match event {
Event::Ask(question) => {
model.questions_number += 1;
gpt::API::new().make_request(&question, &caps.http).send(Event::Set);
},
...
And shell is sending request
switch req.effect {
...
case .http(let hr):
// create and start URLSession task
}
The same logic can be applied to databases (just separate KV-storages and relational), biometric, whatever else.
Final Thoughts
Despite the fact that I'm new to Crux and not yet fluent in Rust, I was able to build a simple app that works on iOS, Android, and Web (almost) in less time than it would have taken to build all three from scratch.
Crux is still in its early stages, e.g. at the time of my note, the HTTP capability didn't support headers and body. But I have high hopes that this project will continue to grow and attract more contributors, as the idea behind it is really cool.
Even if you don't want to use Rust for cross platform development, I think it's worth taking a look at this project to see how you might be able to reuse some of the ideas in your favourite stack. At the end of the day, anything that helps us write better, more modular, and more maintainable code is a win.
Posted on March 12, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.