前言
我有个习惯:每做完一个小项目,过两周再回头看代码。
上一期 JSON 查看器写完之后,我晾了十天,然后打开 main.rs。看到第 80 行,我眉头就开始皱。main.rs 里塞了 UI 描述、事件处理、文件读取、JSON 解析、搜索逻辑,混在一起。四百多行,没有一条注释。
那一刻我突然理解了一个以前不太服气的事实:Demo 和项目之间的差距,不是功能多少,是别人(包括三个月后的自己)能不能读。
这一期不是教你写新功能。是讲我后来做了哪些事,让这个 JSON 查看器从一个"能跑的 main.rs"变成了一个"敢给别人看的项目"。
1. 模块划分:别再往 main.rs 里塞一切
1.1 症状:上帝文件
第一次写 Makepad 项目,几乎所有人都会把一切塞进 main.rs。因为 script_mod! 宏把 UI 和逻辑放在了同一个文件里,这给了你一个错觉:一个文件就够了。
但 main.rs 超过 200 行之后,每次改东西你都要上下翻很久。更致命的是:你不敢删。因为你不确定那段代码是不是还活着。
1.2 拆法:数据、逻辑、UI 各管各的
拆模块不需要什么高深的架构。一个足够用的原则:能不能一句话说清楚这个文件是干什么的。
JSON 查看器最后拆成了这样:
text
json-viewer/
├── Cargo.toml
└── src/
├── main.rs # 入口:script_mod! + UI 描述 + 事件路由
├── app.rs # 应用状态和核心逻辑
├── json_model.rs # JSON 数据模型:解析、树结构、扁平化
├── search.rs # 搜索逻辑
├── config.rs # 配置持久化
└── theme.rs # 设计变量
main.rs 从 450 行砍到了约 150 行。只留三件事:script_mod! 宏(UI 描述)、#[run] 初始化、#[event] 事件路由。所有逻辑都委托给其他模块。
rust
// main.rs 里的 #[event] 变成了这样
#[event]
fn handle_events(&mut self, cx: &mut Cx, actions: &Actions) {
// 路由到各个模块
self.handle_file_events(cx, actions);
self.handle_search_events(cx, actions);
self.handle_tree_events(cx, actions);
self.handle_close_events(cx, actions);
}
每个 handle_xxx_events 在 app.rs 里实现。main.rs 不关心细节,只做分发。
1.3 Makepad 特有的约束
有一件事得说清楚。因为 script_mod! 是一个宏,UI 描述 DSL 和 Rust 代码必须在同一个宏体内。这意味着你没办法把 UI 描述拆到另一个文件里(至少目前是这样)。
但你可以把逻辑拆出去。script_mod! 里的 Rust 代码可以调用其他模块的函数。只要你的数据模型是普通 struct,不在宏内部定义,就能自由拆分。
一个实用经验:把 script_mod! 当成一个"胶水层",不要往里塞业务逻辑。 胶水层只做三件事:声明 UI、处理事件、把调用分发到其他模块。
2. 错误处理:别让 panic 带走整个窗口
2.1 Makepad 里 panic 的后果
Rust 标准做法是用 Result 处理错误。但在 Makepad 里有一个额外的问题:如果 #[event] 函数里 panic 了,整个窗口直接崩溃。 没有全局错误边界,没有 fallback UI。
这就是为什么你在 Makepad 代码里不能随便 unwrap()。
2.2 用状态字段承载错误,而不是 panic
我的做法很简单:所有可能失败的操作,错误不往上抛,而是存进状态字段,让 UI 来展示。
rust
// ❌ 之前的写法:出错就 panic
fn load_file(&mut self, path: &str) {
let content = std::fs::read_to_string(path).unwrap();
self.file_content = content;
}
// ✅ 改了之后:错误存起来
fn load_file(&mut self, path: &str) {
match std::fs::read_to_string(path) {
Ok(content) => {
self.file_content = content;
self.error_message = None;
}
Err(e) => {
self.error_message = Some(format!("读取文件失败: {}", e));
}
}
self.ui.redraw(cx);
}
然后在 update_display 里检查 error_message,有错误就在状态栏显示出来:
rust
if let Some(ref msg) = self.error_message {
self.ui.label(cx, ids!(status_label))
.set_text(cx, &format!("⚠️ {}", msg));
} else {
self.ui.label(cx, ids!(status_label))
.set_text(cx, &self.status_message);
}
用户看到的是友好的错误提示,而不是窗口凭空消失。
2.3 配置加载要能优雅降级
前面第八期提过,这里再强调一次:配置文件加载失败时,应用不能崩溃。
rust
fn load_config() -> AppConfig {
match std::fs::read_to_string(config_path()) {
Ok(content) => {
match serde_json::from_str(&content) {
Ok(config) => config,
Err(_) => AppConfig::default(), // 格式坏了,用默认
}
}
Err(_) => AppConfig::default(), // 文件不存在,用默认
}
}
三个原则:
- 文件不存在 → 默认配置
- 格式损坏 → 默认配置
- 字段缺失 →
#[serde(default)]补默认值
应用永远能启动。哪怕配置文件被手动改坏了,也不会崩。
3. 日志和调试:别再靠 println 猜问题
3.1 println 的局限
Makepad 是 GUI 应用,println! 打印到终端,但如果你双击启动应用(没有终端窗口),你什么也看不到。
更麻烦的是:println! 没有时间戳、没有级别、不能过滤。出问题的时候你不知道这些输出是多久之前打的。
3.2 接一个轻量日志库
log + env_logger 是最轻的选择:
toml
[dependencies]
log = "0.4"
env_logger = "0.11"
应用启动时初始化:
rust
#[run]
fn main(&mut self, cx: &mut Cx) -> Result<(), String> {
env_logger::init();
log::info!("应用启动");
// ...
}
然后在代码里用 log::info!、log::warn!、log::error! 替代 println!:
rust
fn load_file(&mut self, path: &str) {
match std::fs::read_to_string(path) {
Ok(content) => {
log::info!("文件加载成功: {} ({} bytes)", path, content.len());
self.file_content = content;
}
Err(e) => {
log::error!("文件加载失败: {} - {}", path, e);
self.error_message = Some(format!("读取失败: {}", e));
}
}
}
运行时设置 RUST_LOG=debug 或 RUST_LOG=info 控制日志级别。开发时开 debug,发布时关掉或只留 error。
3.3 一个专门的 debug 面板
JSON 查看器里我加了一个隐藏的调试面板,按 Ctrl+D 切出来:
rust
show_debug_panel: bool = false,
#[event]
fn handle_keyboard(&mut self, cx: &mut Cx, actions: &Actions) {
if self.ui.key_down(cx, actions).is_some() {
if cx.key_pressed(Key::D) && cx.modifiers.ctrl {
self.show_debug_panel = !self.show_debug_panel;
self.ui.redraw(cx);
}
}
}
debug 面板里显示:
- 当前加载的文件路径
- 节点总数和展开数
- 最近 10 条日志(存在一个
Vec<String>里循环写入) - 当前帧率(如果平台支持)
这些东西平时隐藏,开发调试时一个快捷键切出来。比反复加 println! 高效得多。
3.4 一个容易忽略的点:Mac 上双击启动看不到日志
如果你用 env_logger,日志默认打到 stderr。Mac 上双击 .app 启动时没有终端,日志会丢失。
解决方法:写到一个日志文件里。
rust
use log::LevelFilter;
use log4rs::config::{Appender, Config, Root};
fn setup_file_logging() {
let logfile = dirs::data_dir()
.unwrap()
.join("json-viewer")
.join("app.log");
let _ = std::fs::create_dir_all(logfile.parent().unwrap());
log4rs::init_config(
Config::builder()
.appender(Appender::builder().build("file", Box::new(
log4rs::append::file::FileAppender::builder().build(logfile).unwrap()
)))
.build(Root::builder().appender("file").build(LevelFilter::Info))
.unwrap()
).ok();
}
这样不管怎么启动应用,日志都会落到文件里。用户报 bug 的时候,让他把日志文件发过来。
4. 代码可维护性的几个实践
4.1 一个 handler 只做一件事
当 #[event] 函数超过 50 行,就该拆了。这是我从 JSON 查看器重构中学到的最疼的教训。
rust
// ❌ 之前:一个 handler 处理所有事情,100+ 行
#[event]
fn handle_everything(&mut self, cx: &mut Cx, actions: &Actions) {
// 打开文件、保存文件、搜索、展开折叠、关闭窗口...
// 全在这里
}
// ✅ 之后:按职责拆成多个
#[event] fn handle_file_open(&mut self, cx: &mut Cx, actions: &Actions) { ... }
#[event] fn handle_file_save(&mut self, cx: &mut Cx, actions: &Actions) { ... }
#[event] fn handle_search(&mut self, cx: &mut Cx, actions: &Actions) { ... }
#[event] fn handle_tree(&mut self, cx: &mut Cx, actions: &Actions) { ... }
#[event] fn update_display(&mut self, cx: &mut Cx, _actions: &Actions) { ... }
Makepad 允许任意多个 #[event] 函数,每帧都会被调用。利用这个特性,一个 handler 只关心一种事件类型。这样改一个功能只需要动一个函数,不会影响其他的。
4.2 状态字段用注释分区,别用脑记
在 script_mod! 里,状态字段是平铺的。项目大了以后很容易找不到某个字段在哪里。
rust
script_mod!(
mod json_viewer {
// ===== 文件状态 =====
current_file_path: Option<String>,
is_modified: bool,
// ===== JSON 数据 =====
json_root: Option<JsonNode>,
expanded_nodes: HashSet<String>,
// ===== 搜索 =====
search_keyword: String,
search_results: Vec<String>,
search_index: usize,
// ===== UI 状态 =====
error_message: Option<String>,
show_debug_panel: bool,
// ===== 设计变量 =====
color_primary: Vec4,
font_size_body: f64,
}
);
分了区之后,新增字段该放哪、找字段该去哪,一目了然。这是第五期讲过的前缀分组法的升级版------前缀变成了注释分区。
4.3 设计变量的"可维护性"含义
第六期我们建了设计系统变量。但当时主要是为了"统一视觉"。现在回头看,它还有一个更重要的价值:可维护性。
当你的项目有 20 个按钮、15 个标签、10 个卡片背景的时候,散落的色值不只是"不统一"的问题,是"改不动"的问题。
换个主色要改 20 个地方,漏一个就会有按钮颜色不对。而有了变量,改一处全部生效。
text
// 改之前:散落的色值
Button { draw_bg: { color: #4A90D9 } }
Button { draw_bg: { color: #4A90D9 } }
Button { draw_bg: { color: #4A90D9 } }
// ... 20 个地方
// 改之后:统一变量
color_primary: #4A90D9,
Button { draw_bg: { color: (color_primary) } }
// 换主色只改一行
这个道理写 CSS 的人都懂。Makepad 里一样适用。
5. 我现在的项目模板
经过这几期的踩坑,我现在启动新 Makepad 项目,目录结构是固定的:
text
my-app/
├── Cargo.toml
└── src/
├── main.rs # script_mod! 胶水层(UI + 事件路由)
├── app.rs # 应用状态和核心逻辑
├── config.rs # 配置持久化
├── theme.rs # 设计变量
├── widgets/ # 自定义控件(如果有的话)
└── platform/ # 平台相关代码(如果有的话)
main.rs 不超过 200 行。每个模块的职责用一句话能说清楚。新增功能优先加新模块,不往里塞。
这不是什么"最佳实践",只是我在踩了足够多坑之后,发现这样写最不容易被自己写的代码气到。
总结
这一期没有新 API,没有新功能。聊的全是"代码怎么写才能活过三个月"。
四件事:
模块划分。 main.rs 是胶水层,只做 UI + 事件路由。业务逻辑拆到独立模块,一个文件只做一件事。
错误处理。 别 unwrap。错误存进状态字段,让 UI 展示给用户。配置加载永远能优雅降级。
日志和调试。 println 不够用。用 log + env_logger,加一个隐藏的 debug 面板。Mac 双击启动要写日志文件。
可维护性纪律。 一个 handler 一个职责,状态字段用注释分区,设计变量统一管理。
这些东西不性感,不难。但做了和没做,三个月后的区别就是:一个是"还能改",一个是"不敢碰"。
下一期聊打包和跨平台交付:怎么把 Makepad 应用打成 macOS / Windows / Linux 的安装包。这个系列的最后一期实战。
你写 Makepad 项目时有没有那种"三个月后打开看不懂自己代码"的经历?评论区聊聊。