用 Tauri 跨平台框架在鸿蒙 PC 上开发待办清单应用:从 Rust 命令集到持久化落盘的完整实践

欢迎加入开源鸿蒙PC社区: https://harmonypc.csdn.net/
欢迎在PC社区平台申请新建项目:https://atomgit.com/OpenHarmonyPCDeveloper

关键词:鸿蒙 PC,Tauri,跨平台框架,Rust,开源鸿蒙,ArkTS,cdylib,HAP,Tauri IPC,待办清单,数据持久化,编译期资产嵌入


一、前言

这是我在开源鸿蒙 PC 上用 Tauri 跨平台框架做应用系列文章的第三篇。第一篇跑通了"最小可运行工程",第二篇用 LumenNote 便签应用验证了数据建模与真机排坑。这一篇的主角是一个更贴近日常的工具——待办清单(Todolist):功能不复杂,增删改查、完成切换、清除已完成、数据持久化,但恰恰因为功能"朴素",它能把 Tauri 在鸿蒙 PC 上的工程化问题暴露得更干净:状态该放哪、命令集怎么设计、持久化落在哪、以及一个很容易踩中的大坑——改了前端代码,真机上界面却纹丝不动。

本文的全部代码都在鸿蒙 PC 真机上完成编译、打包与运行,文中三张截图均来自真机桌面,可以清楚看到鸿蒙 PC 的系统状态栏与 Dock 栏。希望这篇能帮你把 Tauri + 鸿蒙 PC 这条路线从"能跑"推进到"好用"。

二、业务设计:一个待办应用需要哪些命令

在动手写代码之前,先把待办清单的领域模型收敛成一条主线:一条待办(Todo)有唯一 id、文本内容、完成状态和创建时间;围绕它的操作无非六种——列出、新增、重命名、切换完成态、删除、清除已完成。

对应到 Tauri 的 Rust 侧,就是六个 #[tauri::command] 命令:

命令职责
list_todos返回全量待办列表
add_todo新增一条待办(含空文本与长度校验)
rename_todo双击编辑后更新文本
toggle_todo切换完成/未完成
delete_todo删除指定待办
clear_completed一键清除所有已完成项

这套命令集的设计原则是状态只存在于 Rust 侧:前端不持有任何权威数据,每次变更都通过 Tauri IPC 请求 Rust 修改状态,再重新拉取列表渲染。这样的好处是数据流单向、可预测,前端即使脱离原生环境(比如直接在浏览器里预览)也只影响展示层,不影响数据语义。

分层结构如下:

┌──────────────────────────────────────────────┐
│  Todolist (com.example.todolist)             │
│                                              │
│  Web 前端(待办清单 UI)                       │
│    ├─ 渲染列表 / 筛选 / 双击编辑 / 计数         │
│    └─ invoke("add_todo" / "toggle_todo" ...) │
│              ▲ Tauri IPC                     │
│  Rust 侧(src-tauri/src/lib.rs)              │
│    ├─ TodoState:Mutex 内存态 + next_id       │
│    ├─ 六个 #[tauri::command] 命令             │
│    └─ app_data_dir/todos.json 持久化          │
└──────────────────────────────────────────────┘

三、Rust 侧实现:状态、校验与持久化

3.1 数据模型与状态容器

一条待办用 serde 派生序列化能力,这是 Tauri IPC 自动完成 Rust 结构体与前端 JSON 双向转换的前提:

use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize, Clone, Debug)]
struct Todo {
    id: u64,
    text: String,
    done: bool,
    created_at: u64,
}

状态容器同时承载内存数据和持久化文件路径:

#[derive(Default)]
struct TodoState {
    items: Vec<Todo>,
    next_id: u64,
}

impl TodoState {
    fn load(path: &std::path::PathBuf) -> Self {
        if let Ok(data) = std::fs::read_to_string(path) {
            if let Ok(items) = serde_json::from_str::<Vec<Todo>>(&data) {
                let next_id = items.iter().map(|t| t.id).max().unwrap_or(0) + 1;
                return TodoState { items, next_id };
            }
        }
        TodoState::empty()
    }

