前言
上一期做完 JSON 查看器之后,我自己用了几天。
第三天我就烦了。每次打开应用,都要重新点"打开文件"选同一个 JSON。关闭再打开,上次展开的节点全收了。窗口位置也不记得,永远出现在屏幕左上角。
我意识到一件事:一个能跑的桌面应用和一个能用的桌面应用,中间差的就是这些"本地能力"。
文件系统、配置存储、HTTP 请求、异步任务------这些东西在 Web 开发里是标配,在 Makepad 里每一件都要自己想办法。这一期我把每种能力的做法和限制都写清楚,不讲虚的。
1. 文件读写:最基础也最容易被忽视
1.1 打开文件对话框
第七期 JSON 查看器里已经用过了,但只写了最简版本。完整一点的用法是这样的:
rust
// UI 描述里先声明 file_dialog
file_dialog := FileDialog {
// 可以预设过滤器
// filter: "JSON Files (*.json)|*.json"
}
#[event]
fn handle_open_file(&mut self, cx: &mut Cx, actions: &Actions) {
if self.ui.button(cx, ids!(open_btn)).clicked(actions) {
self.ui.file_dialog(cx, ids!(file_dialog)).open(cx);
}
}
#[event]
fn handle_file_selected(&mut self, cx: &mut Cx, actions: &Actions) {
if self.ui.file_dialog(cx, ids!(file_dialog)).selected(actions) {
let path = self.ui.file_dialog(cx, ids!(file_dialog)).selected_path(cx);
if let Some(path) = path {
self.current_file_path = Some(path.clone());
self.load_file(cx, &path);
}
}
}
有几个细节值得注意。第一,FileDialog 是异步的------你调用 open 之后不是立刻拿到结果,而是在后续帧里通过 selected 事件拿到路径。第二,文件过滤器在不同平台表现不一致,依赖 Makepad 的平台层实现,别太依赖。
1.2 读文件内容
读文件本身是标准 Rust,不需要 Makepad 参与:
rust
fn load_file(&mut self, cx: &mut Cx, path: &str) {
match std::fs::read_to_string(path) {
Ok(content) => {
self.file_content = content;
self.status_message = format!("已加载: {}", path);
}
Err(e) => {
self.status_message = format!("读取失败: {}", e);
}
}
self.ui.redraw(cx);
}
但这里有一个坑。std::fs::read_to_string 是同步的,在 Makepad 的主事件循环里直接调用,大文件会卡界面。第七期 JSON 查看器里我处理 2MB 文件时踩过这个坑,窗口直接卡了三秒。
后面的章节会讲怎么解决这个问题。
1.3 写文件
写文件同样简单,Rust 标准库就够了:
rust
fn save_file(&mut self, cx: &mut Cx, path: &str) {
match std::fs::write(path, &self.editor_content) {
Ok(_) => {
self.status_message = "已保存".into();
self.is_modified = false;
}
Err(e) => {
self.status_message = format!("保存失败: {}", e);
}
}
self.ui.redraw(cx);
}
写文件通常比读文件快很多(除非你在写几个 G),所以在主线程直接写问题不大。但如果你要写大文件,考虑分块写入或者在帧间拆分。目前 Makepad 没有内置方案,用标准库的 BufWriter 分块写是临时解。
2. 配置持久化:让应用有"记忆"
2.1 最简单的方案:JSON 配置文件
一个桌面应用至少应该记住:
- 窗口位置和大小
- 最近打开的文件列表
- 用户偏好(主题、字体大小、默认目录等)
最简单的做法是把配置序列化为 JSON,存在应用数据目录里:
rust
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Default)]
struct AppConfig {
window_x: i32,
window_y: i32,
window_width: i32,
window_height: i32,
recent_files: Vec<String>,
theme: String,
font_size: f32,
}
impl AppConfig {
fn config_path() -> Option<std::path::PathBuf> {
dirs::config_dir().map(|p| p.join("my-app").join("config.json"))
}
fn load() -> Self {
if let Some(path) = Self::config_path() {
if let Ok(content) = std::fs::read_to_string(&path) {
if let Ok(config) = serde_json::from_str(&content) {
return config;
}
}
}
Self::default()
}
fn save(&self) {
if let Some(path) = Self::config_path() {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Ok(json) = serde_json::to_string_pretty(self) {
let _ = std::fs::write(&path, json);
}
}
}
}
几个注意点:
- 用
dirs::config_dir()获取系统标准的配置目录,别自己硬编码路径 - 首次启动时目录可能不存在,记得
create_dir_all - 反序列化失败时不要崩溃------返回默认值,应用照常启动
2.2 什么时候写配置
配置不需要用户点"保存按钮"才写。有几个自然的触发点:
应用关闭时:
rust
#[event]
fn handle_close(&mut self, cx: &mut Cx, actions: &Actions) {
if self.ui.window(cx, ids!(main_window)).close_requested(actions) {
self.config.save();
}
}
窗口移动/调整大小时: 这个比较高频,建议做防抖。在 #[event] 里检查窗口位置,变化超过一定阈值再写,或者定时批量写。
最近文件列表更新时: 每次成功打开新文件就追加到列表并保存。
rust
fn add_recent_file(&mut self, path: &str) {
self.config.recent_files.retain(|f| f != path);
self.config.recent_files.insert(0, path.to_string());
self.config.recent_files.truncate(10); // 最多保留 10 个
self.config.save();
}
2.3 配置的默认值和迁移
一个很容易漏的事:配置结构将来会变。你今天存了 5 个字段,下个版本加了 3 个。
最简单的防御:所有字段用 #[serde(default)],新增字段不会导致旧配置文件加载失败。
rust
#[derive(Serialize, Deserialize)]
struct AppConfig {
window_x: i32,
window_y: i32,
#[serde(default = "default_width")]
window_width: i32,
#[serde(default)]
recent_files: Vec<String>,
#[serde(default)]
theme: String,
#[serde(default = "default_font_size")]
font_size: f32,
}
3. HTTP 请求:怎么接 API
桌面应用接 API 的场景比你想象的多------检查更新、同步数据、获取在线资源。Makepad 本身没有 HTTP 客户端,但 Rust 生态有成熟的方案。
3.1 选 reqwest,别自己造轮子
toml
[dependencies]
reqwest = { version = "0.12", features = ["blocking"] }
选 blocking 特征是因为 Makepad 没有内置的 async runtime。后面会讲怎么处理阻塞调用。
一个最简单的 GET 请求:
rust
fn fetch_data(&mut self, url: &str) {
match reqwest::blocking::get(url) {
Ok(response) => {
if let Ok(body) = response.text() {
self.api_result = body;
self.status_message = "数据获取成功".into();
}
}
Err(e) => {
self.status_message = format!("请求失败: {}", e);
}
}
self.ui.redraw(cx);
}
3.2 不要在主线程做网络请求
这是最重要的警告。reqwest::blocking::get 在主线程调用会阻塞整个 UI,直到请求返回。网络慢的时候,你的应用看起来像卡死了。
解决思路和第七期处理大文件解析一样:把耗时操作拆到多帧执行。
但目前 Makepad 没有完整的异步支持。下面几种临时方案,选哪个看你的场景。
方案一:小请求直接在主线程做。
如果你的 API 响应很快(比如本地服务、内网接口),几百毫秒内就返回,用户可能不会注意到。但别在生产环境这么做,只适合开发调试。
方案二:用标准库的线程。
rust
use std::thread;
fn fetch_async(&mut self, url: String) {
self.is_loading = true;
let url_clone = url.clone();
thread::spawn(move || {
match reqwest::blocking::get(&url_clone) {
Ok(response) => {
if let Ok(body) = response.text() {
// 问题:怎么把结果传回主线程?
// 用 std::sync::mpsc::channel 或 Arc<Mutex<...>>
}
}
Err(_) => {}
}
});
}
thread::spawn 能让请求不阻塞 UI,但结果传回主线程需要额外的同步机制。最简单的做法是用 std::sync::mpsc::channel:
rust
script_mod!(
mod my_app {
// 线程间通信
pending_result: Option<String>,
fn check_thread_result(&mut self) {
if let Ok(result) = self.result_receiver.try_recv() {
self.pending_result = Some(result);
self.is_loading = false;
}
}
}
);
然后在每一帧的 #[event] 函数里调用 check_thread_result,或者在专门的 update_display handler 里处理。
这个方案能跑,但写起来不太舒服。线程生命周期管理、panic 处理、请求取消都是你手动管。
方案三(推荐):拆分任务,用帧间状态机。
如果你的 API 调用不是高频操作(比如启动时检查更新、用户手动点"同步"按钮),可以不用线程。把请求拆成多帧执行的小步骤:
帧 1:发起请求前的准备工作
帧 2:执行请求(用户看到加载状态)
帧 3:处理响应,更新 UI
本质上就是把一个长任务切成多个 #[event] 函数的调用。算不上真正的异步,但至少不会卡 UI。
这个方向 Makepad 团队也在做。官方正在推进内置的异步支持,等它成熟了,这些临时方案就可以扔了。
4. 文件系统监控(可选但好用)
JSON 查看器里还有一个我后来加的功能:如果打开的文件被外部程序修改了,自动提示重新加载。
实现思路:存下文件的 modified 时间戳,定时检查是否变化。Rust 标准库就够:
rust
use std::fs;
fn get_modified_time(path: &str) -> Option<std::time::SystemTime> {
fs::metadata(path).ok()?.modified().ok()
}
fn check_file_changed(&mut self) {
if let Some(path) = &self.current_file_path {
let current_mtime = get_modified_time(path);
if current_mtime != self.last_modified {
self.last_modified = current_mtime;
self.status_message = "⚠ 文件已被外部修改,请重新加载".into();
}
}
}
在 update_display handler 里每隔一定帧数调用一次 check_file_changed。不用太频繁,每 30 帧检查一次就够了。
5. 把这些能力串起来:一个完整的启动和关闭流程
下面是第七期 JSON 查看器加上本章所有本地能力后的完整流程:
应用启动时:
markdown
1. 加载配置文件 (AppConfig::load())
2. 恢复窗口位置和大小
3. 检查最近文件列表,如果上次有打开的文件,自动加载
4. 恢复上次展开的节点状态(记住展开状态也可以存进配置)
应用运行时:
diff
- 用户打开文件 → 先读文件 → 解析 → 更新最近文件列表 → 保存配置
- 用户调整窗口 → 防抖后保存窗口位置到配置
- 用户展开/折叠节点 → 更新展开状态集合,定期写入配置
- 检查文件是否被外部修改 → 提示重新加载
- (可选)启动时检查更新 → HTTP 请求 + 线程
应用关闭时:
markdown
1. 保存配置 (config.save())
2. 如果文件有未保存的修改,弹出提示
3. 清理临时文件
5.1 应用启动时自动加载上次文件
rust
#[run]
fn main(&mut self, cx: &mut Cx) -> Result<(), String> {
self.config = AppConfig::load();
// 恢复窗口位置
if self.config.window_width > 0 {
// 设置窗口位置和大小...
}
// 自动打开上次的文件
if let Some(last_file) = self.config.recent_files.first() {
let path = last_file.clone();
self.load_file(cx, &path);
}
Ok(())
}
5.2 退出前保存
rust
#[event]
fn handle_events(&mut self, cx: &mut Cx, actions: &Actions) {
if self.ui.window(cx, ids!(main_window)).close_requested(actions) {
// 保存展开状态到配置
self.config.expanded_nodes = self.expanded_nodes.iter().cloned().collect();
self.config.save();
}
}
6. Makepad 中 Rust 标准库的可用性
一个常被问到的问题:Makepad 应用里能用所有 Rust crate 吗?
答案是:大部分能,但有几个要注意的。
能用的:
std::fs--- 文件读写,完全可用std::net--- 网络,但要开线程serde/serde_json--- 序列化,完全可用dirs--- 获取系统标准目录,跨平台可用chrono/time--- 时间处理,完全可用
需要小心的:
reqwest--- 使用 blocking 模式,注意线程安全tokio--- Makepad 没有内置 tokio runtime,如果你非要用,需要自己起 runtimerusqlite--- SQLite 绑定,可以用但注意文件锁和线程
基本不能用的:
- 依赖系统 GUI 组件的库(比如某些弹窗、系统托盘库)
- 和 Makepad 自己的平台抽象冲突的库
一个很实用的经验:如果你不确定一个 crate 能不能在 Makepad 里用,看它有没有"同步 API"版本。 有同步 API 的几乎都能直接用,纯异步的需要额外处理。
7. 当前阶段的权衡
写到这里,必须坦诚一件事。
Makepad 在本地能力接入上,目前更像是一个"你要自己来"的框架,而不是一个"开箱即用"的平台。跟 Tauri 比,Tauri 的文件对话框、系统通知、自动更新都有现成插件。Makepad 里每件小事都要你自己写。
但这不全是坏事。因为你自己写的代码没有中间层,出了问题你能看到全部调用链。用 Tauri 插件遇到 bug 的时候,你不是在修自己的代码,是在翻插件源码。
现阶段我的建议:把本地能力当基础设施自己搭。 写一遍 load_config、save_config、add_recent_file,后面所有项目都能复用。花的时间是一次性的。
总结
这一期解决的就是"桌面应用不只是个壳子"的问题。
六个能力点:
- 文件读写 ---
std::fs,大文件注意别卡主线程 - 配置持久化 --- JSON +
dirs::config_dir(),默认值用#[serde(default)] - 最近文件列表 --- 打开文件时追加,启动时恢复
- HTTP 请求 ---
reqwest::blocking,耗时调用拆到线程里 - 文件变更检测 --- 比较
modified时间戳 - 应用生命周期 --- 启动加载配置 → 运行按需保存 → 退出持久化
每件事单看都不复杂。但全部接上之后,你的应用从"能演示"变成了"能日常用"。这一步的价值,比多写一个炫酷动画大多了。
下一期聊工程化实践:模块划分、错误处理、日志和调试、代码可维护性。从 demo 作者视角升级到工程作者视角。