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

14 KiB
Raw Permalink Blame History

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. 完整数据流

当前链路可以按下面理解:

第一步:前端发起连接

前端点击按钮后:

await invoke("serial_connect", { port })

这一步只负责“启动”,不要指望它直接把流式数据返回给前端。

第二步Rust command 启动后台任务

serial_connect 做的事应该很少:

  1. 校验参数
  2. 打开串口
  3. 创建取消信号
  4. spawn 后台任务
  5. 把任务句柄保存到全局状态
  6. 立刻返回前端

也就是说command 只是“开机按钮”。

第三步:后台任务持续读串口

后台任务跑在 serial_core/serial.rs 里:

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 后,不要把原始协议直接丢给前端,先整理成业务结构:

let packet = chart_state.apply_frame(&frame);

这里输出的是 HudPacket,它的结构是前端图表直接能渲染的形状。

第五步Rust 发 event

app.emit("hud_stream", packet)?;

这一步就是把数据从 Rust 主动推给前端。

第六步:前端监听 event

前端在页面挂载时注册监听:

const unlisten = await listen<HudPacket>("hud_stream", (event) => {
  applyPacket(event.payload);
});

拿到 payload 后直接更新 signalPanels,图表就会刷新。

4. 为什么要保存后台任务句柄

因为连接不是瞬时动作,而是一个持续运行的任务。

如果你只做:

tauri::async_runtime::spawn(async move {
    ...
});

但不保存返回的 JoinHandle,那你后面其实不知道该停哪个任务。

所以当前项目里 SerialConnectionState 的职责是:

  • 保存当前连接的端口
  • 保存取消信号 CancellationToken
  • 保存后台任务句柄 JoinHandle

断开时:

  1. 从 state 里把当前 session 取出来
  2. cancel.cancel()
  3. 等任务退出
  4. 清空 session

这就是“连接生命周期管理”。

5. 为什么我这里用 CancellationToken而不是只 abort

abort() 可以硬停,但更像“直接掐掉线程”。

CancellationToken 更适合做长期任务,因为它允许你在循环里优雅退出:

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

#[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 后台任务

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(())
}

前端监听

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 字节分组:

[source_id, value1, value2, value3, source_id, value1, value2, value3, ...]

例如:

[0x41, 180, 120, 60, 0x42, 170, 80, 30]

这里会被解释成:

  • 0x41 -> A
  • 0x42 -> B

也就是说这一帧会同时更新两个 panel

  • A panel
  • B panel

如果后续几帧只有 B 和 C没有 A那 A 超时之后就会自动被移除。

source id 是怎么来的

当前 demo 里:

  • 如果字节是字母或数字,比如 AB3
  • 就直接把它当 source id

否则会变成:

  • CH01
  • CH0A
  • CHFF

这种形式。

超时移除在哪里控制

src-tauri/src/serial_core/model.rs 里有一个常量:

const PANEL_STALE_AFTER: Duration = Duration::from_millis(2400);

意思是某个 panel 2.4 秒没收到新数据,就认为它已经“消失”,会从当前 packet 里移除。

如果你的真实协议不是这个格式怎么办

那就只改 expand_frame_updates(frame) 这一层。

你真正要做的是把自己的协议,翻译成:

  • source id
  • 这个 source 对应的 3 个数值

例如如果你的协议里一帧只表示一个源:

fn expand_frame_updates(frame: &TestFrame) -> Vec<HudPanelUpdate> {
    vec![HudPanelUpdate {
        source_id: "A".to_string(),
        values: [12.3, 45.6, 78.9],
    }]
}

如果一帧里同时包含多个源:

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 逻辑替换成真实业务逻辑,直接把下面这段话发给我就行:

我已经把串口 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`,说明新的真实数据流

如果你到时候还没完全确定协议,也可以先用这个简化版提示词:

我已经知道 payload 里哪些字节对应哪几个通道了,请你帮我把 `src-tauri/src/serial_core/model.rs` 里的 demo 映射改成真实映射,并告诉我还缺哪些协议信息。

到时候我最需要你提供的最小信息

  • source id 怎么区分不同数据源
  • 每个 source 对应几条曲线
  • payload 各字段的字节位置
  • 数值类型和缩放系数
  • 多久没数据算“消失”

如果你后面要继续照这个模式扩展,最推荐的顺序是:

  1. 先定义前端真正需要的 payload 结构
  2. 再写 Rust 的 model/service 层
  3. 最后只让 command 负责启动和停止

这样结构通常都会比较干净。