HarmonyOS ArkTS 习惯打卡实战:处理连续天数、补签和日期切换
习惯打卡最容易制造一种"当天看起来完全正确"的错觉:点一下按钮,今日状态变绿,连续天数加一,月历上的格子也亮了。可是一旦跨过零点、切到下个月,或者用户希望补签昨天,原本简单的 todayDone: boolean 和"日期数字数组"就不够用了。
时光清单 的真实源码已经完成了两套打卡页面的数据统一:HabitView 与 HabitWallView 都调用 HabitService,服务会合并 HABITS、HABIT_WALL 两个历史存储键,并把结果回写到两边。但源码中的打卡记录只保存 new Date().getDate(),也就是 1 到 31;连续天数则在每次首次点击时直接加一。这种模型能支撑当月演示,却无法区分 1 月 5 日和 2 月 5 日,也没有跨日重置、断签重算和补签边界。
本文不把这些缺口包装成现成功能,而是从真实代码出发,先复核当前链路,再设计一套适用于 HarmonyOS 5.0 及以上版本的可迁移方案:使用本地日期键表达日历日,用打卡集合推导连续天数,用显式策略控制补签,并在应用重新回到前台时完成日期切换。

本文重点解决:
- 解释当前
todayDone、streak、checkinDays的真实语义和失效边界。 - 将"日号"升级为
yyyy-MM-dd本地日期键,避免跨月、跨年冲突。 - 从打卡记录推导当前连续天数,而不是无条件累加。
- 为补签定义过去日期、未来日期、重复提交和连续天数重算规则。
- 设计兼容旧 Preferences JSON 的迁移过程,并覆盖跨日与时区测试。
本文唯一标记:
CSDN-SERIES:ALL-163201980
一、真实链路:两个页面共用一个 HabitService
项目中有"自律打卡"和"打卡墙"两个入口。它们展示方式不同,但新增、改名、删除和打卡都通过同一个服务:
HabitView ─────┐
├─> HabitService ─> DataStore ─> Preferences
HabitWallView ─┘
HabitView 展示今日完成数、待打卡数和最长连续天数;HabitWallView 还会将 checkinDays 映射为当月热力格。页面点击打卡后直接使用服务返回的新数组更新 @State,再递增全局数据版本。
Button(habit.todayDone ? '✓' : '打卡')
.onClick(async () => {
const next = await this.service.checkIn(habit.id);
this.habits = [...next];
this.bumpDataVersion();
})
这条边界是合理的:页面没有直接读写 Preferences,也没有自行计算持久化结果。问题集中在服务和模型的日期表达,因此可以在不重写两个页面的前提下逐步修正。

