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

HarmonyOS ArkTS 习惯打卡实战:处理连续天数、补签和日期切换

习惯打卡最容易制造一种"当天看起来完全正确"的错觉:点一下按钮,今日状态变绿,连续天数加一,月历上的格子也亮了。可是一旦跨过零点、切到下个月,或者用户希望补签昨天,原本简单的 todayDone: boolean 和"日期数字数组"就不够用了。

时光清单 的真实源码已经完成了两套打卡页面的数据统一:HabitViewHabitWallView 都调用 HabitService,服务会合并 HABITSHABIT_WALL 两个历史存储键,并把结果回写到两边。但源码中的打卡记录只保存 new Date().getDate(),也就是 1 到 31;连续天数则在每次首次点击时直接加一。这种模型能支撑当月演示,却无法区分 1 月 5 日和 2 月 5 日,也没有跨日重置、断签重算和补签边界。

本文不把这些缺口包装成现成功能,而是从真实代码出发,先复核当前链路,再设计一套适用于 HarmonyOS 5.0 及以上版本的可迁移方案:使用本地日期键表达日历日,用打卡集合推导连续天数,用显式策略控制补签,并在应用重新回到前台时完成日期切换。

本文重点解决:

  1. 解释当前 todayDonestreakcheckinDays 的真实语义和失效边界。
  2. 将"日号"升级为 yyyy-MM-dd 本地日期键,避免跨月、跨年冲突。
  3. 从打卡记录推导当前连续天数,而不是无条件累加。
  4. 为补签定义过去日期、未来日期、重复提交和连续天数重算规则。
  5. 设计兼容旧 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 同时包含年份、月份和日期,可以作为集合成员、映射键和日志参数。固定宽度还让字符串字典序与日期先后顺序一致,便于排序。

需要区分两个概念:

  1. Date.now() 是绝对时间戳,适合 updatedAt 和冲突比较。
  2. 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 要看数据量:记录只有几十或几百天时,读取时计算足够快;若要长期保存大量记录,可以缓存统计值,但必须让它能够从事实集合重新构建。

这个模型也为撤销打卡和补签留下了自然入口:向集合增加或删除某个日期,再重新计算统计即可。相比修改多个计数字段,它更不容易产生"总次数减了,连续天数没变"的不一致。

六、连续天数算法:从今天或最近一次打卡向前走

"当前连续"通常有两种产品定义:

  1. 严格定义:今天未打卡时,当前连续为 0。
  2. 宽限定义:今天未打卡但昨天打卡时,仍显示截至昨天的连续天数。

习惯应用更常采用宽限定义,因为用户上午打开应用时还没有完成今日任务,不应立即把昨日连续显示为 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] 已经丢失年月,无法可靠恢复成真实历史日期。迁移代码不能凭空声称这些数字属于某个月。可选策略有三种:

  1. 保守迁移:保留 total 和展示统计,不迁移无法确定的历史格子。
  2. 推断迁移:仅当版本元数据明确记录了月份时,组合成日期键。
  3. 用户确认:提示旧版本记录只能按当前月导入,由用户选择是否接受。

建议给存储增加版本包裹:

复制代码
interface HabitStorePayloadV2 {
  schemaVersion: 2;
  habits: HabitItemV2[];
  migratedAt: number;
}

读取时先识别结构,迁移成功后一次性写入 V2,并保留可回滚的旧键或备份副本。不要在每次 list() 时反复猜测和迁移,因为当前服务还会把合并结果写回两个键,错误推断可能迅速覆盖原始信息。

十一、双存储键合并:updatedAt 解决冲突,但写入要有边界

真实 list() 同时读取 HABITSHABIT_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 相同但内容不同时,当前规则偏向主键内容。这个行为应写入测试,或增加确定性的冲突规则,避免不同运行顺序产生不同结果。

十二、验证矩阵:不要只在下午连续点两次

