关于 makepad-2.0-app-structure
面向 Makepad 2.0 开发者的技能指南,介绍 Rust 与 Splash 的应用结构、通信和事件处理,并涵盖运行、调试及音频和系统集成模式。
主要功能
- 01Rust 与 Splash 应用结构
- 02Rust 和 Splash 双向通信
- 03事件路由与控件访问
- 04运行和热重载方式
- 05音频处理与系统集成
适用场景
- 01搭建 Makepad 2.0 应用骨架
- 02在 Rust 与 Splash 间同步状态
- 03通过 Makepad Studio 调试 Splash
- 04实现音频输入输出或系统快捷键
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ zhanghandong/ makepad-skills --skill makepad-2.0-app-structure安装后在 Claude Code 中输入/makepad-2.0-app-structure确认已加载。
附带文件references/app-boilerplate.mdreferences/rust-splash-integration.md
详细介绍
Makepad 2.0 应用结构技能
版本: makepad-widgets(dev 分支)| 最后更新: 2026-03-03
概述
Makepad 2.0 应用将 Rust 代码与 Splash 脚本结合使用。Rust 端负责应用生命周期、事件路由和业务逻辑。Splash 端定义 UI 结构、模板和内联交互。
文档
详细文档请参阅本地文件:
./references/app-boilerplate.md- 完整的可运行应用模板,包含 Cargo.toml./references/rust-splash-integration.md- Rust ↔ Splash 通信模式
重要:检查文档完整性
回答问题前,Claude 必须:
- 阅读上方列出的相关参考文件
- 如果读取文件失败,请根据 SKILL.md 中的模式和内置知识作答
最简应用模板
Cargo.toml
[package]
name = "my-app"
version = "0.1.0"
edition = "2024"
[dependencies]
makepad-widgets = { path = "../path/to/makepad/widgets" }
src/app.rs(或 src/main.rs)
use makepad_widgets::*;
app_main!(App);
script_mod! {
use mod.prelude.widgets.*
let state = {
counter: 0
}
mod.state = state
startup() do #(App::script_component(vm)){
ui: Root{
on_startup: ||{
ui.main_view.render()
}
main_window := Window{
window.inner_size: vec2(420, 300)
body +: {
main_view := View{
width: Fill height: Fill
flow: Down spacing: 12
align: Center
on_render: ||{
Label{
text: "Count: " + state.counter
draw_text.text_style.font_size: 24
}
}
}
increment_button := Button{
text: "Increment"
}
}
}
}
}
}
impl App {
fn run(vm: &mut ScriptVm) -> Self {
crate::makepad_widgets::script_mod(vm);
App::from_script_mod(vm, self::script_mod)
}
}
#[derive(Script, ScriptHook)]
pub struct App {
#[live]
ui: WidgetRef,
}
impl MatchEvent for App {
fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions) {
if self.ui.button(cx, ids!(increment_button)).clicked(actions) {
script_eval!(cx, {
mod.state.counter += 1
ui.main_view.render()
});
}
}
}
impl AppMain for App {
fn handle_event(&mut self, cx: &mut Cx, event: &Event) {
self.match_event(cx, event);
self.ui.handle_event(cx, event, &mut Scope::empty());
}
}
关键组件说明
1. app_main!(App)
将 App 注册为应用入口点。必须在模块级调用。
2. script_mod! { ... }
定义启动时运行的 Splash 脚本。包含:
use mod.prelude.widgets.*- 导入控件定义- 状态定义(
let state = {...}) - 函数和模板
startup() do #(App::script_component(vm)){...}- 构建 UI
3. App::run(vm) - 初始化顺序
关键:注册顺序很重要!
fn run(vm: &mut ScriptVm) -> Self {
// 1. Register theme (optional, for light/dark theme)
crate::makepad_widgets::theme_mod(vm);
script_eval!(vm, { mod.theme = mod.themes.light });
// 2. Register base widgets
crate::makepad_widgets::widgets_mod(vm);
// 3. Register custom widget modules (if any)
// crate::my_widgets::script_mod(vm);
// 4. Build app from script module
App::from_script_mod(vm, self::script_mod)
}
简化版(不选择主题):
fn run(vm: &mut ScriptVm) -> Self {
crate::makepad_widgets::script_mod(vm); // Registers both theme + widgets
App::from_script_mod(vm, self::script_mod)
}
4. App 结构体上的 #[derive(Script, ScriptHook)]
#[derive(Script, ScriptHook)]
pub struct App {
#[live]
ui: WidgetRef, // The widget tree root
}
Script- 启用 Splash 集成(替代旧版Live)ScriptHook- 启用生命周期钩子(替代旧版LiveHook)#[live]- 可通过 DSL 设置的字段
5. 用于处理操作的 MatchEvent
impl MatchEvent for App {
fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions) {
// Check button clicks
if self.ui.button(cx, ids!(my_button)).clicked(actions) {
// Handle click
}
// Check text input changes
if let Some(text) = self.ui.text_input(cx, ids!(my_input)).changed(actions) {
log!("Input: {}", text);
}
}
}
6. 用于事件分发的 AppMain
impl AppMain for App {
fn handle_event(&mut self, cx: &mut Cx, event: &Event) {
self.match_event(cx, event); // MUST call for MatchEvent to work
self.ui.handle_event(cx, event, &mut Scope::empty());
}
}
Rust → Splash 通信
script_eval! - 更新状态并触发渲染
script_eval!(cx, {
mod.state.counter += 1
ui.main_view.render()
});
script_apply_eval! - 修补控件属性
script_apply_eval!(cx, self.ui, {
title.text: "New Title"
subtitle.draw_text.color: #f00
});
Splash → Rust 通信
script_mod! 中的内联事件处理器
Button{
text: "Click"
on_click: || {
// Splash code runs here
state.count += 1
ui.display.render()
}
}
TextInput{
on_return: || {
let text = ui.my_input.text()
add_item(text)
ui.my_input.set_text("")
}
}
// Render callback
on_render: || {
for i, item in items {
ItemTemplate{label.text: item.name}
}
}
从 Rust 访问控件
// Buttons
self.ui.button(cx, ids!(button_name)).clicked(actions)
// Labels
self.ui.label(cx, ids!(label_name)).set_text(cx, "text")
// Text inputs
self.ui.text_input(cx, ids!(input_name)).text()
// Nested access
self.ui.label(cx, ids!(container.inner.title))
运行应用
# Development
cargo run -p my-app
# Development with hot reload (Splash changes apply without recompilation)
cargo run -p my-app -- --hot
# Release
cargo run -p my-app --release
# With cargo-makepad for mobile/web
cargo makepad run -p my-app
命令行标志
| 标志 | 说明 |
|---|---|
--hot | 启用热重载:监视 script_mod! 源文件,并在保存时自动刷新 UI。仅影响 Splash DSL;Rust 代码变更仍需重新编译。 |
--stdin-loop | Studio 模式:通过 stdin/websocket 与 Makepad Studio 通信。供 Studio 内部使用,不适用于手动操作。 |
--linux-backend=<x11|wayland> | (仅限 Linux)选择窗口后端。 |
Makepad Studio:调试 Splash
在 Studio 中运行 Splash 脚本
Studio 会在项目根目录查找 makepad.splash 文件。它可以通过 运行列表 面板直接运行 Splash 脚本(内部使用 start_script_run),无需执行 cargo run。
调试输出
在 Splash 脚本中使用 std.println() / std.print(),输出会同时显示在 Studio 的 日志视图 面板和终端 stdout 中:
// makepad.splash
std.println("debug: value = " + my_var);
错误显示
脚本运行失败时,Studio 的日志视图会显示文件路径和错误详情:
script failed while evaluating makepad.splash: <error details>
Hub API(由 Studio 注入的 hub 模块)
在 Studio 下运行时,Splash 脚本会获得一个 hub 模块:
| API | 说明 |
|---|---|
hub.run(env, cmd, args) | 从 Splash 启动子进程 |
hub.set_run_items(items) | 在 Studio 的运行列表中注册可运行项目 |
hub.studio_ip | Studio 的 WebSocket 地址 |
当前限制
- 不支持断点调试 — Splash VM 不支持断点或单步执行
- 没有 AST 转储标志 — 检查解析结果需要在 Rust 源代码(
script/)中添加日志 - 基于打印的调试 —
std.println()是主要调试工具
音频 API(仅限 Rust,未向 Splash 开放)
Makepad 2.0 通过 CxMediaApi trait 提供原生音频 I/O。音频回调在独立的实时线程上运行。
音频输出
use makepad_widgets::*;
impl AppMain for App {
fn handle_event(&mut self, cx: &mut Cx, event: &Event) {
match event {
Event::Startup => {
cx.audio_output(0, move |_info, buffer: &mut AudioBuffer| {
for frame in 0..buffer.frame_count() {
let sample = /* generate sample */;
buffer.set(0, frame, sample);
buffer.set(1, frame, sample);
}
});
}
_ => {}
}
self.match_event(cx, event);
self.ui.handle_event(cx, event, &mut Scope::empty());
}
}
音频输入
cx.audio_input(0, move |info: AudioInfo, buffer: &AudioBuffer| {
let sample_rate = info.sample_rate;
for frame in 0..buffer.frame_count() {
let sample = buffer.get(0, frame);
}
});
主要音频类型(platform/src/audio.rs)
| 类型 | 描述 |
|---|---|
AudioBuffer | 多声道采样容器(f32) |
AudioInfo | 设备 ID、采样率、计时信息 |
AudioDeviceDesc | 设备元数据(名称、类型、声道数) |
AudioStreamSender/Receiver | 采用自适应缓冲的解耦音频路由 |
跨线程数据流模式(音频 → UI)
音频回调在实时线程上运行。使用原子变量将数据传递到 UI 线程:
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
let amplitude = Arc::new(AtomicU64::new(0));
let amp_clone = amplitude.clone();
cx.audio_output(0, move |_info, buffer: &mut AudioBuffer| {
let mut sum = 0.0f32;
for frame in 0..buffer.frame_count() {
let s = buffer.get(0, frame);
sum += s * s;
}
let rms = (sum / buffer.frame_count() as f32).sqrt();
amp_clone.store(rms.to_bits() as u64, Ordering::Relaxed);
});
// UI thread: read on NextFrame
let rms = f32::from_bits(amplitude.load(Ordering::Relaxed) as u32);
重要: Splash 脚本无法访问音频。所有音频处理都必须在 Rust 中完成。
平台支持
| 平台 | 音频输出 | 音频输入 | 实现 |
|---|---|---|---|
| macOS/iOS | AudioUnit | AVCapture | apple_media.rs |
| Windows | WASAPI | WASAPI | windows_media.rs |
| Linux | ALSA/PulseAudio | ALSA | linux_media.rs |
| Web/WASM | WebAudio | MediaDevices | web_media.rs |
| Android | AAudio | NDK Camera2 | android_media.rs |
参考:TeamTalk 示例
examples/teamtalk/ 演示了完整的 P2P 音频聊天,包括 cx.audio_input()/cx.audio_output()、重采样和 UDP 流传输。
最佳实践
- 注册顺序——主题 → 基础组件 → 自定义组件 → 应用模块
- 使用
script_eval!将 Rust 操作桥接到 Splash 状态更新 - 在 handle_event 中调用
self.match_event(cx, event)(MatchEvent 必需) - 动态内容使用
on_render,调用.render()触发更新 - 业务逻辑放在 Rust 中,UI 声明放在 Splash 中
- 使用
mod.state管理 Rust 和 Splash 均可访问的应用级状态 - 仅在 Rust 中进行音频处理——使用原子变量实现音频 → UI 数据流
macOS 系统集成模式(学习于 2026-03-31)
NSStatusBar(菜单栏图标)
Makepad 没有内置的 NSStatusBar API。使用 makepad_objc_sys(与 Makepad 使用相同的 ObjC 运行时)进行系统托盘集成:
use makepad_objc_sys::{msg_send, class, sel, sel_impl};
use makepad_objc_sys::runtime::{Object, Sel, YES, NO};
// Create status bar item
let status_bar: ObjcId = msg_send![class!(NSStatusBar), systemStatusBar];
let item: ObjcId = msg_send![status_bar, statusItemWithLength: -1.0f64];
let button: ObjcId = msg_send![item, button];
let () = msg_send![button, setTitle: str_to_nsstring("MIC")];
关键注意事项:
- 不要使用
objc2crate——它会创建独立的 NSApplication 实例,与 Makepad 冲突 show_in_dock(false)会隐藏 NSStatusItem——改为在 Info.plist 中设置LSUIElement=true- 菜单目标对象会被自动释放——使用全局单例目标和
sender.tag()分发操作 - 不要调用
CGEventTapEnable(tap, true)——创建 CGEvent tap 时默认已启用;调用 Enable 实际上会禁用它们
音频输入——必须提供设备 ID
cx.use_audio_inputs(&[]) 表示停止录音,而不是“使用默认设备”。必须从 AudioDevicesEvent 获取设备 ID:
impl MatchEvent for App {
fn handle_audio_devices(&mut self, _cx: &mut Cx, e: &AudioDevicesEvent) {
// Save default device ID
self.default_input = e.default_input().into_iter().next();
}
}
// When starting recording:
if let Some(device_id) = self.default_input {
cx.use_audio_inputs(&[device_id]); // Start capture with specific device
}
// When stopping:
cx.use_audio_inputs(&[]); // Empty = stop all audio input
CGEvent Tap——全局快捷键
用于全局键盘快捷键(例如,按下即可说话):
// CGEventGetFlags returns 0x80000 for Option key (not 0x20 as some docs suggest)
let key_mask: u64 = 0x080000; // kCGEventFlagMaskAlternate
// Use CFRunLoopCommonModes (not DefaultMode)
CFRunLoopAddSource(run_loop, source, kCFRunLoopCommonModes);
// Do NOT call CGEventTapEnable — it's already enabled
// CGEventTapEnable(tap, true); // WRONG — this actually breaks it
// CFRunLoopRun blocks forever — put in dedicated thread
CFRunLoopRun();
跨线程通信模式
macos-sys (CFRunLoop thread) → crossbeam channel → Makepad timer poll (main thread)
Audio callback (RT thread) → Arc<AtomicU64> → NextFrame handler (main thread)
HTTP response → MatchEvent handler → UI update via script_eval!
用于接收 ObjC 回调的定时器轮询模式:
// In handle_timer (10ms interval):
let events: Vec<u64> = self.menu_rx.as_ref()
.map(|rx| rx.try_iter().collect())
.unwrap_or_default();
for action_id in events {
self.handle_menu_action(cx, action_id);
}