YAOTU INSIGHTS

【时光清单|02】HarmonyOS ArkTS 习惯打卡实战:处理连续天数、补签和日期切换

【时光清单|02】HarmonyOS ArkTS 习惯打卡实战:处理连续天数、补签和日期切换
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修改或打卡时更新时间可用于合并冲突但不是打卡日期问题不在于字段多而在于没有一个字段能回答“最近一次打卡发生在哪个日历日”。只要应用在午夜之后仍读到todayDonetrue第二天就无法打卡即使手动重置它checkinDays.includes(5)也会把任何月份的 5 日视为同一天。因此日期切换不是给todayDone加一个定时器就够了。必须先为打卡记录建立不会跨月冲突的身份。三、当前 checkIn 为什么不能代表“连续”真实服务使用getDate()取得日号并在todayDone为假时同时增加连续和累计次数async checkIn(id: string): PromiseHabitItem[] { 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 日写入数字52 月 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 Setstring(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() ): PromiseCheckInResult { 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(): Promisevoid { 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 Mapstring, 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合并历史双键数据下一步需要修正的是日期模型本身。可靠的边界可以概括为本地日期键唯一标识一个自然日重复打卡必须幂等连续天数由有序日期集合计算补签只能在明确窗口内修改过去日期切换要在生命周期恢复时重新派生旧数据无法还原的信息必须如实处理。做到这些月末、年末、跨零点和补签才不再是特殊补丁而是同一套日期规则的自然结果。