习惯追踪应用看似简单,真正实现时通常会遇到三个工程问题:数据不能只停留在内存中,重复点击不能造成重复记录,前端显示状态还必须与本地数据库保持一致。
如果直接把所有逻辑写在前端,短期内可以完成页面,但随着功能增加,日期判断、连续天数、撤销操作和数据迁移很容易变成难以测试的条件分支。更稳妥的做法是把数据规则放在 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_input、database_unavailable 和 conflict。不要把数据库驱动的内部错误细节原样展示给用户,也不要用统一的“操作失败”掩盖真正原因。
切换完成状态时,使用插入或删除表达幂等操作:存在则删除,不存在则插入。为了避免日期格式不一致,前端传入的日期应限定为 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_date 和 previous_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 应用的命令和插件通常受权限配置控制。只开放实际需要的能力,例如调用自定义命令、访问应用数据目录等。不要为了快速调试而开启全部权限,并在发布前检查配置文件是否包含开发期测试权限。
发布前至少验证以下内容:
- 首次启动能够创建数据目录和数据库。
- 应用重启后习惯与完成记录仍然存在。
- 同一天重复点击不会生成重复记录。
- 删除习惯后,其完成记录按外键规则处理。
- 数据库不可写时,界面能显示可理解的错误。
- 日期跨越月份、年份和闰年时,连续天数符合定义。
桌面应用还应考虑数据库备份。可以提供“导出 JSON”功能,把习惯和完成记录导出到用户指定位置;导入时先校验结构,再通过事务写入。导入失败必须回滚,避免只导入了一半数据。
常见问题
为什么不用前端存储?
前端存储适合轻量设置,但当数据需要事务、约束、备份和迁移时,SQLite 更容易形成清晰的数据边界。若应用只保存少量临时选项,使用浏览器存储也可以,不必为了数据库而引入复杂度。
连续天数应该存数据库吗?
通常不需要。它依赖完成记录和当前日期,是可计算字段。只有在数据量、查询频率或离线统计明确证明计算成为瓶颈时,才考虑缓存,并为缓存设计重建机制。
用户修改系统时间怎么办?
应用应把日期来源、时区和可接受的时间变化写入产品规则。简单应用可以使用本地日期,并在检测到日期倒退时提示用户;需要可信时间的场景则应引入受信任的时间来源,但这会增加网络依赖和隐私考量。
数据库迁移怎么做?
不要直接修改已发布数据库的表结构。为数据库增加版本号,应用启动时按顺序执行迁移,每个迁移在事务中完成。迁移脚本应保持幂等或明确记录已执行版本,并在升级前提供备份路径。
总结
用 Rust 与 Tauri 构建本地桌面工具时,关键不在于把页面快速显示出来,而在于明确数据所有权和状态边界。SQLite 约束负责阻止非法状态,Rust 命令负责校验和事务,前端负责交互与结果呈现。习惯树只是一个具体例子,同样的设计也适用于阅读记录、训练计划和个人任务清单等本地应用。
实现顺序可以保持克制:先完成数据模型和迁移,再实现幂等写入与查询,最后补充界面状态、备份和打包验证。这样即使后续增加标签、提醒或统计视图,也能在已有边界上扩展,而不是重写所有业务逻辑。