在这里插入图片描述
在这里插入图片描述

实例:习惯打卡日历(Habit Check-in)|技术:日期范围查询、连续天数统计、双表设计

一、业务需求分析:打卡应用的五个核心诉求

打卡类应用(习惯养成、健身记录、学习打卡)在市场上经久不衰,其数据模型也高度一致。把需求拆解开来,我们得到五个核心诉求:

  1. 创建习惯:用户新建一个习惯,包含名称(晨跑)、图标(🏃)、主题色、每月目标天数;
  2. 每日打卡 / 取消打卡:今天做了就点一下,做错了要能取消——注意「取消」也是需求,因为手滑打卡很常见;
  3. 月历热力图:本月哪些天打了卡?用日历网格可视化展示,一眼看到自律程度;
  4. 连续打卡天数统计:「已连续坚持 15 天」是打卡应用最核心的激励文案,必须算得准确;
  5. 习惯列表 + 各自进度:多个习惯并行展示,每个习惯有自己的进度条。

这五个需求里,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);

注意落地版与文章版的两个差异:

  1. 表名:文章版叫 checkin,落地版叫 habit_check——更清晰地表达「习惯的打卡」,避免与系统保留字混淆;
  2. 索引:文章版建普通索引 idx_checkin_habitidx_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;
}

算法三步走

  1. 拉取该习惯全部打卡日期,转成 Set(去重 + O(1) 查找);
  2. 确定起点:今天打卡了就从今天起算;今天没打卡就从昨天起算——这样「昨天打了今天还没打」不会误判为断签,用户体验更友好(毕竟今天还没过完);
  3. 逐日回溯:往前一天一天数,遇到集合里没有的日期就停,返回连续天数。

为什么不用 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 更简洁。这是日历类、打卡类、签到类应用的通用数据模型——本实例的建模经验可以直接迁移到任何「主体 × 时间行为」的场景。

Logo

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

更多推荐