    fn save(&self, path: &std::path::PathBuf) {
        if let Some(parent) = path.parent() {
            let _ = std::fs::create_dir_all(parent);
        }
        if let Ok(json) = serde_json::to_string_pretty(&self.items) {
            let _ = std::fs::write(path, json);
        }
    }
}

注意 load 的两个细节:一是 next_id 从现有最大 id 推导,避免重启后生成重复 id;二是文件不存在或解析失败时回退到空状态,而不是让应用崩溃——对桌面工具类应用来说,“数据文件损坏但应用可用"远好于"启动即闪退”。

3.2 命令与写后即存

六个命令共享一个辅助函数,把"修改内存态"和"落盘"绑成原子动作——只要命令执行成功,todos.json 就已经更新,不存在内存与磁盘不一致的中间态:

struct Store(std::sync::Mutex<(TodoState, std::path::PathBuf)>);

fn with_state<R>(store: &Store, f: impl FnOnce(&mut TodoState) -> R) -> Result<R, String> {
    let mut guard = store.0.lock().map_err(|_| "状态锁被占用".to_string())?;
    let result = f(&mut guard.0);
    guard.0.save(&guard.1);
    Ok(result)
}

#[tauri::command]
fn add_todo(store: tauri::State<Store>, text: String) -> Result<Todo, String> {
    let text = text.trim().to_string();
    if text.is_empty() {
        return Err("待办内容不能为空".to_string());
    }
    if text.chars().count() > 200 {
        return Err("待办内容过长(最多 200 字)".to_string());
    }
    with_state(&store, |state| {
        let todo = Todo {
            id: state.next_id,
            text,
            done: false,
            created_at: now_millis(),
        };
        state.next_id += 1;
        state.items.insert(0, todo.clone());
        todo
    })
}

