Cross-Platform Applications with Rust 2: Crux in Use
With Rust and the Crux framework, cross-platform apps can be implemented with a clear core, UI separation, and platform-specific effects.
(Image: iX)
- Marcel Koch
The Rust programming language is well-suited for implementing cross-platform projects. The first part of the article series introduced the fundamental concepts of a long-lasting cross-platform architecture: a detached core in Rust forms the foundation for sustainable apps. The implementation of a simple MVVM pattern with ViewModel, Actions, and State demonstrated a straightforward concrete realization of this approach. How the architecture can be refined becomes apparent when it is extended with validations.
Cross-Platform with Crux
The Crux framework, written in Rust for cross-platform development, implements the concepts presented in the first article part. What was called Action, Crux calls Event. The State (State) is called Model. Due to the similar name, the boundary to the ViewModel is unfortunately no longer quite so clear. This is because the ViewModel is also called ViewModel in Crux. It is therefore all the more important to keep the distinction in mind during implementation.
Furthermore, Crux introduces additional important concepts with Effect and Command.
Videos by heise
An Effect represents a side effect of the hexagonal architecture. In this context, side effects are synonymous with platform specifics and the rendering of the User Interface (UI). An Effect is not a one-way street. Through a Command, it can be linked to an Event, so that the processed effect can be answered and the response can be applied to another event in the app. In this way, access to the respective file system and native APIs can be abstracted and implemented.
Crux also defines the terms App, Core, and Shell.
- The
Appis the central trait and a counterpart to the Core from part 1 Corewraps the App and ensures that anEvententers the App and only a list ofEffector theViewModelexits the AppShellrefers to the consumer of the Core (packaged with the App), such as a native app based on Swift, Kotlin, or C#
(Image:Â Marcel Koch)
Example: Email App
The example previously implemented in part 1 is revisited and transferred to Crux. First, the simple types (Listing 1):
Listing 1: Crux: Event/Model/ViewModel Definitions
#[derive(Deserialize, Serialize)]
pub enum Event {
ChangeName(String),
ChangeEmail(String),
ApplyChanges,
}
#[derive(Default)]
pub struct Model {
name: String,
email: String,
}
#[derive(Deserialize, Serialize)]
pub struct ViewModel {
pub name: String,
pub email: String,
}
There are no surprises here. Actions become Event (singular), State becomes Model, and ViewModel remains the same.
Next, the new types (Listing 2):
Listing 2: Crux: Effect Enum and App Struct
#[effect]
pub enum Effect {
Render(RenderOperation),
}
#[derive(Default)]
pub struct EmailApp;
The Effect enum defines all possible communications from the Core outwards. The EmailApp struct remains empty. In the next step, it implements Crux's App trait.
The implementation of App is divided into three blocks (see comments in Listing 3).
Listing 3: Crux: App Trait Implementation
impl App for EmailApp {
// 1
type Event = Event;
type Model = Model;
type ViewModel = ViewModel;
type Capabilities = (); // deprecated
type Effect = Effect;
// 2
fn update(
&self,
event: Self::Event,
model: &mut Self::Model,
_caps: &Self::Capabilities,
) -> Command<Self::Effect, Self::Event> {
match event {
Event::ChangeEmail(email) => {
model.email = email.clone();
}
Event::ChangeName(name) => {
model.name = name.clone();
}
Event::ApplyChanges => {}
}
render()
}
// 3
fn view(&self, model: &Self::Model) -> Self::ViewModel {
ViewModel {
name: model.name.clone(),
email: model.email.clone(),
}
}
}
The first block defines the basic associated types that App provides. These types are familiar from Listings 1 and 2. The Capabilities type is a relic and considered deprecated. This concept was used before the introduction of Command. Therefore, it exists solely for backward compatibility and can be ignored.
The update method receives incoming events and subsequently adjusts the state (Model). Changes to the name or email address are also stored directly in the Model here. After processing an event, a Render Effect is triggered. This can be intercepted in the Shell, the ViewModel can be queried, and re-rendering can be initiated.
The view method in section 3 provides the interface for creating the ViewModel. As before, the ViewModel creates the current Model (state) and prepares the relevant information for the UI so that the user interface can display it directly.
Wrapping the App in the Core
To use the app, it needs to be wrapped in the Core:
let core: Arc<Core<EmailApp>> = Arc::new(Core::new());
An event can be passed to this Core instance, and the returned effects can be processed (Listing 4).
Listing 4: Crux: Effect Processing
let effects: Vec<Effect> =
core.process_event(ChangeEmail("marcel.koch@example.org".into()));
for effect in effects {
match effect {
Effect::Render(_) => {
let view_model = core.view();
assert_eq!(view_model.email, "marcel.koch@example.org")
}
}
}
process_event receives the Event and returns a list of effects. The example only handles one type of Effect: Render. It checks whether the ViewModel contains the email address that was just passed.
If the UI is connected, it appears with every change of the email address.
Wrapping the Core in a Bridge
When it's not a pure call within Rust, integration into other technologies depends on serialization. This task is handled by the Bridge on the Rust side. A simple usage in Rust would look like this:
Listing 5: Crux: Bridge Integration
let serialized =
bincode::serialize(&ChangeEmail("marcel.koch@example.org".into())).unwrap();
let effects: Vec<u8> = bridge.process_event(&serialized).unwrap();
let effects: Vec<Request<EffectFfi>> =
bincode::deserialize(effects.as_slice()).unwrap();
for request in effects {
let effect = request.effect;
match effect {
EffectFfi::Render(_) => {
let view_model = bridge.view().unwrap();
let view_model: ViewModel =
bincode::deserialize(&view_model).unwrap();
assert_eq!(view_model.email, "marcel.koch@example.org")
}
}
}
It's the same process as before with the pure Core. The only differences are the serialization of the event and the deserialization of the effects and the ViewModel. These serializations are performed in the respective foreign technologies (.NET, Swift, etc.) in a realistic scenario. This implementation is shown in the next part of this article series.