专注计时器最容易让人安心的时刻,是屏幕上那枚安静的绿色圆环准确走到零;最容易让人焦虑的时刻,却往往发生在更新应用之后:上周的完成记录没有了,原先的八次目标变回默认值,或者历史列表出现了两次同一条会话。FocusGarden 已经实现截止时刻倒计时、前后台恢复、历史去重、七日统计和周目标配置。最后一篇选择一个比动画更值得做的收尾主题:当本地数据结构需要升级,怎样让旧记录能被理解、异常能被发现、恢复操作不破坏现有数据。

FocusGarden 数据升级与恢复链路的技术封面

本篇没有改动既有数据事实。focusgarden_timer_v2 仍是 Preferences 文件,历史记录保存在 session_bundle_v3,每条 SessionEntry 依靠稳定的 id 去重,包含 startedAtMs、endedAtMs、plannedSec、label;第五篇新增 weekly_goal_v1,其中 weeklyTarget 是每周滚动七天的完成次数目标,范围 1~30。统计依旧按完成时刻所属的本地日历日计算,plannedSec 是计划时长而非传感器采集的实际投入时长。不能通过“迁移”悄悄改写这些语义,否则旧统计和新统计会无法对账。

一、数据升级的第一件事,是停止猜测旧格式

很多小应用习惯在启动时直接把 JSON 反序列化成最新接口。升级前只有 count,升级后需要 entries,一旦读取失败就给空数组;用户看见的便是“历史记录 0 条”,而不是“格式需要迁移”。如果接着把空数组写回,原始内容还可能被覆盖。正确做法是先读取原始字符串,按明确的 schema 区分能识别的版本,再逐字段检查。解析器不负责任何写入;它要么产生经过验证的对象,要么报告错误并保留原始内容。

本文定义一个只处理新格式快照的 RecoveryBundle。它与既有 session_bundle_v3 的外部封装不同,目的是作为恢复前检查和单机备份的新容器;升级时仍从现有会话键加载 SessionEntry,而不是假装旧数据原本就包含下列字段。专注历史中的 id 要求非空,结束时刻不早于开始时刻,计划秒数为正整数,周目标需要复用上一期 validateGoal()。任何一项不满足时都应停下,不要只过滤“坏记录”,然后把其余内容冒充完整历史。

// entry/src/main/ets/common/RecoveryBundle.ets
import { SessionEntry } from './SessionModel';
import { validateGoal } from './WeeklyGoal';

export interface RecoveryBundle {
  schema: number;
  exportedAtMs: number;
  sessions: SessionEntry[];
  weeklyTarget: number;
}

export function validateBundle(value: RecoveryBundle): RecoveryBundle {
  if (value.schema !== 1 || !Array.isArray(value.sessions) ||
      !Number.isFinite(value.exportedAtMs)) {
    throw new Error('快照结构或版本不受支持');
  }
  validateGoal(value.weeklyTarget);
  const seen: Set<string> = new Set<string>();
  for (const item of value.sessions) {
    if (typeof item.id !== 'string' || item.id.length === 0 ||
        seen.has(item.id) || !Number.isFinite(item.startedAtMs) ||
        !Number.isFinite(item.endedAtMs) ||
        item.endedAtMs < item.startedAtMs ||
        !Number.isInteger(item.plannedSec) || item.plannedSec <= 0 ||
        typeof item.label !== 'string') {
      throw new Error('发现重复或损坏的专注会话');
    }
    seen.add(item.id);
  }
  return value;
}

这样校验不承诺证明每次专注都是真实发生的,它只保证结构与基本时间关系能用于后续统计。对于已有的 session_bundle_v3,应复用第三篇的历史解析器加载真实数组;如果那一层也失败,就提示“历史需要核查”并停止迁移,而不是跨过原格式校验直接强转。此前历史上限约定为100条,恢复时同样应该遵守这一业务限制并提示超量,不能靠 slice(0, 100) 静默丢失最旧记录。

二、备份要早于替换,恢复不能覆盖还没核对的数据

