【听见课堂 HarmonyOS NEXT 实战系列 18】写完不要手工改计数:canonical data 与写后刷新

很多 ArkUI 页面在功能刚起步时,会把“保存成功”处理成几行局部赋值:任务数加一、已完成数加一、候选数减一、首页角标改一下。这种做法在单页面 Demo 里很快,但当同一份数据同时驱动首页、课堂回顾、任务中心、历史搜索和导出功能时,任何漏改都会让多个页面对同一事实给出不同答案。

听见课堂选择把 Repository 中的课程、字幕、扫描和任务作为 canonical data。写操作成功后,页面统一调用 refreshData() 回读各类原始数据,再由 ClassroomService 重算复盘、任务中心和历史快照。本文从真实刷新链路出发,说明 canonical data 是什么、为什么不能手工补计数,以及这套模式在性能、错误处理和 ViewModel 演进上还有哪些边界。

canonical data 与写后刷新

一、canonical data 不是“再建一份全局状态”

canonical data 可以理解为业务事实的权威来源。对于当前项目,它是 Repository 持有并可回读的四类数据:

  • 当前课程 CourseSummary
  • 字幕片段 TranscriptSegment[]
  • 扫描笔记 ScanNote[]
  • 任务 TaskItem[]

复盘完成率、重点数、待办数、已过期数、历史证据条数、周历每天的任务数都不是独立事实,而是由这些基础记录派生出来的投影。

因此 canonical data 不等于再复制一份“万能 AppStorage”。如果页面、Service、数据库和全局状态各保存一份可独立修改的任务数,真相会变成四份。正确方向是让写操作落到唯一数据源,其他状态按需计算或从统一快照更新。

二、手工加一减一为什么迟早漂移

假设用户确认一条候选任务。页面可能写出:

this.candidateCount -= 1;
this.confirmedCount += 1;
this.pendingCount += 1;

表面上三个数字都对,但至少还遗漏了:

  • 候选列表要移除该任务;
  • 已确认列表要新增该任务;
  • 任务中心要按状态和截止时间重新排序;
  • 周历某一天的任务数要变化;
  • 复盘时间线要把状态从“待人工确认”改为“已人工确认”;
  • 历史记录和导出数据要读到同一结果;
  • 撤销确认时还要完整执行反向变化。

一旦后续增加“已过期”“今天”“本周”等筛选,局部补丁数量会指数增长。更稳妥的做法是只提交一次业务动作,然后重新读取事实并重算投影。

三、refreshData() 是当前页面的统一收敛点

听见课堂的 Index.ets 把主要回读放在一个异步方法中:

private async refreshData(): Promise<void> {
  this.isDataLoading = true;
  this.dataError = '';
  try {
    this.course = await this.service.getTodayCourse();
    this.transcript = await this.service.getTranscript();
    this.scanNotes = await this.service.getScanNotes();
    this.candidateTasks = await this.service.getCandidateTasks();
    this.confirmedTasks = await this.service.getConfirmedTasks();
    this.reviewSnapshot = await this.service.getReviewSnapshot();
    this.taskCenterSnapshot = await this.service.getTaskCenterSnapshot();
    this.historySnapshot = await this.service.getHistorySnapshot();
  } finally {
    this.isDataLoading = false;
  }
}

这使页面初次出现、保存课程、保存 OCR、确认任务、撤销确认、完成任务、撤销完成和删除课程都可以回到同一刷新路径。后续若调整聚合规则,不需要在每个按钮回调里重复修改计数公式。

四、原始列表和快照为什么都要回读

页面既需要原始记录,也需要 Service 生成的页面友好快照:

页面状态 来源 用途
course Repository 首页与课程编辑
transcript Repository 字幕和实时会话种子
scanNotes Repository OCR 校对与板书列表
candidateTasks Service 过滤 待人工确认列表
confirmedTasks Service 过滤 已确认任务列表
reviewSnapshot Service 聚合 完成率、掌握度、证据时间线
taskCenterSnapshot Service 聚合 状态统计、排序、周历
historySnapshot Service 聚合 按课程分组的历史证据

页面如果只刷新原始任务列表,就还要自己重复过滤、排序和计数;如果只刷新聚合快照,又可能缺少编辑页面需要的原始字段。当前实现把两类读模型都集中获取,保持页面逻辑简单。

五、候选确认:一次写入,所有投影重算

确认任务的页面流程很短:

private async confirmCandidateTask(taskId: string): Promise<void> {
  if (await this.service.confirmTask(taskId)) {
    this.lastConfirmedTaskId = taskId;
    this.selectedCandidateTaskId = '';
    this.editingCandidateTaskId = '';
    await this.refreshData();
    this.notice = '任务已确认并保存';
  }
}

页面只处理短生命周期交互态,例如当前选中项和最近确认 ID。任务是否已确认由 Repository 持久化;候选列表、已确认列表、任务中心和复盘时间线则在刷新时自然改变。

