自律背后的双表结构:ArkTS 为鸿蒙打卡日历设计日期字段


实例:习惯打卡日历(Habit Check-in)|技术:日期范围查询、连续天数统计、双表设计
一、业务需求分析:打卡应用的五个核心诉求
打卡类应用(习惯养成、健身记录、学习打卡)在市场上经久不衰,其数据模型也高度一致。把需求拆解开来,我们得到五个核心诉求:
- 创建习惯:用户新建一个习惯,包含名称(晨跑)、图标(🏃)、主题色、每月目标天数;
- 每日打卡 / 取消打卡:今天做了就点一下,做错了要能取消——注意「取消」也是需求,因为手滑打卡很常见;
- 月历热力图:本月哪些天打了卡?用日历网格可视化展示,一眼看到自律程度;
- 连续打卡天数统计:「已连续坚持 15 天」是打卡应用最核心的激励文案,必须算得准确;
- 习惯列表 + 各自进度:多个习惯并行展示,每个习惯有自己的进度条。
这五个需求里,2、3、4 都围绕一个核心概念——「某习惯 × 某一天」的打卡记录。这决定了数据模型必须是一对多的双表结构:一张习惯表存元数据,一张打卡表存每天的行为记录。
二、双表字段设计:习惯表与打卡表
习惯表 habit
| 字段名 | 类型 | 约束 | 说明 |
|---|---|---|---|
| id | INTEGER | PRIMARY KEY AUTOINCREMENT | 自增主键 |
| name | TEXT | NOT NULL | 习惯名称(晨跑/阅读…) |
| icon | TEXT | DEFAULT ‘🏃’ | 图标 emoji |
| color | TEXT | DEFAULT ‘#6366F1’ | 主题色 HEX |
| target | INTEGER | DEFAULT 30 | 每月目标天数 |
| created_time | INTEGER | NOT NULL | 创建时间戳 |
打卡记录表 habit_check
| 字段名 | 类型 | 约束 | 说明 |
|---|---|---|---|
| id | INTEGER | PRIMARY KEY AUTOINCREMENT | 自增主键 |
| habit_id | INTEGER | NOT NULL | 关联习惯(逻辑外键) |
| date | TEXT | NOT NULL | 打卡日期 YYYY-MM-DD |
| check_time | INTEGER | NOT NULL | 打卡时间戳(毫秒) |
设计要点逐条拆解:
1. checkin.date 用 TEXT 存 YYYY-MM-DD。 这是与前面实例(时间戳 INTEGER)最关键的不同。为什么日历场景用字符串日期?三个理由:
- 可读性:‘2025-06-15’ 一眼看懂,调试友好;
- 有序性:字符串日期按字典序排列 = 按时间序排列,
ORDER BY date天然正确,无需转换; - 区间比较:
BETWEEN '2025-06-01' AND '2025-06-30'直接可用,字符串比较在定长格式下等价于时间比较。
2. habit_id + date 联合唯一约束。 一个习惯一天最多打一次卡,这个业务规则必须由数据库强制保证,而不是靠前端判断。落地版用唯一索引实现:
CREATE UNIQUE INDEX IF NOT EXISTS idx_check_unique ON habit_check (habit_id, date);
3. 一张 habit_check 表存所有习惯的打卡记录。 不按习惯拆表——所有习惯的打卡都在这张表里,用 habit_id 过滤。这是「一对多」关系的标准建模:一(habit)对多(habit_check),外键指向主表。
4. 逻辑外键 vs 物理外键。 落地版没有声明 FOREIGN KEY 物理约束,而是用 habit_id INTEGER NOT NULL + 应用层级联删除。原因:RDB(relationalStore)对物理外键的支持需要额外配置 PRAGMA foreign_keys=ON,且物理外键在级联删除、批量操作时行为更复杂。个人 App 场景用逻辑外键 + 代码级联更可控——删除习惯时先删打卡记录再删习惯(见 deleteHabit 方法)。
三、建表 SQL:双表 + 三索引
CREATE TABLE IF NOT EXISTS habit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
icon TEXT DEFAULT '🏃',
color TEXT DEFAULT '#6366F1',
target INTEGER DEFAULT 30,
created_time INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS habit_check (
id INTEGER PRIMARY KEY AUTOINCREMENT,
habit_id INTEGER NOT NULL,
date TEXT NOT NULL,
check_time INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_check_unique ON habit_check (habit_id, date);
注意落地版与文章版的两个差异:
- 表名:文章版叫
checkin,落地版叫habit_check——更清晰地表达「习惯的打卡」,避免与系统保留字混淆; - 索引:文章版建普通索引
idx_checkin_habit、idx_checkin_date两个;落地版只建一个联合唯一索引idx_check_unique ON (habit_id, date)。一个索引同时完成三件事:强制唯一、按 habit 过滤、按 habit+date 定位。SQLite 的多列索引最左前缀原则下,(habit_id, date)索引能同时服务「按 habit_id 查全部」和「按 habit_id + date 查单天」两种查询——索引更少,能力更全。
四、HabitDao 封装:单例复用 + ResultSet 映射
数据层核心 HabitDao,与前面实例相同的架构模式。实体接口:
export interface Habit {
id: number;
name: string;
icon: string; // emoji 图标
color: string; // 主题色 HEX
target: number; // 每月目标天数
createdTime: number;
}
export interface CheckIn {
id: number;
habitId: number;
date: string; // 'YYYY-MM-DD'
checkTime: number; // 打卡时间戳
}
getStore 单例复用 + 双表初始化:
static async getStore(context: common.Context): Promise<relationalStore.RdbStore> {
if (HabitDao.store) {
return HabitDao.store;
}
const config: relationalStore.StoreConfig = {
name: 'habit.db',
securityLevel: relationalStore.SecurityLevel.S1,
};
HabitDao.store = await relationalStore.getRdbStore(context, config);
await HabitDao.store.executeSql(
`CREATE TABLE IF NOT EXISTS ${HabitDao.TABLE} (...)` // 习惯表
);
await HabitDao.store.executeSql(
`CREATE TABLE IF NOT EXISTS ${HabitDao.CHECK_TABLE} (...)` // 打卡表
);
await HabitDao.store.executeSql(
`CREATE UNIQUE INDEX IF NOT EXISTS idx_check_unique ON ${HabitDao.CHECK_TABLE} (habit_id, date)`
);
hilog.info(DOMAIN, TAG, '习惯表与打卡表初始化成功');
return HabitDao.store;
}
ArkTS 语法红线:静态方法里引用静态字段必须写 HabitDao.store,不能写 this.store——arkts-no-this-in-static 是 ArkTS 编译器的强制规则,文章版代码里的 this.store 全部是编译错误,落地版已修正。
行映射方法(ResultSet 模式,而非文章版的 getRow 强转):
private static rowToHabit(result: relationalStore.ResultSet): Habit {
return {
id: result.getLong(result.getColumnIndex('id')),
name: result.getString(result.getColumnIndex('name')),
icon: result.getString(result.getColumnIndex('icon')) || '🏃',
color: result.getString(result.getColumnIndex('color')) || '#6366F1',
target: result.getLong(result.getColumnIndex('target')),
createdTime: result.getLong(result.getColumnIndex('created_time')),
};
}
五、习惯的增删查:一对多级联删除
习惯 CRUD 中,deleteHabit 是最有讲究的——它演示了逻辑外键下的级联删除:
static async deleteHabit(context: common.Context, id: number): Promise<void> {
const store = await HabitDao.getStore(context);
const checkPred = new relationalStore.RdbPredicates(HabitDao.CHECK_TABLE);
checkPred.equalTo('habit_id', id);
await store.delete(checkPred); // 先删打卡记录
const habitPred = new relationalStore.RdbPredicates(HabitDao.TABLE);
habitPred.equalTo('id', id);
await store.delete(habitPred); // 再删习惯本体
}
顺序为什么重要?如果先删习惯再删打卡,就会出现「孤儿记录」——打卡记录指向一个不存在的习惯,热力图查询会拿到脏数据。先删子表再删主表,保证数据库永远干净。生产环境若数据量大,这一步应包在事务里(beginTransaction/commit),后续实例(库存进销存)会演示事务写法。
六、打卡与取消打卡:toggle 语义的实现
打卡按钮的交互是「再点一次取消」——toggle 切换语义。落地版实现:
static async toggleCheck(context: common.Context, habitId: number, date: string): Promise<void> {
const store = await HabitDao.getStore(context);
const pred = new relationalStore.RdbPredicates(HabitDao.CHECK_TABLE);
pred.equalTo('habit_id', habitId).equalTo('date', date);
const result = await store.query(pred);
const has = result.goToNextRow();
result.close();
if (has) {
await store.delete(pred); // 已打卡 → 取消
} else {
const values: relationalStore.ValuesBucket = {
habit_id: habitId, date: date, check_time: Date.now(),
};
await store.insert(HabitDao.CHECK_TABLE, values); // 未打卡 → 打卡
}
}
为什么不用 INSERT OR REPLACE? 因为 toggle 需要「有则删、无则插」的对称语义,而 INSERT OR REPLACE 只能覆盖不能删除。先查后插/删虽然多一次查询,但逻辑清晰、零歧义。唯一索引 idx_check_unique 在这里是最后防线——即使前端并发点了两次,数据库也会拒绝重复打卡,保证数据一致性。
hasCheckIn(判断某天是否已打卡)与 toggleCheck 前半段逻辑一致,是热力图渲染的查询基础:
static async hasCheckIn(context: common.Context, habitId: number, date: string): Promise<boolean> {
const store = await HabitDao.getStore(context);
const predicates = new relationalStore.RdbPredicates(HabitDao.CHECK_TABLE);
predicates.equalTo('habit_id', habitId).equalTo('date', date);
const result = await store.query(predicates);
const has = result.goToNextRow();
result.close();
return has;
}
七、打卡日期集合:热力图的数据源
热力图需要「某习惯最近 N 天打了哪些卡」的日期集合:
static async checkDates(context: common.Context, habitId: number, days: number): Promise<string[]> {
const store = await HabitDao.getStore(context);
const predicates = new relationalStore.RdbPredicates(HabitDao.CHECK_TABLE);
predicates.equalTo('habit_id', habitId).orderByAsc('date');
const result = await store.query(predicates);
const list: string[] = [];
while (result.goToNextRow()) {
list.push(result.getString(result.getColumnIndex('date')));
}
result.close();
return list;
}
返回的是 string[] 日期数组(如 ['2025-06-01', '2025-06-02', ...])。页面把它转成 Set,判断某天是否打卡就是 O(1) 的 set.has(dateStr)——热力图渲染几十个格子,性能毫无压力。
关于 days 参数:方法签名接收 days 但当前实现没在 SQL 里过滤(简单起见拉全量)。生产版本可以加 WHERE date >= date('now', '-90 days') 只取近 90 天,减少传输量。读者可以自行优化。
八、连续天数统计:Set 回溯算法
「已连续坚持 N 天」是最重要的激励文案,统计逻辑要严谨——从今天(或昨天)往前数,中断即停:
static async currentStreak(context: common.Context, habitId: number): Promise<number> {
const store = await HabitDao.getStore(context);
const predicates = new relationalStore.RdbPredicates(HabitDao.CHECK_TABLE);
predicates.equalTo('habit_id', habitId).orderByDesc('date');
const result = await store.query(predicates);
const dates: string[] = [];
while (result.goToNextRow()) {
dates.push(result.getString(result.getColumnIndex('date')));
}
result.close();
if (dates.length === 0) {
return 0;
}
// 今天格式
const now = new Date();
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
const set = new Set<string>(dates);
let streak = 0;
// 若今天没打卡,从昨天开始算
let cursor = new Date(now.getTime());
if (!set.has(today)) {
cursor = new Date(now.getTime() - 86400000);
}
while (true) {
const key = `${cursor.getFullYear()}-${String(cursor.getMonth() + 1).padStart(2, '0')}-${String(cursor.getDate()).padStart(2, '0')}`;
if (set.has(key)) {
streak++;
cursor = new Date(cursor.getTime() - 86400000);
} else {
break;
}
}
return streak;
}
算法三步走:
- 拉取该习惯全部打卡日期,转成
Set(去重 + O(1) 查找); - 确定起点:今天打卡了就从今天起算;今天没打卡就从昨天起算——这样「昨天打了今天还没打」不会误判为断签,用户体验更友好(毕竟今天还没过完);
- 逐日回溯:往前一天一天数,遇到集合里没有的日期就停,返回连续天数。
为什么不用 SQL 做连续统计? 连续性的本质是「日期序列的连续性」——SQL 做这种「找最长连续子序列」需要窗口函数(LAG/ROW_NUMBER),RDB 的 querySql 支持有限。而拉出全部日期后在前端用 Set 回溯,逻辑直观、代码量少、几十上百条数据性能毫无问题。数据量小时,把复杂逻辑放内存比硬怼 SQL 更明智——这是本实例反复出现的务实原则。
九、本月打卡次数:月历进度
「本月 X/30 天」进度展示的查询:
static async monthCount(context: common.Context, habitId: number): Promise<number> {
const store = await HabitDao.getStore(context);
const now = new Date();
const firstDay = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-01`;
const predicates = new relationalStore.RdbPredicates(HabitDao.CHECK_TABLE);
predicates.equalTo('habit_id', habitId).greaterThanOrEqualTo('date', firstDay);
const result = await store.query(predicates);
let count = 0;
while (result.goToNextRow()) {
count++;
}
result.close();
return count;
}
greaterThanOrEqualTo('date', firstDay) 等价于 WHERE date >= '2025-06-01'——字符串日期比较在这里完美工作,>= '2025-06-01' 天然排除了上月记录。这就是日期字符串存储的回报:范围查询零转换。
十、技术要点对照表
| 技术点 | 实现方式 | 生产价值 |
|---|---|---|
| 日期字符串 | YYYY-MM-DD TEXT |
排序/比较/区间天然有序 |
| 双表设计 | habit + habit_check 一对多 | 习惯与打卡解耦 |
| 联合唯一索引 | UNIQUE (habit_id, date) |
数据库级防重复打卡 |
| 打卡切换 | 先查后插/删 toggle | 有则删无则插,语义对称 |
| 日期范围 | >= '2025-06-01' 字符串比较 |
月历热力图数据源 |
| 连续天数 | 日期集合 + Set 回溯 | 激励文案,算法清晰 |
| 级联删除 | 先删 checkin 再删 habit | 避免孤儿记录 |
十一、文章小结
打卡实例展示了一对多双表 + 日期字符串存储的设计:habit 表存习惯元数据,habit_check 表存每日打卡,UNIQUE(habit_id, date) 联合索引保证一天最多一次。连续天数统计用「日期集合 + Set 回溯」实现,比逐日 SQL 更简洁。这是日历类、打卡类、签到类应用的通用数据模型——本实例的建模经验可以直接迁移到任何「主体 × 时间行为」的场景。
更多推荐




所有评论(0)