这里有两个值得说明的取舍:新待办插入列表头部(insert(0, ...)),符合"最新事项最显眼"的清单习惯;输入校验放在 Rust 侧而不是只靠前端 maxlength,因为前端校验是可以被绕过的,后端兜底才是真校验。toggle_todo、delete_todo 等其余命令结构相同,就不再一一罗列,完整代码可以到 AtomGit 上的开源鸿蒙 PC 开发者组织(https://atomgit.com/OpenHarmonyPCDeveloper)参考同类工程。

最后在 run() 里完成装配:setup 钩子中加载磁盘状态并 app.manage(Store(...)),generate_handler 注册全部六个命令:

pub fn run() {
    tauri::Builder::default()
        .setup(|app| {
            let path = store_path(app.handle());
            let state = TodoState::load(&path);
            app.manage(Store(std::sync::Mutex::new((state, path))));
            Ok(())
        })
        .invoke_handler(tauri::generate_handler![
            app_info, list_todos, add_todo,
            rename_todo, toggle_todo, delete_todo, clear_completed
        ])
        .run(tauri::generate_context!())
        .expect("error while running Todolist");
}

store_path 使用 Tauri 的 app_data_dir(),在鸿蒙 PC 上它会解析到系统为该应用分配的沙箱目录,天然符合鸿蒙的应用数据隔离规范。

四、前端实现:零依赖的交互层

前端继续沿用本系列的约定:无打包器、无框架、纯 HTML/CSS/JS,通过 window.__TAURI__.core.invoke 调用 Rust。交互层面做了三件事:

筛选器。全部 / 进行中 / 已完成三个胶囊按钮切换 filter 状态,render() 只渲染可见子集:

function visibleTodos() {
  if (filter === "active") return todos.filter((t) => !t.done);
  if (filter === "done") return todos.filter((t) => t.done);
  return todos;
}

双击编辑。双击待办文本时把该项切换为输入框,Enter 提交、Esc 取消、失焦自动提交——三个出口收敛到同一个 commit(),避免状态机出现分叉:

const commit = async () => {
  const text = input.value.trim();
  editingId = null;
  if (text && text !== todo.text) {
    await renameTodo(todo.id, text);
  } else {
    render();
  }
};

浏览器降级。检测不到 window.__TAURI__ 时,所有命令落到一份 localStorage 实现上,并在状态栏明确标注"浏览器预览"。这让界面迭代可以先在浏览器里快速完成,再上真机验证 IPC——对鸿蒙 PC 这类真机构建链路较长的平台,这个降级层能省下大量等待时间。

五、排坑实录:改了前端,界面却"没变"

这是本文最想分享的一个坑,因为它没有任何报错,极具迷惑性。

现象:把前端从模板页改成待办清单 UI 后重新打包 HAP,安装到鸿蒙 PC 真机,启动后看到的仍然是旧模板界面——"PROJECT 001 / A NEW BEGINNING"那些内容原封不动。检查 web/index.html,文件明明已经是新代码;检查 HAP,也确实重新打包了。界面却像被"冻结"了。

排查后根因清楚了:Tauri 在编译期把 frontendDist 指向前端目录的全部资产嵌入 Rust 动态库(libtodolist_lib.so)。也就是说,前端不是运行时从 HAP 资源目录读取的,而是变成了 Rust cdylib 的一部分。只要最后一次 Rust release 构建发生在前端改动之前,HAP 里的 .so 就还是旧资产——hvigor 重新打包一万次,界面也不会变。

修复方式就是按正确的顺序重跑构建链:

npm run build:rust   # 先重编 Rust,让新前端资产嵌入 .so
npm run build:hap    # 再用新的 .so 打 HAP

重编之后真机界面立刻正常。这里沉淀成一条口诀:在 Tauri + 鸿蒙 PC 的工程里,web/ 目录的任何改动都必须以一次 Rust release 构建收尾,才能进入真机包。如果你也遇到"界面不动",先别怀疑前端代码,去看 .so 的编译时间是否早于前端改动时间。

六、真机效果

应用在鸿蒙 PC 真机上的完整运行过程如下。第一张图是启动初始态:空列表、空状态提示"这里空空如也,先添加一条待办吧。",右上角计数 0/0,底部状态栏显示数据落在 Rust · todos.json——注意画面顶部的系统状态栏与底部的 Dock 栏,这是鸿蒙 PC 的标志性外观:

鸿蒙PC真机:Todolist 启动初始态

第二张图是添加了两条待办(“鸿蒙PC"和"Rust + Tauri”)之后:列表实时渲染,计数更新为 0/2,每条数据都经由 Tauri IPC 写入 Rust 内存态并同步落盘:

鸿蒙PC真机:添加两条待办

第三张图是勾选"鸿蒙PC"为完成态:绿色实心对勾 + 删除线,计数变为 1/2,筛选器右侧自动出现"清除已完成"按钮——这个按钮的显隐由 Rust 侧返回的数据驱动,前端不做任何本地判断:

鸿蒙PC真机:完成切换与清除入口

杀掉应用重新启动,两条待办(含完成状态)原样恢复——todos.json 的持久化在真机上经受住了验证。

七、结语

回顾这个待办清单项目,Tauri 跨平台框架在鸿蒙 PC 上的开发体验可以概括为三句话:命令集即 API——六个 #[tauri::command] 就是应用的全部后端接口,边界清晰;状态收敛在 Rust——前端只做纯渲染,数据流单向可控;构建链路有秩序——Rust 构建 → HAP 打包 → 测试验证,顺序固定,尤其是"前端改动必须重编 Rust"这条纪律,值得写进每个鸿蒙 PC Tauri 项目的 README。

下一步,我计划在这个工程上补充键盘快捷键与待办排序,并尝试把 Rust 侧的状态管理抽成可复用的模块,让后续项目可以直接继承这套"命令 + 状态 + 持久化"的骨架。如果你也在用 Tauri 开发鸿蒙 PC 应用,欢迎在开源鸿蒙 PC 社区交流踩坑经验,也欢迎在 AtomGit 的鸿蒙 PC 开发者组织申请新建项目,一起把这条工具链养熟。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