Files
JE-Skin/tauri-event.md
lennlouisgeek eec9927ae6 first commit
2026-03-30 02:59:56 +08:00

555 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<HudPacket>("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<Payload>("some_stream", (event) => {
applyPayload(event.payload);
});
```
## 11. 这套方式最容易踩的坑
### 1. 在同步 command 里调用 async-only API
`open_native_async()` 这种需要 runtime 的 APIcommand 本身就应该写成 `async fn`
### 2. 把长期循环直接写在 command 里
这样前端 `invoke` 会一直挂着,按钮像卡死一样。
### 3. 解码时传整个 buffer而不是 `&buffer[..n]`
这会把未读取区域的垃圾值一并喂给解码器。
### 4. 没有保存任务句柄
这样断开时你并不知道要停哪个任务。
### 5. 原始协议直接发前端
短期快,长期维护会很痛苦。最好让 Rust 先转换成业务数据。
## 12. 你后面可以继续优化的方向
- 增加 `status_stream`,当串口异常退出时主动通知前端
-`SerialConnectionState` 扩成 `HashMap<String, SerialSession>`,支持多串口
-`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<HudPanelUpdate> {
vec![HudPanelUpdate {
source_id: "A".to_string(),
values: [12.3, 45.6, 78.9],
}]
}
```
如果一帧里同时包含多个源:
```rust
fn expand_frame_updates(frame: &TestFrame) -> Vec<HudPanelUpdate> {
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 负责启动和停止
这样结构通常都会比较干净。