【听见课堂 HarmonyOS NEXT 实战系列 18】写完不要手工改计数:canonical data 与写后刷新
【听见课堂 HarmonyOS NEXT 实战系列 18】写完不要手工改计数:canonical data 与写后刷新
很多 ArkUI 页面在功能刚起步时,会把“保存成功”处理成几行局部赋值:任务数加一、已完成数加一、候选数减一、首页角标改一下。这种做法在单页面 Demo 里很快,但当同一份数据同时驱动首页、课堂回顾、任务中心、历史搜索和导出功能时,任何漏改都会让多个页面对同一事实给出不同答案。
听见课堂选择把 Repository 中的课程、字幕、扫描和任务作为 canonical data。写操作成功后,页面统一调用 refreshData() 回读各类原始数据,再由 ClassroomService 重算复盘、任务中心和历史快照。本文从真实刷新链路出发,说明 canonical data 是什么、为什么不能手工补计数,以及这套模式在性能、错误处理和 ViewModel 演进上还有哪些边界。

一、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(/* 映射后的稳定字段 */);
});
随后列表按“已过期、待办、已完成”等业务优先级和截止时间排序,再统计总数、待办、已完成、已过期,并生成一周日历。页面只消费快照,不重复实现日期和排序规则。

九、复盘快照为什么不能复用任务中心的几个数字
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 -> 0 且 completed -> 0 |
候选、完成统计、时间线状态 |
| 完成任务 | completed: 0 -> 1 |
排序、完成率、待办/已完成数、周历 |
| 撤销完成 | 恢复上一状态 | 所有任务投影与提示文案 |
| 保存 OCR | 扫描正文、来源更新 | OCR 页、复盘、历史、导出 |
| 删除课程 | 四类数据清空 | 首页空态、复盘、任务、历史、重启回读 |
还应覆盖刷新期间再次点击、Repository 抛错、空库、内存降级和强停重启。只有同一事实从多个入口回读一致,才能说明页面没有藏着第二套计数真相。
十六、总结与落地清单
canonical data 的价值,是把“什么是真的”和“页面怎样展示”分开。听见课堂让 Repository 保存课程、字幕、扫描和任务事实,Service 基于事实生成不同读模型,页面在写成功后统一 refreshData()。因此确认、撤销、完成、OCR 保存和课程删除不需要各自维护一组脆弱的加一减一逻辑。
落地时可以检查:
- 每个长期业务字段只有一个权威写入位置;
- 页面计数和列表是投影,不是可独立修改的事实;
- 写成功后回读 Repository,而不是只改当前卡片;
- 候选、已确认、复盘、任务中心和历史来自同一批基础数据;
- Service 统一处理过滤、排序、日期和统计规则;
- 页面只保留选择态、草稿态和撤销提示等短期状态;
- 加载、错误和重试不会在多个按钮中重复实现;
- 刷新失败不会把“已发起”展示成“已持久化”;
- 全量刷新成本经过数据量评估,优化不破坏唯一事实源;
- 并发刷新有版本门禁或串行策略;
- ViewModel 负责状态编排,不越过 Service/Repository;
- 跨页面与强停重启测试验证一致性;
- 当前未实现的原子总快照和并发控制被如实记录。
下一篇将进入本地数据导出,分析为什么课堂 JSON 不能只是把几个对象 JSON.stringify(),而要同时定义 schema 版本、生成时间、存储模式、原始音频边界和未来兼容策略。
更多推荐



所有评论(0)