对于示例规模的数据,Preferences 可以保存一个额外的 recovery_snapshot_v1 JSON 键。它适合提供“本次修改前的一份本机快照”,但本机同一文件里的备份并不等于异机备份:卸载应用、文件损坏或设备丢失时,两份内容可能同时失效。正式产品需要另外设计用户授权的导出、设备间迁移、加密与访问控制,不能把新键命名为“云备份”就误导用户。

备份要在决定替换旧会话之前进行,并核对被读取的完整内容。RecoveryStore 接收已经在页面初始化完成后取得的 Preferences 实例,不负责新建多进程存储,也不暗中请求外部权限。先保存一份候选快照,待 flush() 成功后才报告“本机快照已保存”。对于 Preferences 的 XML 存储,官方说明 put() 后应执行 flush();若选择了不同的存储类型,需按对应文档确认落盘语义。

// entry/src/main/ets/common/RecoveryStore.ets
import { preferences } from '@kit.ArkData';
import { RecoveryBundle, validateBundle } from './RecoveryBundle';

export class RecoveryStore {
  constructor(private store: preferences.Preferences) {}

  async saveSnapshot(bundle: RecoveryBundle): Promise<void> {
    validateBundle(bundle);
    await this.store.put('recovery_snapshot_v1', JSON.stringify(bundle));
    await this.store.flush();
  }

  async loadSnapshot(): Promise<RecoveryBundle> {
    const raw = await this.store.get('recovery_snapshot_v1', '');
    if (typeof raw !== 'string' || raw.length === 0) {
      throw new Error('尚无可读取的本机快照');
    }
    return validateBundle(JSON.parse(raw) as RecoveryBundle);
  }
}

要额外考虑 put() 已修改缓存而 flush() 报错的窗口:这时不能说磁盘内容一定保留原值,也不能说备份成功。界面必须进入“保存状态待核查”,由恢复流程在可控条件下重新建立一致快照。用一个异常消息给用户提供确定的下一步,比直接从 catch 中再次 put() 更安全。Preferences 的 官方接口说明 提供 getPreferences()、put()、flush() 等 API,同时指出不应将其作为多进程并发修改同一文件的事务设施。

三、恢复时先预览影响,再允许用户按下确认

真正值得设计的不是“恢复”两个字,而是按下按钮之前的差异预览。页面应该显示现有会话数、快照会话数、重复或无效记录数、当前周目标以及拟恢复的周目标。上一次的周目标只是用户偏好,不能因为导入一份旧快照就悄悄覆盖用户刚设定的新目标。可以提供单独的确认开关,但默认先展示信息,不在页面初始化时自动覆盖任何既有键。

恢复流程采用“校验快照 → 展示差异 → 用户再次确认 → 备份当前数据 → 替换会话键和目标键 → 重新计算统计”顺序。需要注意,写两个 Preferences 键不等于数据库跨键事务。若会话写入成功而周目标写入失败,内存状态和磁盘状态可能存在半完成恢复。实际实现中应增加迁移阶段标记,在重启时检测未完成迁移并进入人工核查;数据量继续增长时,应该使用具备事务特性的本地数据库实现完整数据替换。

// entry/src/main/ets/common/RecoveryPreview.ets
import { RecoveryBundle, validateBundle } from './RecoveryBundle';

export interface RecoveryPreview {
  currentCount: number;
  backupCount: number;
  currentGoal: number;
  backupGoal: number;
  changed: boolean;
}

export function compareRecovery(current: RecoveryBundle,
  backup: RecoveryBundle): RecoveryPreview {
  validateBundle(current);
  validateBundle(backup);
  return {
    currentCount: current.sessions.length,
    backupCount: backup.sessions.length,
    currentGoal: current.weeklyTarget,
    backupGoal: backup.weeklyTarget,
    changed: JSON.stringify(current.sessions) !== JSON.stringify(backup.sessions) ||
      current.weeklyTarget !== backup.weeklyTarget
  };
}

JSON.stringify() 在这里用于说明同一数据形态下的差异提示,不是安全哈希,也不适合大型数据的精确语义对账。实际系统应使用 id 逐条比较,列明新增、缺失与冲突的会话,并使用明确的迁移版本管理。不能把两套不同顺序但内容相同的数组误认为业务上必然发生了记录冲突。用户确认恢复之前,还应明确说明“本机会话可能被当前快照替换”,并允许取消返回;页面不该因为预览刷新就修改存储。

