Bỏ qua để đến nội dung

Ứng dụng GUI bằng Rust: Tauri, egui, Slint và Iced

Sớm hay muộn bạn sẽ muốn có một cửa sổ thật với các nút bấm, chứ không chỉ là chữ trong terminal. Rust làm được điều đó không? Có. Ứng dụng GUI viết bằng Rust nhanh, nhỏ và an toàn, và hiện có vài bộ công cụ (toolkit) tốt. Nhưng thế giới GUI của Rust còn trẻ hơn, chẳng hạn, C# với WPF hay JavaScript với Electron, nên bạn nên nắm bức tranh chung trước khi chọn một cái.

Trang này cho bạn một cái nhìn tổng quan thẳng thắn, một bảng giúp bạn chọn, ý tưởng đằng sau immediate mode và retained mode, và một chương trình nhỏ đầu tiên cho những toolkit phổ biến nhất.

Bạn chạy code ở đâu? Một GUI cần một cửa sổ thật, nên các chương trình egui, Tauri, Slint và Iced dành cho máy của bạn với cargo run. Các chương trình egui và Iced trên trang này đã được biên dịch bằng cargo check với các phiên bản hiện tại. Hai phần mô phỏng một GUI bằng Rust thuần, in ra chữ thay vì vẽ cửa sổ, nên chúng chạy được ngay trong trình duyệt. Hãy tìm các nút “Try it Yourself”.

Trang cộng đồng Are we GUI yet? (tiếng Anh) tóm gọn trong một câu: “The roots aren’t deep but the seeds are planted” (rễ chưa sâu nhưng hạt giống đã được gieo). Câu đó đến giờ vẫn đúng:

  • Rất tốt: tốc độ, ứng dụng nhỏ, và sự an toàn của Rust. egui, Iced và Slint tự vẽ mọi thứ, nên một code base chạy được trên Windows, macOS và Linux. Tauri cho bạn tận dụng kỹ năng web và giữ ứng dụng nhỏ hơn Electron rất nhiều, vì nó dùng webview có sẵn trong hệ thống.
  • Vẫn đang phát triển: chưa có một toolkit “chuẩn” duy nhất như Qt trong C++ hay WPF trong C#. Nhiều crate phổ biến vẫn ở phiên bản 0.x, nên các bản cập nhật có thể làm hỏng code của bạn. Khả năng hỗ trợ tiếp cận (accessibility), chữ viết từ phải sang trái và giao diện giống ứng dụng gốc của hệ điều hành khác nhau tùy toolkit.
  • Tin tốt: bạn không cần tìm ra toolkit hoàn hảo. Với ứng dụng đầu tiên, toolkit nào trong bảng bên dưới cũng dùng tốt.

Muốn so sánh dài hơn, bài 2025 survey of Rust GUI libraries (tiếng Anh) thử từng toolkit một.

Những ứng dụng thật bạn có thể xem ngay hôm nay:

  • Rerun Viewer (tiếng Anh), một công cụ để xem dữ liệu từ robot và thị giác máy tính, được xây bằng egui. (Rerun cũng tài trợ cho việc phát triển egui.)
  • COSMIC, môi trường desktop của System76 cho Linux, được xây bằng Iced. Ứng dụng chat IRC Halloy cũng vậy.
  • Công cụ desktop với giao diện kiểu web: ứng dụng ghi chú, dashboard và công cụ nhỏ cho doanh nghiệp thường rất hợp với Tauri.
  • Màn hình trên thiết bị: Slint còn chạy được trên vi điều khiển, nên nó hợp với màn hình cảm ứng trên máy móc và đồ gia dụng.

Phiên bản là bản phát hành mới nhất vào tháng 10 năm 2026.

