三个习惯点亮大片热力图:鸿蒙打卡日历种子数据与连续天数


实例:习惯打卡日历(Habit)|技术:双表种子注入、30 天打卡数据生成
一、种子数据的设计目标
实例 5 的种子数据是前四个实例里最复杂的——因为它是双表注入:既要插习惯(habit),又要按规则生成 30 天的打卡记录(habit_check)。设计目标:
- 3 个习惯:晨跑、阅读、早睡,覆盖运动/学习/作息三类典型习惯;
- 30 天打卡数据:每个习惯近 30 天按不同频率打卡,热力图「大片点亮」但有留白——全满太假,全空没效果;
- 频率差异化:跑步 80%、阅读 90%、早睡 70%,展示不同强度的坚持效果;
- 今天必打卡:保证「连续天数」从今天开始是连续的,激励文案有内容。
二、习惯种子数据
const habitSeeds: HabitSeed[] = [
{ name: '晨跑 5 公里', icon: '🏃', color: '#3B82F6', target: 22, createdTime: now - 40 * day },
{ name: '阅读 30 分钟', icon: '📚', color: '#F59E0B', target: 25, createdTime: now - 38 * day },
{ name: '早睡不熬夜', icon: '🌙', color: '#8B5CF6', target: 26, createdTime: now - 35 * day },
];
const ids: number[] = [];
for (const s of habitSeeds) {
const values: relationalStore.ValuesBucket = {
name: s.name, icon: s.icon, color: s.color, target: s.target, created_time: s.createdTime,
};
ids.push(await store.insert(HabitDao.TABLE, values));
}
三个习惯的差异化设计:
| 习惯 | 图标 | 主题色 | 月目标 | 打卡频率 |
|---|---|---|---|---|
| 晨跑 5 公里 | 🏃 | 蓝 #3B82F6 |
22 天 | 80% |
| 阅读 30 分钟 | 📚 | 橙 #F59E0B |
25 天 | 90% |
| 早睡不熬夜 | 🌙 | 紫 #8B5CF6 |
26 天 | 70% |
ids.push(await store.insert(...)) 记录新插入的习惯 id——打卡记录要用这些 id 关联。这是双表注入的关键衔接:先插主表拿到自增 id,再插从表引用。
三、30 天打卡数据的生成算法
打卡记录的生成是这个种子数据的算法亮点——用「每 5 天跳过几天」的取模规则模拟频率:
// 生成近 30 天的日期列表
const dateList: string[] = [];
for (let i = 29; i >= 0; i--) {
const d = new Date(now - i * day);
dateList.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`);
}
const rate = [0.8, 0.9, 0.7];
for (let h = 0; h < ids.length; h++) {
for (let i = 0; i < dateList.length; i++) {
const skip = (i % 5) >= Math.round(rate[h] * 5) && i !== 29;
if (skip) {
continue;
}
const values: relationalStore.ValuesBucket = {
habit_id: ids[h], date: dateList[i], check_time: now - i * day,
};
await store.insert(HabitDao.CHECK_TABLE, values);
}
}
算法拆解:
- 日期列表生成:
for (let i = 29; i >= 0; i--)从 29 天前到今天的 30 个日期,now - i * day逐日偏移,格式化YYYY-MM-DD; - 频率控制:
(i % 5) >= Math.round(rate[h] * 5)—— 每 5 天是一个周期,跳过周期的后半段。晨跑 80%(Math.round(0.8*5)=4)每 5 天跳 1 天;阅读 90%(round(0.9*5)=5)几乎全勤;早睡 70%(round(0.7*5)=4)每 5 天跳 1 天; - 今天强制打卡:
&& i !== 29排除今天——不管频率如何,今天必须打卡,保证连续天数从今天起算非零; - check_time 复用日期偏移:打卡时间戳也用
now - i * day,与 date 一致,数据自洽。
这个取模生成法的优点:确定性——同样的输入永远生成同样的数据(不像随机数每次不同),便于调试复现;有规律——留白均匀分布,热力图呈现「规律性斑驳」而非杂乱随机。
四、注入后的热力图效果预测
三个习惯 × 30 天的打卡数据注入后,热力图呈现:
| 习惯 | 30 天打卡天数 | 连续天数(今天起算) | 视觉效果 |
|---|---|---|---|
| 晨跑 🏃 | 约 24 天 | 取决于尾部连续 | 蓝色大面积点亮,偶有留白 |
| 阅读 📚 | 约 27 天 | 大概率最长 | 橙色几乎全满 |
| 早睡 🌙 | 约 24 天 | 各有不同 | 紫色大片点亮 |
因为今天的打卡是强制的,且取模跳过的天数是「每 5 天跳 1 天」,尾部(最近几天)大概率连续——连续天数会是一个有说服力的数字(如 5~10 天),激励文案「已连续坚持 N 天」立得住。
五、幂等保护与注入顺序
双表注入的幂等保护与前面实例相同,但注意判断依据是习惯表:
const countResult = await store.querySql(`SELECT COUNT(*) AS c FROM ${HabitDao.TABLE}`);
let total = 0;
if (countResult.goToNextRow()) {
total = countResult.getLong(countResult.getColumnIndex('c'));
}
countResult.close();
if (total > 0) {
return; // 已有习惯,跳过(连带打卡记录也不会重复注入)
}
为什么只看习惯表? 因为打卡记录必然伴随习惯存在(insertHabit 后才会有 checkIn)。习惯表非空即代表整套种子已注入,无需再查打卡表——幂等判断选「根表」即可,避免重复查询。
注入顺序至关重要:先插 3 个习惯(拿到 ids),再插打卡记录(引用 ids)。如果顺序颠倒,打卡记录会引用不存在的 habit_id,产生孤儿数据。
六、验证方法
- hilog 日志:搜
HabitDao,看到「已填充 3 个习惯及近 30 天打卡数据」; - 页面显示:进入打卡页面,热力图大片点亮、连续天数非零、习惯列表 3 行;
- 数据库直查:
SELECT COUNT(*) FROM habit; -- 3 SELECT COUNT(*) FROM habit_check; -- 约 75(3 习惯 × 25 天平均) SELECT habit_id, COUNT(*) FROM habit_check GROUP BY habit_id; -- 各习惯打卡天数:晨跑约 24、阅读约 27、早睡约 24 - 断点验证:点掉今天的打卡(再点一次格子),连续天数应减 1(从昨天起算),再点回来恢复。
6.1 打卡数据与连续天数的一致性推演
种子数据的生成算法是确定性的,因此我们可以手推验证连续天数是否正确。以阅读习惯(频率 90%)为例,取模规则 (i % 5) >= 5 恒为 false(Math.round(0.9*5)=5),意味着阅读习惯 30 天全勤——除了今天(i=29)本应全勤,所以阅读的连续天数应该是 30(如果 30 天前也是打卡的话,从昨天起算最多 29~30)。
晨跑(频率 80%,Math.round(0.8*5)=4)每 5 天跳过 1 天(i % 5 === 4 时跳过,i 从 0 到 29):跳过的天是 i=4, 9, 14, 19, 24——对应「4 天前、9 天前…」。最近一次跳过是 4 天前,所以从今天往前数:今天 ✅、昨天 ✅、前天 ✅、3 天前 ✅、4 天前 ❌——连续天数 = 4。这个手推值可以直接验证页面显示是否一致。
6.2 为什么用取模而不是随机?
前文提到取模生成法的两个优点(确定性、有规律),这里再补充一个关键点:可测试性。随机生成的打卡数据每次重装都不一样,无法写自动化测试断言;取模生成的数据永远相同,测试可以硬编码期望值(如「晨跑连续 4 天」)。对种子数据来说,确定性就是可测试性,这是测试工程的重要考量。
七、FAQ
Q1:为什么热力图有规律性留白?
A:取模生成法每 5 天跳 1 天,留白均匀分布。真实用户的打卡也是「偶尔断一天」,规律性留白比全满更真实。如果你想要更自然的随机分布,可以用 Math.random() < rate 生成,但会牺牲确定性(每次重装数据不同)。
Q2:连续天数为什么不是 30?
A:因为中间有跳过日(断签),连续天数只算「从今天往前不间断」的天数。这正是激励设计——它反映的是「当下坚持的连续性」,而不是「历史总打卡数」。
Q3:可以只注入习惯不注入打卡吗?
A:可以,把打卡循环删掉即可——页面会显示空热力图和 0 连续天数。但演示效果大打折扣,种子数据的意义就是「开屏即有内容」。
Q4:双表种子的 order 为什么重要?
A:先插 habit 拿自增 id,后插 habit_check 引用 id。反了会出现外键悬空。生产环境的大批量双表注入建议包在事务里保证原子性。
Q5:三个习惯的频率为什么不同?
A:差异化设计。全 100% 会显得「完美到假」,全 50% 显得「三天打鱼」。80%/90%/70% 的梯度让热力图呈现三种不同的「坚持强度」,对比展示更有说服力,也验证了连续天数算法在不同密度下的正确性。
Q6:target(月目标)和实际打卡数不一致怎么办?
A:没关系——target 是「目标」,打卡数是「实际」。页面显示「本月 22/22 天」表示达标,「24/26 天」表示还差 2 天,这种「目标 vs 实际」的对比正是进度激励的核心。本实例的 target 设计(22/25/26)刻意高于实际打卡数(24/27/24 中的部分),让部分习惯显示「未达标」,展示进度条的非满状态。
Q7:注入的 check_time 和 date 是否自洽?
A:自洽。check_time: now - i * day 与 dateList[i] 对应的日期一致——同一行记录里,日期字符串与时间戳指向同一天。数据自洽是种子数据的基本质量要求。
八、文章小结
本篇文章展示了实例 5 的种子数据:3 个习惯 + 按频率规则生成的近 30 天打卡记录。技术亮点是「取模跳过」的频率模拟算法——确定性、有规律、今天强制打卡,让热力图一开屏就大片点亮且连续天数有内容。双表注入的顺序(先主后从)与根表幂等判断也是可复用的模式。更进一步,我们验证了打卡数据与连续天数的可推演一致性——种子数据不只用于演示,还能作为算法正确性的测试基准。
九、initSeedData 完整代码回顾
为了让读者完整复现,这里给出 initSeedData 的完整实现(与落地代码一致):
static async initSeedData(context: common.Context): Promise<void> {
const store = await HabitDao.getStore(context);
// 1. 幂等判断(以根表 habit 为准)
const countResult = await store.querySql(`SELECT COUNT(*) AS c FROM ${HabitDao.TABLE}`);
let total = 0;
if (countResult.goToNextRow()) {
total = countResult.getLong(countResult.getColumnIndex('c'));
}
countResult.close();
if (total > 0) {
return;
}
const now = Date.now();
const day = 86400000;
// 2. 插入 3 个习惯,记录返回的 id
const habitSeeds: HabitSeed[] = [
{ name: '晨跑 5 公里', icon: '🏃', color: '#3B82F6', target: 22, createdTime: now - 40 * day },
{ name: '阅读 30 分钟', icon: '📚', color: '#F59E0B', target: 25, createdTime: now - 38 * day },
{ name: '早睡不熬夜', icon: '🌙', color: '#8B5CF6', target: 26, createdTime: now - 35 * day },
];
const ids: number[] = [];
for (const s of habitSeeds) {
const values: relationalStore.ValuesBucket = {
name: s.name, icon: s.icon, color: s.color, target: s.target, created_time: s.createdTime,
};
ids.push(await store.insert(HabitDao.TABLE, values));
}
// 3. 生成近 30 天日期列表
const dateList: string[] = [];
for (let i = 29; i >= 0; i--) {
const d = new Date(now - i * day);
dateList.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`);
}
// 4. 按频率注入打卡记录(今天强制打卡)
const rate = [0.8, 0.9, 0.7];
for (let h = 0; h < ids.length; h++) {
for (let i = 0; i < dateList.length; i++) {
const skip = (i % 5) >= Math.round(rate[h] * 5) && i !== 29;
if (skip) {
continue;
}
const values: relationalStore.ValuesBucket = {
habit_id: ids[h], date: dateList[i], check_time: now - i * day,
};
await store.insert(HabitDao.CHECK_TABLE, values);
}
}
hilog.info(DOMAIN, TAG, '已填充 3 个习惯及近 30 天打卡数据');
}
四步流程:幂等判断 → 插习惯拿 id → 生成日期列表 → 按频率插打卡。每一步都是可独立复用的模式。
十、批量插入的性能思考
3 个习惯 × 约 25 天 ≈ 75 条打卡记录,逐条 store.insert 毫秒级完成,无需事务优化。但如果扩展到 20 个习惯 × 365 天 ≈ 7300 条,逐条插入会明显变慢(每条 insert 都是一次独立事务)。此时应包在显式事务里:
await store.beginTransaction();
// ... 循环插入
await store.commit();
事务把多次插入合并为一次磁盘提交,性能提升一个数量级。实例 6(库存进销存)会正式演示 beginTransaction/commit/rollBack 的完整用法,这里先埋个伏笔。
十一、FAQ(补充)
Q8:为什么打卡记录的插入顺序是「先习惯后打卡」?
A:打卡记录引用习惯 id,必须先插入习惯拿到自增 id。如果顺序颠倒,ids[h] 就是空数组,打卡记录全部插入失败或引用错误 id。双表种子的顺序依赖是硬约束。
Q9:dateList 为什么从 i=29 倒序生成?
A:for (let i = 29; i >= 0; i--) 从 29 天前生成到今天——dateList[0] 是最早的日期,dateList[29] 是今天。这样后续 i !== 29 的判断恰好表达「今天」,语义清晰。倒序 vs 正序不影响数据正确性,只是让「今天是最后一个元素」这个约定更自然。
Q10:频率 rate 数组和习惯的顺序如何对应?
A:rate = [0.8, 0.9, 0.7] 与 ids(插入顺序)一一对应:ids[0] 晨跑 80%、ids[1] 阅读 90%、ids[2] 早睡 70%。两个数组靠下标对齐,是「平行数组」模式。生产代码建议用对象数组(习惯 + 频率成对),更易维护——本 Demo 保持简洁。
Q11:如果修改了种子数据,如何让旧设备生效?
A:幂等保护阻止重复注入。两个方案:卸载重装(Demo 最快),或把数据库版本号 +1 触发迁移逻辑(生产正确做法)。5-5 文章的运行效果是基于全新安装的演示。
Q12:打卡数据为什么要「今天强制打卡」?
A:如果今天不打卡,currentStreak 会从昨天起算(5-3 文章讲过边界),连续天数可能为 0——开屏看到「连续 0 天」的激励文案,演示效果大打折扣。强制今天打卡保证「连续 N 天」有值,激励文案立得住。
十二、总结与预告
实例 5 的种子数据到此讲透。至此,实例 5 的建表(5-1)、UI(5-2)、查询(5-3)、种子(5-4)四篇完成。下一篇(5-5)是收官文章,展示 HabitPage 全量代码与运行效果,把双表 + 热力图 + 连续天数串成一条完整的「自律可视化」App。
更多推荐




所有评论(0)