FocusGarden 恢复中心的 9:16 竖屏示例页面

四、恢复完成后,统计和页面必须从同一源重新计算

最容易忽略的一步是恢复后刷新统计。如果历史列表已替换,而“本周目标”的进度卡仍缓存旧 completedCount,用户就会看到数字互相矛盾。FocusGarden 的统计本来就应该由 SessionEntry[] 派生:第04篇的 DailySummary 以本地结束日归组,第05篇的 countRollingDays() 按过去七个本地自然日统计,再与 weeklyTarget 合并。恢复成功后只重新调用这些纯计算函数,不应该另存一份“恢复后的统计总数”。

另一个细节是跨日期边界。日历日按设备的本地时区解释;如果用户旅行后切换时区,同一个 endedAtMs 可能显示在另一日。恢复程序不能偷偷把毫秒时刻改成当地午夜来“修正图表”。它应保留绝对时间戳,让展示层根据当前时区重新派生日期键,并在需要严格审计时由业务显式保存原始时区信息。

// entry/src/main/ets/pages/RecoveryPage.ets(局部)
@State private previewText: string = '尚未读取本机快照';
@State private restoreEnabled: boolean = false;
@State private applying: boolean = false;

@Builder RecoveryCard() {
  Column({ space: 12 }) {
    Text('本机恢复中心').fontSize(25).fontWeight(FontWeight.Bold)
    Text(this.previewText).fontSize(16)
    Button(this.applying ? '处理中' : '查看恢复差异')
      .enabled(!this.applying)
      .onClick(() => { this.loadRecoveryPreview(); })
    Button('确认使用此快照')
      .enabled(this.restoreEnabled && !this.applying)
      .onClick(() => { this.applyConfirmedRecovery(); })
  }
  .width('100%').padding(20).borderRadius(20)
  .backgroundColor('#FFFFFF')
}

其中 loadRecoveryPreview() 应先读取现有会话与快照并调用 compareRecovery(),再在屏幕上显示明确的数量差异;applyConfirmedRecovery() 必须完成二次确认、写前备份和跨键失败处理后才允许改变页面可见的历史数据。这里把入口组件与多步数据写入分开,避免在一个 onClick() 里混合校验、覆盖、统计和用户反馈。真实 UI 还应加入更明确的“取消”入口、错误详情与不可恢复操作提醒。

从旧键读取、完整校验、写前备份到恢复后重新派生统计的流程

五、用一组小数据做收尾验收

建议准备五条会话,标识分别为 s01 到 s05,其中一条跨越午夜;周目标设置为8。先核对七日统计与记录列表,再保存本机快照;随后新增一条 s06 并将目标改为6,打开恢复预览,应看见“现有6条 / 快照5条”和“当前目标6次 / 快照目标8次”,而不是立即丢掉新增记录。用户取消时任何数据都不改变;确认后再核对历史条目和目标,以及本地日历日归组是否按原 endedAtMs 重新生成。

继续测试重复 id、结束时间早于开始时间、非法 JSON、未知 schema、目标超出范围和 flush() 失败。任何失败都不应在未再次确认的情况下自动覆盖正常数据。把这些场景做成一个小型测试清单,可以比毫无依据的“成功率100%”更清楚地说明设计是否扎实。由于示例没有实际编译、设备运行和外部服务器连接记录,性能与持久化效果必须在目标版本的 DevEco 工程中按测试步骤另行验证。

FocusGarden 六篇连载的主线也在这里闭合:截止时刻处理倒计时漂移,生命周期负责前后台恢复,完成会话形成可去重历史,七日统计从事实数据派生,周目标与统计口径相互独立,最后通过版本校验和恢复预览守住长期使用中的数据。开发完成不是拥有最多页面,而是升级之后仍知道每一个数字从哪里来、哪些内容可能被替换、出现异常时如何保住用户的原始记录。这比给圆环再添一圈动画更有工程价值。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