Cross-Plattform-Applikationen mit Rust 2: Crux-Architektur in der Praxis
Mit Rust und dem Crux-Framework lassen sich Cross-Plattform-Apps mit klarem Core, UI-Trennung und plattformspezifischen Effekten umsetzen.
(Bild: iX)
- Marcel Koch
Die Programmiersprache Rust eignet sich gut für die Umsetzung von Cross-Plattformprojekten. Der erste Teil der Artikelserie hat die grundlegenden Konzepte einer langlebigen Cross-Plattform-Architektur vorgestellt: Ein herausgelöster Core in Rust bildet das Fundament für nachhaltige Apps. Die Implementierung eines einfachen MVVM-Patterns mit ViewModel, Actions und State zeigte eine einfache konkrete Umsetzung dieses Ansatzes. Wie sich die Architektur verfeinern lässt, zeigt sich, wenn man sie um Validierungen erweitert.
Cross-Plattform mit Crux
Das in Rust geschriebene Framework Crux für die plattformübergreifende Entwicklung implementiert die im ersten Artikelteil vorgestellten Konzepte. Was Aktion hieß, nennt Crux Event. Der Zustand(State) heißt Model. Durch den ähnlichen Namen ist die Grenze zum ViewModel leider nicht mehr ganz so deutlich. Das ViewModel heißt nämlich auch bei Crux ViewModel. Umso wichtiger ist es, die Abgrenzung bei der Umsetzung im Hinterkopf zu behalten.
Zudem bringt Crux mit Effect und Command noch weitere wichtige Konzepte mit.
Videos by heise
Ein Effect bildet einen Seiteneffekt der hexagonalen Architektur ab. In diesem Kontext sind Seiteneffekte gleichbedeutend mit Plattformspezifika und dem Rendern des User Interface (UI). Dabei ist ein Effect keine Einbahnstraße. Durch ein Command lässt es sich mit einem Event verknüpfen, sodass der verarbeitete Effekt beantwortet und die Antwort in der App auf ein weiteres Event angewendet werden kann. Auf diese Art lässt sich der Zugriff auf das jeweilige Dateisystem und auf native APIs abstrahieren und umsetzen.
Crux definiert auĂźerdem die Begriffe App, Core und Shell.
- Die
Appist das zentrale Trait und ein Pendant zum Core aus Teil 1 CoreumhĂĽllt die App und sorgt dafĂĽr, dass einEventin die App hinein- und nur eine Liste vonEffectoder dasViewModelaus der App hinausgehtShellbezeichnetet den Konsumenten des Cores (bepackt mit der App), wie eine native App auf Basis von Swift, Kotlin oder C#
(Bild:Â Marcel Koch)
Beispiel: E-Mail-App
Das zuvor in Teil 1 implementierte Beispiel wird erneut aufgegriffen und auf Crux ĂĽbertragen. Als Erstes die einfachen Typen (Listing 1):
Listing 1: Crux: Event/Model/ViewModel-Definitionen
#[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,
}
Hierbei gibt es keine Ăśberraschungen. Actions werden zu Event (Einzahl), State wird zu Model und das ViewModel bleibt bestehen.
Als Nächstes die neuen Typen (Listing 2):
Listing 2: Crux: Effect-Enum und App-Struct
#[effect]
pub enum Effect {
Render(RenderOperation),
}
#[derive(Default)]
pub struct EmailApp;
Das Enum Effect definiert alle möglichen Kommunikationen aus dem Core hinaus. Das Struct EmailApp bleibt leer. Es implementiert im nächsten Schritt das Trait App von Crux.
Die Implementierung von App ist in drei Blöcke (siehe Kommentare in Listing 3) unterteilt.
Listing 3: Crux: App-Trait-Implementierung
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(),
}
}
}
Der erste Block legt die grundlegenden assoziierten Typen fest, die App vorsieht. Diese Typen sind aus Listing 1 und 2 bekannt. Der Typ Capabilities ist ein Relikt und gilt als veraltet (deprecated). Dieses Konzept wurde vor der Einführung von Command genutzt. Daher ist es lediglich aus Gründen der Rückwärtskompatibilität vorhanden und lässt sich ignorieren.
Die update-Methode nimmt eingehende Events entgegen und passt daraufhin den Zustand (Model) an. Änderungen am Namen oder der E-Mail-Adresse werden auch hier direkt im Model gespeichert. Nach der Verarbeitung eines Events wird ein Render- Effect ausgelöst. Dieser kann in der Shell aufgegriffen, das ViewModel angefragt und das Re-Rendering angestoßen werden.
Die view-Methode in Abschnitt 3 bietet die Schnittstelle, um das ViewModel zu erstellen. Wie zuvor erzeugt das ViewModel das aktuelle Model (Zustand) und bereitet die relevanten Informationen für die UI so auf, dass die Benutzeroberfläche sie direkt anzeigen kann.
App im Core einwickeln
Um die App zu verwenden, gilt es diese im Core zu umhĂĽllen (zu wrappen):
let core: Arc<Core<EmailApp>> = Arc::new(Core::new());
Dieser Core-Instanz lässt sich ein Event übergeben und die zurückkommenden Effekte können verarbeitet werden (Listing 4).
Listing 4: Crux: Effect-Verarbeitung
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 nimmt das Event entgegen und gibt eine Liste von Effekten zurück. Das Beispiel behandelt nur eine Art von Effect: Render. Es wird geprüft, ob das ViewModel die eben übergebene E-Mail-Adresse enthält.
Ist das UI verbunden, tritt es bei jeder Änderung der E-Mail-Adresse auf.
Core in Bridge wrappen
Geht es nicht um einen reinen Aufruf innerhalb von Rust, ist die Integration in andere Technologien abhängig von Serialisierung. Diese Aufgabe übernimmt auf der Rust-Seite die Bridge. Ein einfacher Einsatz sähe in Rust wie folgt aus:
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")
}
}
}
Es ist der gleiche Ablauf wie zuvor mit dem reinen Core. Die einzigen Unterschiede sind die Serialisierung des Events und die Deserialisierung der Effekte und des ViewModel. Diese Serialisierungen werden in einem realistischen Einsatz in den jeweiligen Fremdtechnologien (.NET, Swift etc.) durchgeführt. Diese Umsetzung zeigt der nächste Teil dieser Artikelserie.