# Tauri Event Demo 这份笔记对应当前工程里的“串口后台任务 -> Tauri event -> 前端折线图”这条链路。 目标是把一类很常见的需求拆清楚: - 前端点一次按钮,调用 Rust command - Rust 启动一个长期运行的后台任务 - 后台任务持续读取数据 - 每拿到一帧数据,就通过 Tauri event 推给前端 - 前端监听 event,直接刷新图表 ## 0. 2026-03-25 需求更新(WebGL 点阵改用真实数据) 这次改动的目标是: - WebGL dot matrix 不再使用虚拟 demo 数据 - 直接消费串口解析后的真实通道值 - 默认矩阵大小改为 `row=12, col=7` ### 是否需要新建 event? 不需要。继续使用现有 `hud_stream` 即可,只扩展 payload 字段: - 原来:`{ ts, panels, summary }` - 现在:`{ ts, panels, summary, pressureMatrix }` 这样做的好处是: - 前后端事件通道不变,兼容原有监听逻辑 - 折线图和点阵图保持同一时间基准 - 只需在 `HudPacket` 上增量扩展,不引入额外生命周期管理 ### 本次代码落地点 - 后端 `HudPacket` 新增 `pressure_matrix`(序列化为 `pressureMatrix`): - `src-tauri/src/serial_core/model.rs` - 串口读循环在拿到解析值后写入 matrix 状态: - `src-tauri/src/serial_core/serial.rs` - 前端类型 `HudPacket` 新增 `pressureMatrix: number[] | null`: - `src/lib/types/hud.ts` - 页面把 `pressureMatrix` 透传到 `CenterStage -> PressureMatrixViewer`: - `src/routes/+page.svelte` - `PressureMatrixViewer` 去除 demo 生成逻辑;无真实数据时显示全 0: - `src/lib/components/PressureMatrixViewer.svelte` - 默认矩阵改为 `12 x 7`(含 reset 默认值): - `src/routes/+page.svelte` - `src/lib/components/CenterStage.svelte` - `src/lib/components/ConfigPanel.svelte` ## 1. 什么时候用 command,什么时候用 event 先记一个最实用的判断: - `command` 适合“请求一次,返回一次” - `event` 适合“后台持续推送” 在这个项目里: - `serial_enum` 是 command,因为它是“一次查询串口列表” - `serial_connect` 是 command,因为它是“一次启动连接” - `serial_disconnect` 是 command,因为它是“一次停止连接” - `hud_stream` 是 event,因为图表数据会持续不断地到来 如果你用 command 去不停轮询图表数据,也能做,但会有这些问题: - 前后端耦合更重 - 轮询频率不好拿捏 - 串口数据到了也不能立刻推到前端 - 后面做 TCP、日志流、状态流时会越来越别扭 所以像串口采样、TCP telemetry、日志输出、设备状态广播,这类都更适合 event。 ## 2. 当前工程里各文件的职责 ### Rust - `src-tauri/src/lib.rs` - 注册 command - 注册全局状态 `SerialConnectionState` - `src-tauri/src/commands/serial.rs` - 给前端暴露 command - 管理串口后台任务的生命周期 - 这里尽量保持“薄” - `src-tauri/src/serial_core/serial.rs` - 真正的串口读循环 - 解码 frame - 发出 `hud_stream` - `src-tauri/src/serial_core/model.rs` - 定义发给前端的结构 - 把原始 frame 转成前端折线图直接能吃的 `HudPacket` - `src-tauri/src/serial_core/codecs/test.rs` - 协议解码 ### Frontend - `src/routes/+page.svelte` - 调用 `serial_connect` / `serial_disconnect` - 监听 `hud_stream` - 拿到 `HudPacket` 后刷新页面状态 - `src/lib/types/hud.ts` - 前端使用的 HUD 类型定义 ## 3. 完整数据流 当前链路可以按下面理解: ### 第一步:前端发起连接 前端点击按钮后: ```ts await invoke("serial_connect", { port }) ``` 这一步只负责“启动”,不要指望它直接把流式数据返回给前端。 ### 第二步:Rust command 启动后台任务 `serial_connect` 做的事应该很少: 1. 校验参数 2. 打开串口 3. 创建取消信号 4. `spawn` 后台任务 5. 把任务句柄保存到全局状态 6. 立刻返回前端 也就是说,command 只是“开机按钮”。 ### 第三步:后台任务持续读串口 后台任务跑在 `serial_core/serial.rs` 里: ```rust loop { tokio::select! { _ = cancel.cancelled() => break, read_result = port.read(&mut buffer) => { let n = read_result?; let frames = codec.decode(&buffer[..n])?; ... } } } ``` 这里有两个关键点: - 必须只解码 `&buffer[..n]` - 长循环不要放在 command 里 `await` 到结束 第一点是为了避免把缓冲区后面没读到的 `0` 一起喂给解码器。 第二点是为了避免前端永远等不到 command 返回。 ### 第四步:frame 转成前端友好的 packet 后台拿到 frame 后,不要把原始协议直接丢给前端,先整理成业务结构: ```rust let packet = chart_state.apply_frame(&frame); ``` 这里输出的是 `HudPacket`,它的结构是前端图表直接能渲染的形状。 ### 第五步:Rust 发 event ```rust app.emit("hud_stream", packet)?; ``` 这一步就是把数据从 Rust 主动推给前端。 ### 第六步:前端监听 event 前端在页面挂载时注册监听: ```ts const unlisten = await listen("hud_stream", (event) => { applyPacket(event.payload); }); ``` 拿到 payload 后直接更新 `signalPanels`,图表就会刷新。 ## 4. 为什么要保存后台任务句柄 因为连接不是瞬时动作,而是一个持续运行的任务。 如果你只做: ```rust tauri::async_runtime::spawn(async move { ... }); ``` 但不保存返回的 `JoinHandle`,那你后面其实不知道该停哪个任务。 所以当前项目里 `SerialConnectionState` 的职责是: - 保存当前连接的端口 - 保存取消信号 `CancellationToken` - 保存后台任务句柄 `JoinHandle` 断开时: 1. 从 state 里把当前 session 取出来 2. `cancel.cancel()` 3. 等任务退出 4. 清空 session 这就是“连接生命周期管理”。 ## 5. 为什么我这里用 CancellationToken,而不是只 abort `abort()` 可以硬停,但更像“直接掐掉线程”。 `CancellationToken` 更适合做长期任务,因为它允许你在循环里优雅退出: ```rust tokio::select! { _ = cancel.cancelled() => break, ... } ``` 这样后面如果你需要: - 退出前写日志 - 退出前关闭资源 - 退出前发送状态事件 都更自然。 当前代码里依然保留了 `JoinHandle`,因为它可以让你等待任务真正结束。 ## 6. 这个 demo 里 model 层在做什么 `src-tauri/src/serial_core/model.rs` 里现在做了两件事: 1. 定义前端消费的数据结构 2. 提供 `HudChartState` `HudChartState` 的作用是把“一帧 frame”逐步积累成“可滚动折线图数据”。 你可以把它理解成一个很轻量的 view-model: - 输入:`TestFrame` - 内部:维护每条折线的点数组 - 输出:`HudPacket` 好处是前端不用知道: - CRC - 包头包尾 - payload 字节意义 - 缓冲和滑动窗口怎么维护 前端只知道:我收到了一份新的图表数据。 ## 7. 前端这里做了什么 前端现在分成两层: ### 控制层 通过 `invoke` 调 Rust command: - `serial_enum` - `serial_connect` - `serial_disconnect` ### 数据层 通过 `listen("hud_stream")` 收 Rust 推送的数据。 也就是说: - 控制动作走 command - 连续数据走 event 这就是后面最好复用的模式。 ## 8. 为什么页面里还保留了 mock feed 因为浏览器直接预览 UI 的时候,没有 Tauri runtime,也没有串口。 所以当前页面里做了两条分支: - 在 Tauri 环境里:监听真实 `hud_stream` - 在普通浏览器里:启动本地 mock feed 这样你开发样式时不用每次都连设备,效率会高很多。 ## 9. 后面照着扩展时怎么套 如果你接下来做 TCP、日志流、设备状态流,基本可以照这个模板: ### TCP telemetry - `tcp_connect` command - `tcp_disconnect` command - `tcp_stream` event ### 设备状态 - `device_get_status` command - `device_status` event ### 日志输出 - `log_stream` event 只要记住一句话: “一次性动作走 command,持续推送走 event。” ## 10. 一个最小可复用模板 ### Rust command ```rust #[tauri::command] pub async fn start_task(app: AppHandle, state: State<'_, TaskState>) -> Result<(), TaskError> { let cancel = CancellationToken::new(); let task_cancel = cancel.clone(); let task_app = app.clone(); let task = tauri::async_runtime::spawn(async move { let _ = run_task(task_app, task_cancel).await; }); *state.task.lock().unwrap() = Some(TaskSession { cancel, task }); Ok(()) } ``` ### Rust 后台任务 ```rust pub async fn run_task(app: AppHandle, cancel: CancellationToken) -> anyhow::Result<()> { loop { tokio::select! { _ = cancel.cancelled() => break, data = next_data() => { let payload = build_payload(data?); app.emit("some_stream", payload)?; } } } Ok(()) } ``` ### 前端监听 ```ts const unlisten = await listen("some_stream", (event) => { applyPayload(event.payload); }); ``` ## 11. 这套方式最容易踩的坑 ### 1. 在同步 command 里调用 async-only API 像 `open_native_async()` 这种需要 runtime 的 API,command 本身就应该写成 `async fn`。 ### 2. 把长期循环直接写在 command 里 这样前端 `invoke` 会一直挂着,按钮像卡死一样。 ### 3. 解码时传整个 buffer,而不是 `&buffer[..n]` 这会把未读取区域的垃圾值一并喂给解码器。 ### 4. 没有保存任务句柄 这样断开时你并不知道要停哪个任务。 ### 5. 原始协议直接发前端 短期快,长期维护会很痛苦。最好让 Rust 先转换成业务数据。 ## 12. 你后面可以继续优化的方向 - 增加 `status_stream`,当串口异常退出时主动通知前端 - 把 `SerialConnectionState` 扩成 `HashMap`,支持多串口 - 把 `HudChartState` 抽成更明确的 telemetry service - 给不同协议定义不同 packet,而不是全部复用一个事件名 ## 13. 现在这版如何支持动态 panel 这次我已经把 demo 从“固定四个 panel”改成了“按数据源 id 动态创建 panel”。 当前后端的行为是: - 串口保持连接 - 收到某个 source id 的数据时,如果这个 id 还没有 panel,就新建 - 后面这个 id 持续有数据,就持续往它自己的折线里追加点 - 如果某个 id 超过一段时间没有新数据,就自动移除 - 移除后前端会走离场动画 ### 当前 demo 约定的数据格式 现在 `model.rs` 里的 demo 约定是一帧 payload 可以写成多个 4 字节分组: ```text [source_id, value1, value2, value3, source_id, value1, value2, value3, ...] ``` 例如: ```text [0x41, 180, 120, 60, 0x42, 170, 80, 30] ``` 这里会被解释成: - `0x41` -> `A` - `0x42` -> `B` 也就是说这一帧会同时更新两个 panel: - A panel - B panel 如果后续几帧只有 B 和 C,没有 A,那 A 超时之后就会自动被移除。 ### source id 是怎么来的 当前 demo 里: - 如果字节是字母或数字,比如 `A`、`B`、`3` - 就直接把它当 source id 否则会变成: - `CH01` - `CH0A` - `CHFF` 这种形式。 ### 超时移除在哪里控制 在 `src-tauri/src/serial_core/model.rs` 里有一个常量: ```rust const PANEL_STALE_AFTER: Duration = Duration::from_millis(2400); ``` 意思是某个 panel 2.4 秒没收到新数据,就认为它已经“消失”,会从当前 packet 里移除。 ### 如果你的真实协议不是这个格式怎么办 那就只改 `expand_frame_updates(frame)` 这一层。 你真正要做的是把自己的协议,翻译成: - source id - 这个 source 对应的 3 个数值 例如如果你的协议里一帧只表示一个源: ```rust fn expand_frame_updates(frame: &TestFrame) -> Vec { vec![HudPanelUpdate { source_id: "A".to_string(), values: [12.3, 45.6, 78.9], }] } ``` 如果一帧里同时包含多个源: ```rust fn expand_frame_updates(frame: &TestFrame) -> Vec { vec![ HudPanelUpdate { source_id: "A".to_string(), values: [12.3, 45.6, 78.9], }, HudPanelUpdate { source_id: "C".to_string(), values: [33.1, 29.8, 81.4], }, ] } ``` 后面的动态创建、更新、超时移除,`HudChartState` 都会自动帮你处理掉。 ## 14. TODO 提示词 如果你后面把 `handle` / 协议解析逻辑写完了,想让我把 demo 逻辑替换成真实业务逻辑,直接把下面这段话发给我就行: ```md 我已经把串口 frame 的业务含义梳理清楚了,请你把当前 tauri-demo 里的 demo 动态 panel 逻辑改成真实协议版本。 已知信息: - frame 类型:`TestFrame` - 数据来源文件:`src-tauri/src/serial_core/...` - 当前 demo 映射入口:`src-tauri/src/serial_core/model.rs` 里的 `expand_frame_updates` - 如果需要,也可以调整 `handler.on_frame(...)` 的职责 真实协议说明: - source_id 怎么取: - 一帧里有几路数据: - 每一路数据对应哪些字段: - 数值是 u8 / u16 / i16 / float: - 字节序是大端还是小端: - 缩放系数是多少: - 哪些情况下表示“该路数据消失”: - panel 标题 / 图例文案要如何显示: 请你帮我做这些事: 1. 去掉当前 demo 的 4 字节分组映射逻辑 2. 改成按真实协议生成动态 panel 3. 保留“有数据就创建,超时没数据就移除”的行为 4. 如有必要,重构 `model.rs` / `handler` / `serial.rs` 的职责边界 5. 更新 `tauri-event.md`,说明新的真实数据流 ``` 如果你到时候还没完全确定协议,也可以先用这个简化版提示词: ```md 我已经知道 payload 里哪些字节对应哪几个通道了,请你帮我把 `src-tauri/src/serial_core/model.rs` 里的 demo 映射改成真实映射,并告诉我还缺哪些协议信息。 ``` ### 到时候我最需要你提供的最小信息 - source id 怎么区分不同数据源 - 每个 source 对应几条曲线 - payload 各字段的字节位置 - 数值类型和缩放系数 - 多久没数据算“消失” --- 如果你后面要继续照这个模式扩展,最推荐的顺序是: 1. 先定义前端真正需要的 payload 结构 2. 再写 Rust 的 model/service 层 3. 最后只让 command 负责启动和停止 这样结构通常都会比较干净。