Crate Phiên bản Cách bạn viết giao diện Chạy trên Chọn nó khi
Tauri 2.12 (3.0 đang alpha) HTML, CSS và JavaScript cho màn hình; Rust cho phần logic Windows, macOS, Linux, Android, iOS Bạn biết lập trình web, hoặc muốn một giao diện phong phú, nhiều kiểu dáng
egui + eframe 0.37 Rust thuần, immediate mode Windows, macOS, Linux, web (Wasm) Công cụ, bảng debug, trình soạn thảo, và cách bắt đầu nhanh nhất bằng Rust thuần
Slint 1.18 Ngôn ngữ markup .slint riêng, cộng Rust cho phần logic Desktop, nhúng (embedded), di động, web Một ứng dụng chỉn chu, hoặc màn hình trên thiết bị nhúng
Iced 0.14 Rust thuần, kiến trúc Elm (state, message, update, view) Windows, macOS, Linux, web Bạn thích code gọn gàng, dễ đoán, mọi thay đổi nằm ở một chỗ
gtk-rs (gtk4) 0.11 Binding Rust cho GTK 4, retained mode Ưu tiên Linux; Windows và macOS chạy được nhưng cần cài đặt thêm Ứng dụng trông như ứng dụng gốc cho desktop GNOME trên Linux
Dioxus 0.7 Component giống React với macro rsx! Web, desktop (webview), di động Bạn biết React và muốn một code base cho cả web lẫn desktop

Vài lưu ý thẳng thắn về giấy phép và độ trưởng thành:

  • Slint miễn phí theo GPLv3, hoặc miễn phí cho ứng dụng desktop, di động và web mã nguồn đóng theo giấy phép royalty-free của nó (bạn phải ghi rõ là mình dùng Slint). Sản phẩm nhúng mã nguồn đóng cần giấy phép trả phí. Hãy đọc slint.dev/pricing (tiếng Anh) trước khi phát hành.
  • egui, Iced và Dioxus vẫn ở 0.x. Hãy chờ đón những thay đổi nhỏ không tương thích ở mỗi bản phát hành, và kiểm tra xem hướng dẫn có khớp với phiên bản của bạn không. Ví dụ, các hướng dẫn egui cũ dùng fn update(&mut self, ctx: &egui::Context, ...), còn eframe 0.37 dùng fn ui(&mut self, ui: &mut egui::Ui, ...).
  • Tauri, Dioxus desktop và gtk-rs phụ thuộc vào thư viện hệ thống. Xem Yêu cầu theo nền tảng bên dưới.

Các toolkit bất đồng ở một câu hỏi lớn: ai ghi nhớ các widget?

Trong retained mode (gtk-rs, Slint, trang web của Tauri, và phần lớn toolkit trong các ngôn ngữ khác), bạn tạo một nút bấm một lần. Toolkit giữ nó trong một cây widget. Bạn gắn một callback, và khi người dùng bấm, toolkit gọi callback của bạn. Muốn đổi chữ trên nút sau đó, bạn cần một handle trỏ tới nút đó.

Trong immediate mode (egui), không có gì được giữ lại. Hàm giao diện của bạn chạy lại ở mỗi khung hình (frame) và chỉ đơn giản mô tả cần hiển thị gì ngay lúc này. Một nút được vẽ và kiểm tra chỉ trong một dòng: if ui.button("+").clicked() { ... }. Không có callback, không có handle. Trạng thái (state) của ứng dụng nằm trong struct của chính bạn, và giao diện luôn được vẽ từ đó, nên màn hình không bao giờ lệch khỏi dữ liệu.

Cái giá phải trả: toolkit tính bố cục toàn bộ giao diện ở mỗi khung hình, nên một giao diện rất lớn tốn nhiều CPU hơn, và một số bố cục (như căn giữa một cửa sổ chưa biết kích thước) khó làm hơn. egui chỉ vẽ khung hình mới khi có chuyện gì đó xảy ra, như chuột di chuyển, nên một ứng dụng đang rảnh gần như không dùng CPU.

Đây là một toolkit immediate mode “giả vờ”, tí hon, viết bằng Rust thuần. Mỗi bước lặp là một khung hình. Mảng clicks đóng vai con chuột:

// Một "Ui" giả vờ, ghi lại những gì được vẽ trong khung hình này.
struct Ui {
clicked: Option<&'static str>, // nút mà chuột đã bấm trong khung hình này
drawn: Vec<String>,
}
impl Ui {
fn label(&mut self, text: &str) {
self.drawn.push(text.to_string());
}
// Vẽ một nút VÀ cho bạn biết ngay nó có được bấm hay không.
fn button(&mut self, text: &'static str) -> bool {
self.drawn.push(format!("[{text}]"));
self.clicked == Some(text)
}
}
// Code giao diện của bạn: được gọi lại ở mỗi khung hình.
fn draw(count: &mut i32, ui: &mut Ui) {
ui.label(&format!("Đếm: {count}"));
if ui.button("+") {
*count += 1;
}
if ui.button("Đặt lại") {
*count = 0;
}
}
fn main() {
let mut count = 0; // state của ứng dụng nằm bên ngoài code giao diện
let clicks = [None, Some("+"), Some("+"), None, Some("Đặt lại"), None];
for (frame, click) in clicks.into_iter().enumerate() {
let mut ui = Ui { clicked: click, drawn: Vec::new() };
draw(&mut count, &mut ui);
println!("khung {frame}: {}", ui.drawn.join(" "));
}
}

Kết quả:

khung 0: Đếm: 0 [+] [Đặt lại]
khung 1: Đếm: 0 [+] [Đặt lại]
khung 2: Đếm: 1 [+] [Đặt lại]
khung 3: Đếm: 2 [+] [Đặt lại]
khung 4: Đếm: 2 [+] [Đặt lại]
khung 5: Đếm: 0 [+] [Đặt lại]
▶ Tự chạy thử

Nhìn vào khung 1: bạn bấm “+”, nhưng nhãn vẫn ghi 0. Nhãn đã được vẽ trước khi nút nhận ra cú bấm. Giá trị mới xuất hiện ở khung hình sau. egui thật lo việc này giúp bạn: bất kỳ cú bấm chuột hay phím nào cũng khiến nó vẽ ngay khung hình tiếp theo, nên bạn không bao giờ thấy con số cũ.

eframe là “app framework” chính thức của egui: nó mở cửa sổ và chạy egui bên trong. Tạo một project và thêm nó vào:

Terminal window
cargo new counter
cd counter
[dependencies]
eframe = "0.37"

Thay src/main.rs bằng đoạn này:

use eframe::egui;
fn main() -> eframe::Result {
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default().with_inner_size([300.0, 160.0]),
..Default::default()
};
eframe::run_native(
"Counter",
options,
Box::new(|_cc| Ok(Box::<CounterApp>::default())),
)
}
#[derive(Default)]
struct CounterApp {
count: i32,
}
impl eframe::App for CounterApp {
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
egui::CentralPanel::default().show(ui, |ui| {
ui.heading(format!("Count: {}", self.count));
ui.horizontal(|ui| {
if ui.button("-").clicked() {
self.count -= 1;
}
if ui.button("+").clicked() {
self.count += 1;
}
if ui.button("Reset").clicked() {
self.count = 0;
}
});
});
}
}

Chạy bằng cargo run. Lần build đầu tải về và biên dịch vài trăm crate, nên hãy cho nó vài phút. Những lần build sau sẽ nhanh. Một cửa sổ nhỏ mở ra với dòng “Count: 0” và ba nút.

Các phần khớp với nhau thế nào:

  • CounterApp là state của bạn. #[derive(Default)] cho count bắt đầu từ 0.
  • run_native mở cửa sổ. Closure tạo ứng dụng của bạn một lần, bên trong một Box (xem Con trỏ thông minh).
  • eframe gọi ui ở mỗi khung hình. CentralPanel lấp đầy cửa sổ, còn ui.horizontal xếp ba nút thành một hàng.
  • Mỗi if ui.button(..).clicked() chính là mẫu immediate mode ở phần trên: vẽ nút, kiểm tra nó, thay đổi state.

Đây cũng là lý do bạn phải giữ state trong self, đừng bao giờ để trong một biến cục bộ bên trong ui: một dòng let mut count = 0; ở đó sẽ bắt đầu lại từ 0 ở mỗi khung hình.

Một command của Tauri: Rust đằng sau một trang web

Phần tiêu đề “Một command của Tauri: Rust đằng sau một trang web”

Một ứng dụng Tauri có hai phần. Frontend là một trang web bình thường (HTML, CSS và JavaScript, hoặc một framework như React hay Svelte) chạy trong webview của hệ thống. Backend là code Rust của bạn. Frontend gọi các hàm Rust gọi là command.

Cách bắt đầu dễ nhất là công cụ tạo project chính thức. Nó hỏi tên và một template cho frontend (template “vanilla”, không dùng framework, là lựa chọn đầu tiên tốt):

