用 Rust 与 Tauri 构建可验证的习惯追踪桌面应用

简介: 本文介绍如何用Rust+Tauri构建本地习惯追踪应用,聚焦工程痛点:数据持久化、幂等操作与状态一致性。通过SQLite约束保障数据正确性,Rust后端封装事务与业务规则,前端专注交互,实现可维护、可测试的三层架构。(239字)

习惯追踪应用看似简单,真正实现时通常会遇到三个工程问题:数据不能只停留在内存中,重复点击不能造成重复记录,前端显示状态还必须与本地数据库保持一致。

如果直接把所有逻辑写在前端,短期内可以完成页面,但随着功能增加,日期判断、连续天数、撤销操作和数据迁移很容易变成难以测试的条件分支。更稳妥的做法是把数据规则放在 Rust 后端,把界面交互留给前端,把 SQLite 作为本地持久化边界。

本文构建一个最小的习惯树应用:用户创建习惯,每天完成或取消一次,应用根据完成记录计算连续天数,并在重启后恢复数据。示例以 Tauri 2 的项目结构为参考,具体命令和配置应以所使用的模板版本为准。

总体设计

应用分成三层:

  • 前端负责表单、列表、按钮和加载状态。
  • Rust 命令层负责参数校验、事务和返回结构。
  • SQLite 负责保存习惯及其每日完成记录。

核心数据关系可以简化为:

habits
  id           INTEGER PRIMARY KEY
  name         TEXT NOT NULL
  created_at   TEXT NOT NULL
  archived     INTEGER NOT NULL DEFAULT 0

habit_checks
  habit_id     INTEGER NOT NULL
  check_date   TEXT NOT NULL
  PRIMARY KEY (habit_id, check_date)

habit_checks 使用 (habit_id, check_date) 作为联合主键,同一个习惯在同一天最多只能有一条完成记录。这个约束比在业务代码中先查询再插入更可靠,因为并发请求或重复点击都不会破坏数据一致性。

连续天数也不建议直接存储。它是完成记录的派生结果,实时计算可以避免用户取消某天后还要同步多个计数字段。数据规模较小时,这种计算成本通常足够低;如果记录量很大,再考虑增加缓存或汇总表。

创建项目

先准备 Rust 工具链、Node.js 和 Tauri 所需的系统依赖,然后创建项目:

npm create tauri-app@latest habit-tree
cd habit-tree
npm install
npm run tauri dev

创建时可以选择熟悉的前端模板。本文只依赖前端能够调用 Tauri 命令这一事实,不绑定具体的 UI 框架。

在 Rust 端加入 SQLite 依赖。下面的配置使用 rusqlite,并启用 SQLite 内置能力:

[dependencies]
tauri = { version = "2" }
serde = { version = "1", features = ["derive"] }
rсqlite = { package = "rusqlite", version = "0.32", features = ["bundled"] }

上面的依赖名中应使用 ASCII 拼写 rusqlite。正确配置如下:

[dependencies]
tauri = { version = "2" }
serde = { version = "1", features = ["derive"] }
rusqlite = { version = "0.32", features = ["bundled"] }

bundled 会让项目使用依赖提供的 SQLite 源码构建,便于减少本机 SQLite 版本差异;它可能增加编译时间和构建产物体积,是否启用应结合发布环境决定。

初始化本地数据库

数据库文件应放到应用数据目录,而不是当前工作目录。这样开发运行、安装运行和系统升级时更容易保持路径稳定。

use rusqlite::{
   Connection, Result};
use std::path::Path;

pub fn open_database(path: &Path) -> Result<Connection> {
   
    let connection = Connection::open(path)?;
    connection.execute_batch(
        "PRAGMA foreign_keys = ON;
         CREATE TABLE IF NOT EXISTS habits (
             id INTEGER PRIMARY KEY AUTOINCREMENT,
             name TEXT NOT NULL CHECK(length(trim(name)) > 0),
             created_at TEXT NOT NULL,
             archived INTEGER NOT NULL DEFAULT 0
         );
         CREATE TABLE IF NOT EXISTS habit_checks (
             habit_id INTEGER NOT NULL,
             check_date TEXT NOT NULL,
             PRIMARY KEY (habit_id, check_date),
             FOREIGN KEY (habit_id) REFERENCES habits(id) ON DELETE CASCADE
         );",
    )?;
    Ok(connection)
}

