写 Makepad Demo 不难,难的是把它写成项目

前言

我有个习惯:每做完一个小项目,过两周再回头看代码。

上一期 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_eventsapp.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=debugRUST_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 项目时有没有那种"三个月后打开看不懂自己代码"的经历?评论区聊聊。

相关推荐
用户059540174461 小时前
localStorage清除策略踩坑实录:一个过期的token让我排查了3小时
前端·css
Nanachi1 小时前
跨框架的前端源码定位,再加上点LLM
前端
人无远虑必有近忧!1 小时前
fetch请求图片报跨域
前端·javascript
谢院柯2 小时前
解决修改 node_modules 依赖库源码后重复安装问题的几种方案
前端
疯狂打码的少年2 小时前
【程序语言与编译】NFA转DFA(子集构造法)
前端·笔记
半只小闲鱼2 小时前
合并多个excel文件到一个文件中
前端·python·数据分析
咸甜适中2 小时前
rust语言学习笔记Trait(十七)Send、Sync(线程间数据所有权)
笔记·学习·rust
fobwebs2 小时前
Chrome谷歌浏览器多开教程,如何在电脑上同时登录多个GMAIL账号
前端·chrome·多开·同时登录多个gmail
前端 贾公子2 小时前
小程序蓝牙打印探索与实践 (最终章)
前端·微信小程序·小程序