Terminal window
cargo install create-tauri-app --locked
cargo create-tauri-app

Cũng có npm create tauri-app@latest nếu bạn đã dùng Node.js. Sau đó vào thư mục mới và khởi động ứng dụng bằng npm install và npm run tauri dev (với template npm), hoặc cargo install tauri-cli --version "^2.0.0" --locked và cargo tauri dev (với template chỉ dùng Rust). Công cụ sẽ in ra đúng các lệnh cho lựa chọn của bạn.

File src-tauri/src/lib.rs được sinh ra chứa command đầu tiên của bạn:

// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}! You've been greeted from Rust!", name)
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![greet])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

Và frontend (src/main.js trong template vanilla, đã rút gọn ở đây) gọi nó như sau:

const { invoke } = window.__TAURI__.core;
greetMsgEl.textContent = await invoke("greet", { name: greetInputEl.value });

Chuyện gì đang diễn ra:

  • #[tauri::command] là một attribute macro (xem Procedural macro). Nó viết phần code “keo dán” biến lời gọi từ JavaScript thành một lời gọi Rust bình thường.
  • Các đối số đến dưới dạng JSON, nên kiểu của mỗi đối số phải implement Deserialize của serde, còn kiểu trả về phải implement Serialize (xem JSON với Serde). Trong JavaScript, hãy viết tên đối số theo camelCase: tham số Rust invoke_message trở thành { invokeMessage: "..." }.
  • generate_handler![greet] đăng ký command. Nếu bạn quên thêm một command mới vào đây, lời gọi từ JavaScript sẽ thất bại.
  • Trả về một Result để báo lỗi: Ok resolve promise của JavaScript, còn Err reject nó, nên bạn có thể xử lý bằng .catch(). Ví dụ, fn load(path: String) -> Result<String, String>.
  • main.rs chỉ gọi run(). Code nằm trong lib.rs để cùng một ứng dụng cũng build được cho Android và iOS.

Slint: giao diện trong ngôn ngữ markup riêng

Phần tiêu đề “Slint: giao diện trong ngôn ngữ markup riêng”

Slint mô tả màn hình bằng một ngôn ngữ khai báo nhỏ, hơi giống HTML và CSS gộp lại. Code Rust của bạn lo phần logic. Đây là ví dụ hello world chính thức. Thêm slint = "1.18" vào [dependencies] và đặt đoạn này vào src/main.rs:

slint::slint!{
export component HelloWorld {
Text {
text: "hello world";
color: green;
}
}
}
fn main() {
HelloWorld::new().unwrap().run().unwrap();
}

Macro slint! biên dịch phần markup lúc build và tạo cho bạn một kiểu Rust HelloWorld. Với một ứng dụng thật, markup nằm trong file .slint riêng. Bộ đếm này lấy từ template Rust chính thức của Slint (tiếng Anh). File ui/app-window.slint:

import { Button, VerticalBox } from "std-widgets.slint";
export component AppWindow inherits Window {
in-out property <int> counter: 42;
callback request-increase-value();
VerticalBox {
Text {
text: "Counter: \{root.counter}";
}
Button {
text: "Increase value";
clicked => {
root.request-increase-value();
}
}
}
}

Và phía Rust trong src/main.rs:

slint::include_modules!();
fn main() -> Result<(), Box<dyn std::error::Error>> {
let ui = AppWindow::new()?;
let ui_handle = ui.as_weak();
ui.on_request_increase_value(move || {
let ui = ui_handle.unwrap();
ui.set_counter(ui.get_counter() + 1);
});
ui.run()?;
Ok(())
}

Slint là retained mode: thuộc tính counter sống trong giao diện, và nút bấm gọi một callback. Slint sinh ra get_counter, set_counter và on_request_increase_value từ markup (dấu gạch ngang thành dấu gạch dưới). Closure giữ một handle yếu (as_weak()) để cửa sổ và callback của chính nó không giữ nhau sống mãi mãi (xem Weak và Cow). Với một file .slint, bạn còn cần slint-build = "1.18" trong [build-dependencies] và một file build.rs chỉ một dòng: slint_build::compile("ui/app-window.slint").expect("Slint build failed");.