二、先审计当前模型:三个字段混合了状态与统计
真实 HabitItem 同时保存即时状态、累计统计和月历展示数据:
export interface HabitItem {
id: string;
name: string;
icon: string;
streak: number;
total: number;
target: number;
todayDone: boolean;
checkinDays: number[];
updatedAt: number;
}
这些字段在当前实现中的含义分别是:
| 字段 | 当前写入方式 | 实际边界 |
|---|---|---|
todayDone |
首次点击后设为 true |
没有保存它属于哪一天 |
streak |
每次允许打卡时 +1 |
不检查昨天是否打卡 |
total |
每次允许打卡时 +1 |
依赖 todayDone 阻止重复 |
checkinDays |
保存当月"几号" | 不能区分月份和年份 |
updatedAt |
修改或打卡时更新时间 | 可用于合并冲突,但不是打卡日期 |
问题不在于字段多,而在于没有一个字段能回答"最近一次打卡发生在哪个日历日"。只要应用在午夜之后仍读到 todayDone=true,第二天就无法打卡;即使手动重置它,checkinDays.includes(5) 也会把任何月份的 5 日视为同一天。
因此,日期切换不是给 todayDone 加一个定时器就够了。必须先为打卡记录建立不会跨月冲突的身份。
三、当前 checkIn 为什么不能代表"连续"
真实服务使用 getDate() 取得日号,并在 todayDone 为假时同时增加连续和累计次数:
async checkIn(id: string): Promise<HabitItem[]> {
const habits = await this.list();
const today = new Date().getDate();
const next = habits.map((habit: HabitItem) => {
if (habit.id !== id || habit.todayDone) return habit;
const days = habit.checkinDays.includes(today)
? habit.checkinDays
: [...habit.checkinDays, today];
return {
...habit,
streak: habit.streak + 1,
total: habit.total + 1,
todayDone: true,
checkinDays: days,
updatedAt: Date.now()
};
});
await this.persist(next);
return next;
}
假设用户周一打卡,周二没有打开应用,周三再次进入。如果 todayDone 被某处重置,周三点击后 streak 仍从 1 加到 2,实际却已经断签。反过来,如果没有重置,周三连按钮都不会生效。
再看跨月:1 月 5 日写入数字 5,2 月 5 日执行 includes(5) 会命中旧值,热力图无法区分这两次记录。即使 total 继续增加,月历仍只拥有一个格子身份。
所以"连续天数"应是日期集合的派生值,而不是按钮点击次数的别名。数据层保存事实,统计层从事实计算结果,才能在补签、撤销或数据迁移后重新得到正确答案。
四、用本地日期键替代日号,避免 UTC 偏移
习惯打卡通常按用户所在地的自然日计算。直接使用 toISOString().slice(0, 10) 会先转成 UTC,在东八区午夜到上午 8 点之间可能得到前一天。更稳的做法是从本地年月日构造固定宽度的日期键。
export function toLocalDateKey(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
export function startOfLocalDay(date: Date): number {
return new Date(
date.getFullYear(),
date.getMonth(),
date.getDate()
).getTime();
}
2026-07-05 同时包含年份、月份和日期,可以作为集合成员、映射键和日志参数。固定宽度还让字符串字典序与日期先后顺序一致,便于排序。
需要区分两个概念:
Date.now()是绝对时间戳,适合updatedAt和冲突比较。yyyy-MM-dd是用户当前时区下的日历日,适合"今天是否打卡"和月历格子。
当用户跨时区旅行时,产品还要明确规则:跟随设备当前时区,还是锁定习惯创建时区。轻量本地应用通常跟随设备当前时区,但必须在日期变化时重新派生今日状态。
五、模型升级:保存记录事实,减少重复状态
一种兼容成本较低的 V2 模型,是把 checkinDays: number[] 升级为完整日期键数组,并增加最近打卡日期:
export interface HabitItemV2 {
id: string;
name: string;
icon: string;
target: number;
checkinDateKeys: string[];
lastCheckinDateKey?: string;
updatedAt: number;
}
此时以下字段可以按需派生:
export function isDoneOn(
habit: HabitItemV2,
date: Date
): boolean {
return habit.checkinDateKeys.includes(toLocalDateKey(date));
}
export function getTotal(habit: HabitItemV2): number {
return new Set(habit.checkinDateKeys).size;
}
todayDone 不再需要永久保存,因为它等于"日期集合是否包含今天"。total 也可以由去重后的集合长度得到。是否继续缓存 streak 要看数据量:记录只有几十或几百天时,读取时计算足够快;若要长期保存大量记录,可以缓存统计值,但必须让它能够从事实集合重新构建。

这个模型也为撤销打卡和补签留下了自然入口:向集合增加或删除某个日期,再重新计算统计即可。相比修改多个计数字段,它更不容易产生"总次数减了,连续天数没变"的不一致。
六、连续天数算法:从今天或最近一次打卡向前走
"当前连续"通常有两种产品定义:
- 严格定义:今天未打卡时,当前连续为 0。
- 宽限定义:今天未打卡但昨天打卡时,仍显示截至昨天的连续天数。
习惯应用更常采用宽限定义,因为用户上午打开应用时还没有完成今日任务,不应立即把昨日连续显示为 0。可以先确定起点,再逐日向前检查:
export function calculateCurrentStreak(
dateKeys: string[],
now: Date
): number {
const keys = new Set<string>(dateKeys);
const cursor = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate()
);
if (!keys.has(toLocalDateKey(cursor))) {
cursor.setDate(cursor.getDate() - 1);
}
let streak = 0;
while (keys.has(toLocalDateKey(cursor))) {
streak += 1;
cursor.setDate(cursor.getDate() - 1);
}
return streak;
}
这里使用 setDate(getDate() - 1),由本地日历处理月末和年末变化,而不是减去固定的 24 * 60 * 60 * 1000。固定毫秒在夏令时地区可能跨到错误的本地小时;以日历字段移动更符合"自然日"的业务定义。
最长连续天数则适合先去重、排序,再比较相邻日期是否相差一个日历日。它和当前连续不是同一个指标,页面上的"最长连续"如果取当前 streak 最大值,就无法保留历史最佳记录。
七、打卡命令:幂等、可判断,不靠按钮禁用兜底
页面把按钮显示为"今日已打卡"只是交互反馈,真正的重复保护必须在服务层。否则快速连点、两个页面同时操作或恢复旧页面状态都可能重复提交。
export type CheckInResult =
'created' | 'already_done' | 'habit_not_found';
async checkIn(
id: string,
date: Date = new Date()
): Promise<CheckInResult> {
const habits = await this.listV2();
const index = habits.findIndex((item) => item.id === id);
if (index < 0) return 'habit_not_found';
const key = toLocalDateKey(date);
const habit = habits[index];
if (habit.checkinDateKeys.includes(key)) {
return 'already_done';
}
const keys = [...habit.checkinDateKeys, key].sort();
habits[index] = {
...habit,
checkinDateKeys: keys,
lastCheckinDateKey: key,
updatedAt: Date.now()
};
await this.persistV2(habits);
return 'created';
}
服务显式返回结果后,页面可以分别显示"打卡成功""今天已经打过卡"或"习惯已被删除"。相比始终返回整个数组,命令结果更清晰;页面若需要最新列表,再调用一次查询,或让结果同时携带不可变的新列表。
还要注意并发写入。当前 HabitService 每次都是"读完整数组、修改、写完整数组",两个并发请求可能后写覆盖先写。单用户低频点击时风险较小;若同时支持卡片、服务扩展或多设备同步,应增加串行写入队列、版本比较或迁移到 RDB 事务。
八、补签规则要先写成策略,再写按钮
补签并不是普通的 checkIn(id, date)。它会修改历史事实,必须明确允许范围和统计影响。一个可审核的本地策略可以是:
| 条件 | 是否允许 | 原因 |
|---|---|---|
| 今天 | 允许 | 等同普通打卡 |
| 过去 7 天内 | 允许 | 提供有限纠错窗口 |
| 未来日期 | 禁止 | 不能记录尚未发生的行为 |
| 已存在日期 | 幂等返回 | 防止重复累计 |
| 超过窗口 | 禁止 | 避免任意改写历史 |
服务可以把校验单独提取:
export function validateMakeupDate(
target: Date,
now: Date,
maxPastDays: number
): boolean {
const today = startOfLocalDay(now);
const targetDay = startOfLocalDay(target);
if (targetDay > today) return false;
const diff = Math.round(
(today - targetDay) / (24 * 60 * 60 * 1000)
);
return diff <= maxPastDays;
}
若目标用户所在地区涉及夏令时,天数差仍应改为逐日比较或使用日期序号,避免固定毫秒误差。对于中国大陆本地发布,固定 24 小时通常可用,但边界函数仍应有时区用例。
补签成功后不要简单执行 streak + 1。它可能连接原本分开的两段记录,使当前连续和历史最长连续同时变化;也可能只是补上很久以前的一天,对当前连续没有影响。正确做法是更新日期集合后重新计算统计。
九、日期切换:页面重新出现时派生,不依赖常驻定时器
HarmonyOS 应用可能进入后台、被冻结或被系统回收,常驻 setInterval 不是可靠的跨零点方案。更稳的方式是在页面 aboutToAppear()、窗口重新获焦或应用回到前台时读取当前日期键,与上次渲染日期比较。
@State currentDateKey: string =
toLocalDateKey(new Date());
aboutToAppear(): void {
this.refreshForCurrentDay();
}
private async refreshForCurrentDay(): Promise<void> {
const nextKey = toLocalDateKey(new Date());
if (nextKey !== this.currentDateKey) {
this.currentDateKey = nextKey;
}
this.habits = await this.service.listV2();
}
在 V2 模型中,跨日不需要批量把每条记录的 todayDone 写回 false。页面只用新的 currentDateKey 计算 isDoneOn(),状态会自然切换。这样避免零点时对整个数组进行一次无业务事实变化的写入。
若应用在前台跨过零点且用户一直停留在页面,可以在可见生命周期内安排一个到下一个本地零点的单次定时任务;页面不可见时取消,重新出现时再次校准。定时器只是改善前台即时刷新,不应成为数据正确性的唯一来源。
十、旧数据迁移:日号无法还原月份,必须承认信息缺失
当前 normalizeHabit() 会为缺失字段补默认值:
export function normalizeHabit(raw: HabitItem): HabitItem {
const now = Date.now();
return {
id: raw.id,
name: raw.name,
icon: raw.icon || '⭐',
streak: raw.streak ?? 0,
total: raw.total ?? 0,
target: raw.target ?? 21,
todayDone: raw.todayDone ?? false,
checkinDays: raw.checkinDays ?? [],
updatedAt: raw.updatedAt ?? now
};
}
升级到完整日期键时,旧 checkinDays: [3, 8, 15] 已经丢失年月,无法可靠恢复成真实历史日期。迁移代码不能凭空声称这些数字属于某个月。可选策略有三种:
- 保守迁移:保留
total和展示统计,不迁移无法确定的历史格子。 - 推断迁移:仅当版本元数据明确记录了月份时,组合成日期键。
- 用户确认:提示旧版本记录只能按当前月导入,由用户选择是否接受。
建议给存储增加版本包裹:
interface HabitStorePayloadV2 {
schemaVersion: 2;
habits: HabitItemV2[];
migratedAt: number;
}
读取时先识别结构,迁移成功后一次性写入 V2,并保留可回滚的旧键或备份副本。不要在每次 list() 时反复猜测和迁移,因为当前服务还会把合并结果写回两个键,错误推断可能迅速覆盖原始信息。
十一、双存储键合并:updatedAt 解决冲突,但写入要有边界
真实 list() 同时读取 HABITS 与 HABIT_WALL,按 ID 合并,冲突时保留 updatedAt 较新的项:
private merge(
primary: HabitItem[],
secondary: HabitItem[]
): HabitItem[] {
const map = new Map<string, HabitItem>();
primary.forEach((item) =>
map.set(item.id, normalizeHabit(item))
);
secondary.forEach((item) => {
const normalized = normalizeHabit(item);
const existing = map.get(normalized.id);
if (!existing ||
normalized.updatedAt > existing.updatedAt) {
map.set(normalized.id, normalized);
}
});
return Array.from(map.values());
}
这能兼容两个历史页面曾各自保存数据的情况。随后 persist() 把统一结果写回两个键,完成渐进收敛。
但 list() 是查询方法,却包含写入副作用:每次打开页面都会 flush 两次 Preferences。更稳的实现可以比较合并前后的内容,只在确实发生迁移或冲突修复时回写;完成版本迁移后,再让两个页面只使用一个主键。
另外,updatedAt 相同但内容不同时,当前规则偏向主键内容。这个行为应写入测试,或增加确定性的冲突规则,避免不同运行顺序产生不同结果。
十二、验证矩阵:不要只在下午连续点两次
日期功能的错误往往藏在时间边界。至少应覆盖以下用例:
- 同一天第一次打卡成功,第二次返回
already_done,总次数不变。 - 昨天打卡、今天未打卡时,宽限定义仍显示截至昨天的连续。
- 前天打卡、昨天断签、今天打卡,当前连续应为 1。
- 1 月 5 日和 2 月 5 日生成不同日期键,两个热力图分别显示。
- 12 月 31 日后移动一天得到下一年 1 月 1 日。
- 应用在后台跨零点,重新进入后按钮恢复为可打卡。
- 前台停留跨零点,单次定时刷新后状态切换。
- 补签未来日期被拒绝,重复补签保持幂等。
- 补签连接两段记录后,当前连续和最长连续重新计算。
- 删除习惯后,从另一个页面重新读取不再出现。
- 两个旧存储键同 ID 不同更新时间,保留较新记录。
- V1 数据迁移后重启应用,迁移不重复执行。
- 手动调整系统日期后,页面重新出现会按新日期键派生状态。
- 深浅色和不同窗口尺寸下,热力格、日期标题和补签入口保持可读。
测试时应允许向服务注入 Date 或 Clock,不要让所有用例依赖真实系统时间。可控时钟能够稳定复现月末、年末和跨零点场景。
十三、常见故障与定位
| 现象 | 真实原因 | 优先修复 |
|---|---|---|
| 第二天按钮仍是"今日已打卡" | todayDone 没有日期归属 |
改为按日期键派生 |
| 下个月同一日自动点亮 | 只保存 getDate() |
保存完整本地日期键 |
| 隔一天再打卡仍增加连续 | streak 无条件 +1 |
从日期集合重算 |
| 补签后连续数不正确 | 把补签当普通点击累计 | 更新事实后重算统计 |
| 两个页面数据偶尔不同 | 双键写入或页面持有旧数组 | 收敛主键并重新查询 |
| 打开页面就频繁写盘 | list() 总是调用 persist() |
仅迁移或冲突时写入 |
| 清理后台后日期状态才正确 | 依赖定时器或内存布尔值 | 生命周期恢复时重新派生 |
| 午夜附近日期提前或延后 | 使用 UTC 日期字符串 | 用本地年月日构造键 |
排查顺序应该是:先打印当前本地日期键,再检查日期集合是否包含它,然后计算连续值,最后看页面绑定。若一开始就只盯按钮颜色,很容易把模型错误误判为 ArkUI 刷新问题。
总结
习惯打卡不是"把计数器加一",而是对日历事实进行记录,再从事实推导今日状态、累计次数和连续天数。时光清单 的真实工程已经用 HabitService 统一了两个页面,并通过 updatedAt 合并历史双键数据;下一步需要修正的是日期模型本身。
可靠的边界可以概括为:本地日期键唯一标识一个自然日,重复打卡必须幂等,连续天数由有序日期集合计算,补签只能在明确窗口内修改过去,日期切换要在生命周期恢复时重新派生,旧数据无法还原的信息必须如实处理。做到这些,月末、年末、跨零点和补签才不再是特殊补丁,而是同一套日期规则的自然结果。
AI 辅助声明:本文由 AI 辅助整理,当前实现分析基于
D:\huawei\one8中HabitService.ets、Habit.ets、HabitView.ets与HabitWallView.ets的真实源码复核;V2 模型和代码均为针对现有边界的工程改进建议,需经过项目测试后落地。