这一区分很重要:lastConfirmedTaskId 是为了支持“立即撤销”的临时 UI 线索,不是长期业务事实;真正的 confirmed 字段在 canonical data 中。

六、撤销确认不能只把候选数加回来

Repository 的 unconfirmTask() 不仅把 confirmed 设为 0,还会把 completed 设为 0。这是一个业务不变量:未确认任务不能保持“已完成”。

如果页面只执行“候选数加一、确认数减一”,就可能遗漏完成状态清理,导致任务中心统计和复盘完成率继续把它算作已完成。统一回读后,Service 会重新过滤任务集合,所有下游投影一起恢复。

这说明写后刷新不是为了少写几行代码,而是为了让隐藏在 Repository 中的业务规则能够完整反映到 UI。页面不应该猜测一次写操作到底修改了几个字段。

七、完成与撤销完成如何共享同一条刷新链

任务中心切换完成状态时,页面先根据当前快照计算目标值,再交给 Service:

const nextCompleted: boolean = !task.completed;
if (await this.service.setTaskCompleted(task.id, nextCompleted)) {
  this.lastTaskMutationId = task.id;
  this.lastTaskMutationPreviousCompleted = task.completed;
  await this.refreshData();
  this.notice = nextCompleted ? '已标记完成,可立即撤销' : '已恢复待办,可立即撤销';
}

撤销动作使用保存下来的上一个值再次写入,然后仍然调用 refreshData()。这样完成、恢复待办、撤销三条路径不会各自维护一套计数算法。

页面保存“上一次是什么”用于交互撤销,Repository 保存“现在是什么”作为业务事实。两者职责不同,不应混为一谈。

八、任务中心快照怎样从任务表重算

getTaskCenterSnapshot() 每次从 Repository 获取完整任务集合,只保留已确认项,再计算截止时间、逾期状态和页面状态:

const items: Array<TaskCenterItem> = tasks
  .filter((task: TaskItem) => task.confirmed)
  .map((task: TaskItem): TaskCenterItem => {
    const dueAtMillis: number = task.dueAtMillis > 0
      ? task.dueAtMillis
      : TaskDateResolver.resolveDueText(task.dueText, nowMillis);
    const overdue: boolean = TaskDateResolver.isOverdue(
      dueAtMillis,
      task.completed,
      nowMillis
    );
    const status: string = task.completed
      ? '已完成'
      : (overdue ? '已过期' : '待办');
    return new TaskCenterItem(/* 映射后的稳定字段 */);
  });

随后列表按“已过期、待办、已完成”等业务优先级和截止时间排序,再统计总数、待办、已完成、已过期,并生成一周日历。页面只消费快照,不重复实现日期和排序规则。

写入后由 Service 重算多页面读模型

九、复盘快照为什么不能复用任务中心的几个数字

getReviewSnapshot() 同时读取课程、字幕、扫描和任务。它的完成率、掌握度和待办数有自己的业务定义:

const confirmedTasks = tasks.filter(item => item.confirmed);
const completedTasks = confirmedTasks.filter(item => item.completed);
const keyPointCount = transcript.filter(item => item.isKeyPoint).length;

const completionPercent = confirmedTasks.length > 0
  ? Math.round(completedTasks.length * 100 / confirmedTasks.length)
  : Math.round(course.progress * 100);

const masteryPercent = transcript.length > 0
  ? Math.round(keyPointCount * 100 / transcript.length)
  : 0;

任务中心关心当前任务执行状态,复盘页还关心字幕重点、扫描数量和时间线证据。两个快照共享 canonical data,却不强行共享一个“万能统计对象”。这能让不同页面拥有清晰、可测试的读模型。

十、历史快照把多种证据重新组装成课程时间线

历史页从同一批课程、字幕、扫描和任务中创建 HistoryEvidenceItem,按时间排序后再组成课程分组。课程被删除时,getTodayCourse() 返回空 ID,历史快照直接返回零课程、零证据;字幕文本修改后,历史条目也会在刷新时获得新正文。

如果历史页维护自己的独立缓存,保存 OCR 后就必须额外广播“某条扫描正文已变化”;删除课程又要广播“清空某课程所有历史项”。当前统一回读减少了这种跨页面同步协议。

未来当历史记录规模变大时,可以为历史页建立专用查询或读模型缓存,但缓存失效规则仍应围绕 canonical data 的版本或变更事件设计,而不是让缓存成为第二个可写事实源。

十一、加载态和错误态也要集中处理

refreshData() 在开始时设置 isDataLoading=true,清空旧错误;读取失败时记录非敏感日志,并给页面统一的“课堂数据暂时不可用,请稍后重试”;最终在 finally 中关闭加载态。

集中处理有三个好处:

  • 任意写操作后的刷新失败,都不会永久停留在加载中;
  • 页面可以保留明确的重试入口;
  • 不同 Tab 不会分别拼接数据库异常文案。