Iced theo kiến trúc Elm, đặt theo tên ngôn ngữ Elm. Ứng dụng nào cũng có bốn phần:

  1. State: một struct chứa mọi thứ ứng dụng ghi nhớ.
  2. Message: một enum liệt kê mọi thứ có thể xảy ra (“nút + vừa được bấm”).
  3. update: nhận state và một message, rồi thay đổi state. Đây là nơi duy nhất có thay đổi.
  4. view: dựng các widget từ state. Widget không tự thay đổi gì; chúng chỉ gửi message.

Đây là bộ đếm Iced chính thức (thêm iced = "0.14" vào [dependencies]):

use iced::widget::{button, column, text, Column};
use iced::Center;
fn main() -> iced::Result {
iced::run(Counter::update, Counter::view)
}
#[derive(Default)]
struct Counter {
value: i64,
}
#[derive(Debug, Clone, Copy)]
enum Message {
Increment,
Decrement,
}
impl Counter {
fn update(&mut self, message: Message) {
match message {
Message::Increment => self.value += 1,
Message::Decrement => self.value -= 1,
}
}
fn view(&self) -> Column<'_, Message> {
column![
button("Increment").on_press(Message::Increment),
text(self.value).size(50),
button("Decrement").on_press(Message::Decrement),
]
.padding(20)
.align_x(Center)
}
}

on_press(Message::Increment) không chạy đoạn code nào cả. Nó chỉ nói “khi được bấm, hãy gửi message này”. Sau đó Iced gọi update với message đó và gọi lại view để hiển thị state mới. Vì mọi thay đổi đều đi qua một match duy nhất, bạn dễ dàng thấy (và test) mọi việc ứng dụng có thể làm. Đây là một cách dùng enum và match rất hay.

Bạn không cần Iced để luyện ý tưởng này. Mô hình to-do dưới đây có đúng bốn phần như vậy. view trả về chữ thay vì widget, và một danh sách message đóng vai việc người dùng gõ phím và bấm chuột:

// 1. State: mọi thứ ứng dụng cần ghi nhớ.
#[derive(Default)]
struct App {
draft: String,
todos: Vec<Todo>,
}
struct Todo {
title: String,
done: bool,
}
// 2. Message: mọi thứ có thể xảy ra.
#[derive(Debug)]
enum Message {
DraftChanged(String),
AddPressed,
Toggle(usize),
ClearDone,
}
impl App {
// 3. update: nơi DUY NHẤT state thay đổi.
fn update(&mut self, message: Message) {
match message {
Message::DraftChanged(text) => self.draft = text,
Message::AddPressed => {
let title = self.draft.trim();
if !title.is_empty() {
self.todos.push(Todo { title: title.to_string(), done: false });
}
self.draft.clear();
}
Message::Toggle(index) => {
if let Some(todo) = self.todos.get_mut(index) {
todo.done = !todo.done;
}
}
Message::ClearDone => self.todos.retain(|todo| !todo.done),
}
}
// 4. view: biến state thành thứ người dùng nhìn thấy (ở đây là chữ).
fn view(&self) -> String {
let mut screen = format!("Mới: [{}]\n", self.draft);
for (index, todo) in self.todos.iter().enumerate() {
let mark = if todo.done { "x" } else { " " };
screen.push_str(&format!("{index}. [{mark}] {}\n", todo.title));
}
screen
}
}
fn main() {
let mut app = App::default();
// Giả vờ những message này đến từ các phím bấm và cú bấm nút.
let messages = vec![
Message::DraftChanged("Mua sữa".to_string()),
Message::AddPressed,
Message::DraftChanged("Gọi ngân hàng".to_string()),
Message::AddPressed,
Message::DraftChanged("Tưới cây".to_string()),
Message::Toggle(0),
];
for message in messages {
println!("> {message:?}");
app.update(message);
}
print!("{}", app.view());
app.update(Message::ClearDone);
println!("> sau ClearDone:");
print!("{}", app.view());
}

Kết quả:

> DraftChanged("Mua sữa")
> AddPressed
> DraftChanged("Gọi ngân hàng")
> AddPressed
> DraftChanged("Tưới cây")
> Toggle(0)
Mới: [Tưới cây]
0. [x] Mua sữa
1. [ ] Gọi ngân hàng
> sau ClearDone:
Mới: [Tưới cây]
0. [ ] Gọi ngân hàng
▶ Tự chạy thử