日期功能的错误往往藏在时间边界。至少应覆盖以下用例:

  1. 同一天第一次打卡成功,第二次返回 already_done,总次数不变。
  2. 昨天打卡、今天未打卡时,宽限定义仍显示截至昨天的连续。
  3. 前天打卡、昨天断签、今天打卡,当前连续应为 1。
  4. 1 月 5 日和 2 月 5 日生成不同日期键,两个热力图分别显示。
  5. 12 月 31 日后移动一天得到下一年 1 月 1 日。
  6. 应用在后台跨零点,重新进入后按钮恢复为可打卡。
  7. 前台停留跨零点,单次定时刷新后状态切换。
  8. 补签未来日期被拒绝,重复补签保持幂等。
  9. 补签连接两段记录后,当前连续和最长连续重新计算。
  10. 删除习惯后,从另一个页面重新读取不再出现。
  11. 两个旧存储键同 ID 不同更新时间,保留较新记录。
  12. V1 数据迁移后重启应用,迁移不重复执行。
  13. 手动调整系统日期后,页面重新出现会按新日期键派生状态。
  14. 深浅色和不同窗口尺寸下,热力格、日期标题和补签入口保持可读。

测试时应允许向服务注入 DateClock,不要让所有用例依赖真实系统时间。可控时钟能够稳定复现月末、年末和跨零点场景。

十三、常见故障与定位

现象 真实原因 优先修复
第二天按钮仍是"今日已打卡" todayDone 没有日期归属 改为按日期键派生
下个月同一日自动点亮 只保存 getDate() 保存完整本地日期键
隔一天再打卡仍增加连续 streak 无条件 +1 从日期集合重算
补签后连续数不正确 把补签当普通点击累计 更新事实后重算统计
两个页面数据偶尔不同 双键写入或页面持有旧数组 收敛主键并重新查询
打开页面就频繁写盘 list() 总是调用 persist() 仅迁移或冲突时写入
清理后台后日期状态才正确 依赖定时器或内存布尔值 生命周期恢复时重新派生
午夜附近日期提前或延后 使用 UTC 日期字符串 用本地年月日构造键

排查顺序应该是:先打印当前本地日期键,再检查日期集合是否包含它,然后计算连续值,最后看页面绑定。若一开始就只盯按钮颜色,很容易把模型错误误判为 ArkUI 刷新问题。

总结

习惯打卡不是"把计数器加一",而是对日历事实进行记录,再从事实推导今日状态、累计次数和连续天数。时光清单 的真实工程已经用 HabitService 统一了两个页面,并通过 updatedAt 合并历史双键数据;下一步需要修正的是日期模型本身。

可靠的边界可以概括为:本地日期键唯一标识一个自然日,重复打卡必须幂等,连续天数由有序日期集合计算,补签只能在明确窗口内修改过去,日期切换要在生命周期恢复时重新派生,旧数据无法还原的信息必须如实处理。做到这些,月末、年末、跨零点和补签才不再是特殊补丁,而是同一套日期规则的自然结果。

AI 辅助声明:本文由 AI 辅助整理,当前实现分析基于 D:\huawei\one8HabitService.etsHabit.etsHabitView.etsHabitWallView.ets 的真实源码复核;V2 模型和代码均为针对现有边界的工程改进建议,需经过项目测试后落地。

相关推荐
大锅盖13 小时前
围绕夜航深蓝与霓虹青的跨境画卷构建 HarmonyOS ArkUI 出境旅行向导平台
华为·harmonyos
骐骥111 小时前
学习:使用postMessage()建立应用侧与前端页面的数据通道
harmonyos·鸿蒙·jsbridge·postmessage·数据通道
HwJack2018 小时前
鸿蒙开发ArkData 全景与选型决策:三种存储到底用哪个
华为·harmonyos
czhm5719 小时前
基于 HarmonyOS ArkTS API 24,工具函数抽离业务计算逻辑,状态颜色判断解耦组件代码
harmonyos·arkts
lilian23320 小时前
HarmonyOS 7 新特性(九)|碰一碰精准分享与互动卡片
华为·harmonyos
lilian23320 小时前
HarmonyOS 7 新特性(十)|DevEco Code 与 DevEco CLI AI 开发提效
人工智能·华为·harmonyos
袁震21 小时前
HarmonyOS 应用包体积优化与上架自检实战:从 76.2MB 到 3.6MB
java·华为·性能优化·harmonyos
2501_9197490321 小时前
华为鸿蒙免费美食APP—小羊美食
华为·harmonyos·美食
czhm571 天前
HarmonyOS 6.1 实战:长列表 ForEach 性能优化,大数据量列表避免不必要的组件重渲染
华为·harmonyos