但当前实现会顺序执行多次读取。如果前几个成功、后一个失败,页面对象可能已经部分更新。更严格的做法是先把所有结果读入局部变量,全部成功后一次性赋给页面状态,或由 ViewModel 返回一个不可变总快照。

十二、统一刷新不等于每次都要暴力全量查询

canonical data 模式强调唯一事实源,不要求永远把所有表全量读取。当前项目数据量小、页面集中在单个 Index.ets,全量刷新简单可靠;长课堂或多课程场景则要评估读取成本。

可选优化包括:

  • Repository 提供一次性 getClassroomSnapshot(),在一致读取窗口中返回相关基础数据;
  • Service 基于同一份任务数组同时生成候选、已确认和任务中心快照;
  • 为字幕和历史记录增加分页;
  • 使用单调递增的数据版本号,未变化的读模型跳过重建;
  • 对高频实时字幕采用增量显示,最终提交后再做完整回读。

优化的前提是保留一致性契约。不要为了减少一次查询,让页面重新承担跨表统计和缓存失效逻辑。

十三、并发刷新要防止旧结果覆盖新结果

如果用户快速连续操作,可能出现两个 refreshData() 并发执行:第二次刷新先返回新数据,第一次刷新后返回旧数据,最终旧结果覆盖新结果。这是典型的异步竞态。

当前页面通过按钮流程和加载态降低了概率,但没有完整的刷新序号门禁。后续可以引入:

private refreshVersion: number = 0;

private async refreshData(): Promise<void> {
  const version = ++this.refreshVersion;
  const snapshot = await this.service.getClassroomSnapshot();
  if (version !== this.refreshVersion) {
    return;
  }
  this.applySnapshot(snapshot);
}

也可以在 Service 层串行化同一课程的写操作,或让 Repository 返回版本戳。当前源码还没有这套并发控制,所以应把它列为后续增强,不写成已解决。

十四、向 ViewModel 演进时应移动什么

当前 refreshData() 已经形成一个清晰收敛点,但仍位于体量较大的 ArkUI 页面。随着功能增长,可以把以下内容迁入 ViewModel:

  • loading、error 与完整页面快照;
  • 首次加载和写后刷新编排;
  • 刷新版本号与竞态丢弃;
  • Service 错误到用户文案的映射;
  • 跨页面数据版本或变更信号。

页面只订阅 ViewModel 状态并发送意图,例如 confirmTask(id)saveOcr(id, text)deleteCourse(id)。Service 继续负责聚合规则,Repository 继续负责数据读写。不要把 SQL 或 RdbStore 因为“集中管理”而搬进 ViewModel。

十五、怎样测试写后刷新真的一致

测试不能只看点击后的 Toast。至少要为每类写操作建立“写前—写入—统一回读—跨页面检查”矩阵:

动作 canonical data 变化 必查读模型
确认候选任务 confirmed: 0 -> 1 候选、已确认、任务中心、复盘、历史
撤销确认 confirmed: 1 -> 0completed -> 0 候选、完成统计、时间线状态
完成任务 completed: 0 -> 1 排序、完成率、待办/已完成数、周历
撤销完成 恢复上一状态 所有任务投影与提示文案
保存 OCR 扫描正文、来源更新 OCR 页、复盘、历史、导出
删除课程 四类数据清空 首页空态、复盘、任务、历史、重启回读

还应覆盖刷新期间再次点击、Repository 抛错、空库、内存降级和强停重启。只有同一事实从多个入口回读一致,才能说明页面没有藏着第二套计数真相。

十六、总结与落地清单

canonical data 的价值,是把“什么是真的”和“页面怎样展示”分开。听见课堂让 Repository 保存课程、字幕、扫描和任务事实,Service 基于事实生成不同读模型,页面在写成功后统一 refreshData()。因此确认、撤销、完成、OCR 保存和课程删除不需要各自维护一组脆弱的加一减一逻辑。

落地时可以检查:

  • 每个长期业务字段只有一个权威写入位置;
  • 页面计数和列表是投影,不是可独立修改的事实;
  • 写成功后回读 Repository,而不是只改当前卡片;
  • 候选、已确认、复盘、任务中心和历史来自同一批基础数据;
  • Service 统一处理过滤、排序、日期和统计规则;
  • 页面只保留选择态、草稿态和撤销提示等短期状态;
  • 加载、错误和重试不会在多个按钮中重复实现;
  • 刷新失败不会把“已发起”展示成“已持久化”;
  • 全量刷新成本经过数据量评估,优化不破坏唯一事实源;
  • 并发刷新有版本门禁或串行策略;
  • ViewModel 负责状态编排,不越过 Service/Repository;
  • 跨页面与强停重启测试验证一致性;
  • 当前未实现的原子总快照和并发控制被如实记录。

下一篇将进入本地数据导出,分析为什么课堂 JSON 不能只是把几个对象 JSON.stringify(),而要同时定义 schema 版本、生成时间、存储模式、原始音频边界和未来兼容策略。

Logo

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

更多推荐