Để ý rằng view không bao giờ thay đổi gì, còn update không bao giờ in gì. Sự tách biệt đó chính là toàn bộ bí quyết. Để biến đoạn này thành một ứng dụng Iced thật, bạn chỉ cần viết lại view để trả về widget (một text_input gửi DraftChanged, một button gửi AddPressed). State, các message và update giữ nguyên y hệt.

Các crate GUI nói chuyện với hệ điều hành, nên bạn cần nhiều hơn Rust thuần một chút. Trước tiên hãy cài những thứ cơ bản trong Công cụ và cài đặt Rust. Sau đó:

Hệ điều hành egui/eframe, Iced, Slint Tauri cần thêm
Windows Không cần gì thêm ngoài bộ công cụ MSVC C++ WebView2. Nó đã có sẵn trong Windows 10 (phiên bản 1803 trở lên) và Windows 11
macOS Xcode command line tools (xcode-select --install) Không cần gì thêm cho ứng dụng desktop; cần Xcode đầy đủ cho iOS
Linux (Debian/Ubuntu) README của eframe liệt kê libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev libxkbcommon-dev libssl-dev webkit2gtk: libwebkit2gtk-4.1-dev cùng vài gói khác trong trang yêu cầu của Tauri (tiếng Anh)

Nếu một bản build trên Linux thất bại với thông báo từ pkg-config về một thư viện bị thiếu, hãy cài gói -dev của thư viện đó rồi build lại. gtk-rs cần các file phát triển của GTK 4 (libgtk-4-dev trên Ubuntu).

  • Chặn luồng giao diện (UI thread). Một lần tải file hay một phép tính dài bên trong hàm ui của egui hoặc update của Iced sẽ làm đơ cả cửa sổ cho đến khi xong. Hãy làm việc chậm trên một luồng khác hoặc trong một task bất đồng bộ (xem Đồng thời và Async/Await), rồi gửi kết quả về giao diện dưới dạng một message.
  • Giữ state của egui trong biến cục bộ. ui chạy lại ở mỗi khung hình, nên một let mut bên trong nó lần nào cũng bắt đầu lại từ đầu. Hãy đặt state trong struct ứng dụng của bạn.
  • Một command Tauri không bao giờ được đăng ký. Mọi command phải xuất hiện trong tauri::generate_handler![...]. Ngoài ra, trong lib.rs, đừng đánh dấu command là pub: tài liệu Tauri giải thích rằng việc này gây ra lỗi E0255 “defined multiple times”.
  • Làm theo một hướng dẫn cũ. Với các crate 0.x, một ví dụ từ năm ngoái có thể không biên dịch được. Hãy đối chiếu phiên bản trong hướng dẫn với Cargo.toml của bạn, và xem thư mục examples của chính crate đó cho đúng phiên bản bạn dùng.

Viết một bộ đếm kiểu Elm với các message Increment, Decrement và Reset. Giá trị không bao giờ được xuống dưới 0, kể cả khi người dùng cứ bấm “-” mãi. Cho một danh sách message chạy qua update và in view() sau mỗi message.

Gợi ý: dùng u32 và saturating_sub(1), hàm này dừng ở 0 thay vì tràn số (xem Tràn số nguyên).

Xem lời giải
#[derive(Default)]
struct Counter {
value: u32,
}
#[derive(Debug)]
enum Message {
Increment,
Decrement,
Reset,
}
impl Counter {
fn update(&mut self, message: Message) {
match message {
Message::Increment => self.value += 1,
Message::Decrement => self.value = self.value.saturating_sub(1),
Message::Reset => self.value = 0,
}
}
fn view(&self) -> String {
format!("Đếm: {}", self.value)
}
}
fn main() {
let mut counter = Counter::default();
let messages = [
Message::Increment,
Message::Increment,
Message::Decrement,
Message::Decrement,
Message::Decrement,
Message::Increment,
Message::Reset,
];
for message in messages {
let name = format!("{message:?}");
counter.update(message);
println!("{name:<9} -> {}", counter.view());
}
}