这里有两个容易遗漏的点。第一,外键约束需要显式打开,SQLite 不一定默认启用。第二,习惯名称同时受到数据库约束和应用层校验保护,避免只依赖界面限制。

实际项目中可以在启动阶段取得 Tauri 的应用数据目录,创建目录后再调用 open_database。目录创建失败、文件不可写或数据库损坏时,应让启动过程返回明确错误,而不是静默退化成内存模式,否则用户会误以为数据已经保存。

设计命令接口

前端不应直接拼接 SQL。可以定义几个窄接口:查询列表、创建习惯、切换当天完成状态。

use serde::Serialize;
use rusqlite::{
   params, Connection};

#[derive(Serialize)]
pub struct HabitView {
   
    pub id: i64,
    pub name: String,
    pub checked_today: bool,
    pub streak: u32,
}

pub fn create_habit(db: &Connection, name: &str, created_at: &str) -> rusqlite::Result<i64> {
   
    let name = name.trim();
    if name.is_empty() || name.chars().count() > 80 {
   
        return Err(rusqlite::Error::InvalidParameterName("invalid habit name".into()));
    }

    db.execute(
        "INSERT INTO habits (name, created_at) VALUES (?1, ?2)",
        params![name, created_at],
    )?;
    Ok(db.last_insert_rowid())
}

错误类型最好在命令边界转换成前端可识别的字符串或结构,例如 invalid_inputdatabase_unavailableconflict。不要把数据库驱动的内部错误细节原样展示给用户,也不要用统一的“操作失败”掩盖真正原因。

切换完成状态时,使用插入或删除表达幂等操作:存在则删除,不存在则插入。为了避免日期格式不一致,前端传入的日期应限定为 YYYY-MM-DD,后端仍然需要进行格式校验。

pub fn toggle_check(
    db: &Connection,
    habit_id: i64,
    date: &str,
) -> rusqlite::Result<bool> {
   
    let transaction = db.unchecked_transaction()?;
    let exists: bool = transaction.query_row(
        "SELECT EXISTS(SELECT 1 FROM habit_checks WHERE habit_id = ?1 AND check_date = ?2)",
        params![habit_id, date],
        |row| row.get(0),
    )?;

    if exists {
   
        transaction.execute(
            "DELETE FROM habit_checks WHERE habit_id = ?1 AND check_date = ?2",
            params![habit_id, date],
        )?;
    } else {
   
        transaction.execute(
            "INSERT INTO habit_checks (habit_id, check_date) VALUES (?1, ?2)",
            params![habit_id, date],
        )?;
    }
    transaction.commit()?;
    Ok(!exists)
}

事务保证查询和修改属于同一个原子操作。若应用未来支持多窗口或后台同步,仍然应在数据库层保留联合主键,并根据并发模型进一步选择锁策略。

计算连续天数

连续天数的定义必须先写清楚。本文采用“从指定日期向前连续完成的天数”:如果今天已完成,从今天开始向前检查;如果今天未完成,连续天数为零。也可以改成包含最近完成日的定义,但界面文案和测试必须同步。

pub fn calculate_streak(
    db: &Connection,
    habit_id: i64,
    today: &str,
) -> rusqlite::Result<u32> {
   
    let mut statement = db.prepare(
        "SELECT check_date
         FROM habit_checks
         WHERE habit_id = ?1 AND check_date <= ?2
         ORDER BY check_date DESC",
    )?;

    let dates = statement.query_map(params![habit_id, today], |row| row.get::<_, String>(0))?;
    let mut streak = 0;
    let mut expected = parse_date(today);

    for date in dates {
   
        let actual = parse_date(&date?);
        if actual != expected {
   
            break;
        }
        streak += 1;
        expected = expected.previous_day();
    }
    Ok(streak)
}

parse_dateprevious_day 应使用可靠的日期类型实现,不能通过字符串递减日期。示例省略日期库的具体代码,是因为日期库版本和项目已有依赖可能不同;实现时应确保闰年、月份边界和非法日期都有测试。

前端调用与状态刷新

前端每次创建或切换成功后,重新调用列表命令即可。对于习惯数量较少的桌面应用,全量刷新比维护多处局部状态更容易保证一致性。

import {
    invoke } from '@tauri-apps/api/core';

type Habit = {
   
  id: number;
  name: string;
  checked_today: boolean;
  streak: number;
};

let habits: Habit[] = [];

async function loadHabits() {
   
  habits = await invoke<Habit[]>('list_habits');
}

async function toggleToday(id: number, date: string) {
   
  await invoke('toggle_habit_check', {
    habitId: id, date });
  await loadHabits();
}

按钮在请求期间应暂时禁用,并显示失败状态。不要在点击后无条件把 checked_today 取反,因为数据库操作可能失败,乐观更新会让界面与真实数据产生偏差。

权限与打包配置

Tauri 应用的命令和插件通常受权限配置控制。只开放实际需要的能力,例如调用自定义命令、访问应用数据目录等。不要为了快速调试而开启全部权限,并在发布前检查配置文件是否包含开发期测试权限。

发布前至少验证以下内容:

  1. 首次启动能够创建数据目录和数据库。
  2. 应用重启后习惯与完成记录仍然存在。
  3. 同一天重复点击不会生成重复记录。
  4. 删除习惯后,其完成记录按外键规则处理。
  5. 数据库不可写时,界面能显示可理解的错误。
  6. 日期跨越月份、年份和闰年时,连续天数符合定义。

桌面应用还应考虑数据库备份。可以提供“导出 JSON”功能,把习惯和完成记录导出到用户指定位置;导入时先校验结构,再通过事务写入。导入失败必须回滚,避免只导入了一半数据。

常见问题

为什么不用前端存储?

前端存储适合轻量设置,但当数据需要事务、约束、备份和迁移时,SQLite 更容易形成清晰的数据边界。若应用只保存少量临时选项,使用浏览器存储也可以,不必为了数据库而引入复杂度。

连续天数应该存数据库吗?

通常不需要。它依赖完成记录和当前日期,是可计算字段。只有在数据量、查询频率或离线统计明确证明计算成为瓶颈时,才考虑缓存,并为缓存设计重建机制。

用户修改系统时间怎么办?

应用应把日期来源、时区和可接受的时间变化写入产品规则。简单应用可以使用本地日期,并在检测到日期倒退时提示用户;需要可信时间的场景则应引入受信任的时间来源,但这会增加网络依赖和隐私考量。

数据库迁移怎么做?

不要直接修改已发布数据库的表结构。为数据库增加版本号,应用启动时按顺序执行迁移,每个迁移在事务中完成。迁移脚本应保持幂等或明确记录已执行版本,并在升级前提供备份路径。

总结

用 Rust 与 Tauri 构建本地桌面工具时,关键不在于把页面快速显示出来,而在于明确数据所有权和状态边界。SQLite 约束负责阻止非法状态,Rust 命令负责校验和事务,前端负责交互与结果呈现。习惯树只是一个具体例子,同样的设计也适用于阅读记录、训练计划和个人任务清单等本地应用。

实现顺序可以保持克制:先完成数据模型和迁移,再实现幂等写入与查询,最后补充界面状态、备份和打包验证。这样即使后续增加标签、提醒或统计视图,也能在已有边界上扩展,而不是重写所有业务逻辑。

相关文章
人工智能 缓存 前端开发
8205 29
人工智能 JavaScript 开发工具
3533 8
开发工具 Swift git
1344 2
缓存 JavaScript Shell
1660 2
Shell API 调度
917 3
人工智能 JavaScript 测试技术
961 0
安全 机器人 API
701 2
|
16天前
|
人工智能 程序员 API
Codex 接入 DeepSeek-V4-Flash:还能补上识图,提供两套方案
Codex 接入 DeepSeek-V4-Flash 怎么配?本文覆盖 CLI 与桌面端,再用 qwen3-vl-flash 补识图,两套方案可直接照做
1886 13
|
15天前
|
存储 弹性计算 缓存
阿里云服务器租赁费用:新版租赁收费标准及活动报价参考
本文更新了2026年阿里云全系列云服务器租赁活动报价,所有特惠资源均可前往阿里云活动中心选购,整体覆盖从个人入门到企业级高性能场景的全梯度需求。其中轻量应用服务器主打极致性价比,2核2G峰值200M带宽配置每日10点、15点限时抢购价仅38元/年,2核4G配置379元/年起;高性价比的经济型e实例、通用算力型u2i实例覆盖2核4G至4核32G全档位,适配开发测试与中小型企业业务;搭载英特尔至强6处理器的第九代c9i企业级实例算力较上代提升20%,支撑高并发生产环境,不同实例规格价差清晰,用户可根据自身业务负载与预算灵活选型。
2178 121
阿里云服务器租赁费用:新版租赁收费标准及活动报价参考