Kết quả:

Increment -> Đếm: 1
Increment -> Đếm: 2
Decrement -> Đếm: 1
Decrement -> Đếm: 0
Decrement -> Đếm: 0
Increment -> Đếm: 1
Reset -> Đếm: 0
▶ Tự chạy thử

Quy tắc “không bao giờ xuống dưới 0” nằm đúng ở một chỗ duy nhất, update. Ta định dạng tên message trước khi gọi update, vì update nhận message theo giá trị và move nó đi.

Mở rộng bộ đếm với một message Undo. Mỗi thay đổi ghi nhớ giá trị cũ, và Undo quay lại một bước. Undo khi không còn lịch sử thì không làm gì. Hiển thị số bước hoàn tác còn lại trong view.

Gợi ý: giữ một history: Vec<u32> trong state. push giá trị cũ trước mỗi thay đổi, và pop nó khi gặp Undo.

Xem lời giải
#[derive(Default)]
struct Counter {
value: u32,
history: Vec<u32>, // các giá trị cũ, mới nhất ở cuối
}
#[derive(Debug)]
enum Message {
Increment,
Decrement,
Reset,
Undo,
}
impl Counter {
fn update(&mut self, message: Message) {
let new_value = match message {
Message::Increment => self.value + 1,
Message::Decrement => self.value.saturating_sub(1),
Message::Reset => 0,
Message::Undo => {
if let Some(previous) = self.history.pop() {
self.value = previous;
}
return;
}
};
self.history.push(self.value); // ghi nhớ giá trị cũ trước đã
self.value = new_value;
}
fn view(&self) -> String {
format!("Đếm: {} (còn {} bước hoàn tác)", self.value, self.history.len())
}
}
fn main() {
let mut counter = Counter::default();
let messages = [
Message::Increment,
Message::Increment,
Message::Decrement,
Message::Increment,
Message::Reset,
Message::Undo,
Message::Undo,
Message::Undo,
];
for message in messages {
let name = format!("{message:?}");
counter.update(message);
println!("{name:<9} -> {}", counter.view());
}
}

Kết quả:

Increment -> Đếm: 1 (còn 1 bước hoàn tác)
Increment -> Đếm: 2 (còn 2 bước hoàn tác)
Decrement -> Đếm: 1 (còn 3 bước hoàn tác)
Increment -> Đếm: 2 (còn 4 bước hoàn tác)
Reset -> Đếm: 0 (còn 5 bước hoàn tác)
Undo -> Đếm: 2 (còn 4 bước hoàn tác)
Undo -> Đếm: 1 (còn 3 bước hoàn tác)
Undo -> Đếm: 2 (còn 2 bước hoàn tác)
▶ Tự chạy thử

Thêm Undo thật dễ, vì mọi thay đổi vốn đã đi qua update. Trong một ứng dụng Iced thật, bạn chỉ cần thêm một nút nữa gửi Message::Undo.

  • GUI bằng Rust hiện đã dùng tốt, nhưng chưa có một toolkit chuẩn duy nhất, và nhiều crate phổ biến vẫn ở 0.x.
  • Tauri ghép một frontend web với một backend Rust; egui là immediate mode bằng Rust thuần; Slint dùng markup riêng và còn chạy trên thiết bị nhúng; Iced dùng kiến trúc Elm.
  • Retained mode giữ một cây widget và gọi các callback của bạn. Immediate mode vẽ lại giao diện từ state của bạn ở mỗi khung hình, không có callback.
  • Kiến trúc Elm chia ứng dụng thành state, message, update và view, giúp các thay đổi dễ theo dõi và dễ test.
  • Tauri cần WebView2 trên Windows và webkit2gtk trên Linux. Xem Công cụ và cài đặt Rust cho những thứ cơ bản.

Kiểm tra nhanh

1. Trong egui, vì sao giá trị bộ đếm phải nằm trong struct ứng dụng chứ không phải trong một biến cục bộ bên trong ui?

2. Trong một ứng dụng Tauri, #[tauri::command] làm gì?

3. Trong kiến trúc Elm mà Iced dùng, state của ứng dụng thay đổi ở đâu?

4. Trên Linux, Tauri cần thứ gì mà egui không cần?

Điểm: 0 / 4