【听见课堂 HarmonyOS NEXT 实战系列 11】先定义接口再选数据库:ClassroomRepository 的可替换数据层
【听见课堂 HarmonyOS NEXT 实战系列 11】先定义接口再选数据库:ClassroomRepository 的可替换数据层
很多 ArkUI 项目在第一版里会直接从页面调用 RelationalStore:按钮点击后拼 RdbPredicates,列表刷新时执行 SQL,删除时顺手清理几张表。功能少时看似高效,等到要增加内存降级、测试替身、迁移或多页面刷新,页面就会同时承担展示、业务规则和数据一致性。
听见课堂没有让页面认识 RdbStore。它先定义 ClassroomRepository,再提供 RelationalStore 和内存两套实现;ClassroomService 只依赖接口,ArkUI 只依赖 Service。本文结合当前源码,拆解接口粒度、状态约束、事务边界和替换步骤。

一、先看真实依赖方向
项目的数据调用链是:
ArkUI Page
-> ClassroomService
-> ClassroomRepository
-> RelationalClassroomRepository
-> InMemoryClassroomRepository
这条链最重要的性质是单向依赖。页面不知道 SQL,Service 不知道表名,Repository 不处理页面提示。具体数据库实现可以变化,但候选任务必须人工确认、已确认任务才能完成等业务语义保持稳定。
二、Repository 接口暴露的不是 CRUD 表名
当前 ClassroomRepository 只有一个领域接口:
export interface ClassroomRepository {
getTodayCourse(): Promise<CourseSummary>;
getTranscript(): Promise<Array<TranscriptSegment>>;
replaceTranscript(courseId: string, transcript: Array<TranscriptSegment>): Promise<boolean>;
getScanNotes(): Promise<Array<ScanNote>>;
getTasks(): Promise<Array<TaskItem>>;
updateCourse(course: CourseSummary): Promise<boolean>;
deleteCourse(courseId: string): Promise<boolean>;
clearAllData(): Promise<boolean>;
updateScanText(scanId: string, text: string, source?: string): Promise<boolean>;
updateTask(taskId: string, title: string, dueText: string, dueAtMillis: number): Promise<boolean>;
confirmTask(taskId: string): Promise<boolean>;
unconfirmTask(taskId: string): Promise<boolean>;
setTaskCompleted(taskId: string, completed: boolean): Promise<boolean>;
toggleTask(taskId: string): Promise<boolean>;
}
它没有暴露 querySql()、表名或 ValuesBucket,而是使用课程、字幕、扫描、任务这些领域语言。调用方关注“替换某课程字幕”而不是“删除后批量插入 transcript_segments”。
三、读操作和写操作要分开理解
接口可以分为三类:
1. 快照读取
getTodayCourse()、getTranscript()、getScanNotes()、getTasks() 返回当前 canonical data。Service 再据此构造复盘、任务中心、历史搜索等页面快照。
2. 领域更新
updateScanText()、updateTask()、confirmTask() 等方法表达一个明确状态变化。它们避免页面直接更新某个字段,同时为两套 Repository 保留相同契约。
3. 组合与破坏性操作
replaceTranscript()、deleteCourse()、clearAllData() 可能影响多条或多表记录,需要原子性和明确失败语义。事务属于具体 Repository,而二次确认和用户提示属于页面与 Service。
这种分类能阻止接口退化成“万能 execute”。如果 Repository 只提供 execute(sql),上层仍会泄漏数据库细节,替换实现没有实际价值。
四、为什么 Service 只持有接口
ClassroomService 的构造函数接收 ClassroomRepository:
export class ClassroomService {
private repository: ClassroomRepository;
private storageMode: string = '内存降级';
constructor(repository: ClassroomRepository) {
this.repository = repository;
}
useRepository(repository: ClassroomRepository, storageMode: string): void {
this.repository = repository;
this.storageMode = storageMode;
}
}
Service 可以在应用初始化成功后切换到 RelationalStore,也可以在初始化失败时继续使用内存实现。页面调用 getTaskCenterSnapshot() 时,无需判断当前数据来自数据库还是内存。

五、业务规则应该放在 Service 还是 Repository
一个实用判断是:规则是否与数据源无关。
例如任务标题和截止时间不能为空,这是所有实现都要遵守的输入规则,放在 Service:
async updateTask(taskId: string, title: string, dueText: string): Promise<boolean> {
const normalizedTitle: string = title.trim();
const normalizedDueText: string = dueText.trim();
if (normalizedTitle.length === 0 || normalizedDueText.length === 0) {
return false;
}
const dueAtMillis: number = TaskDateResolver.resolveDueText(normalizedDueText);
return this.repository.updateTask(taskId, normalizedTitle, normalizedDueText, dueAtMillis);
}
而“更新哪张表、如何构造 predicates、影响了几行”与数据源相关,属于 Repository。两层都可以做防御:当前两套 Repository 都会拒绝编辑已确认任务,从而保证即使调用方绕过部分页面流程,也不会静默改写稳定事实。
六、事务为什么必须留在具体实现
替换课堂字幕不是单条 UPDATE,而是删除课程旧字幕后按顺序插入新字幕。如果中途失败,旧数据和新数据不能各留一半。
RelationalStore 实现使用事务:
store.beginTransaction();
try {
await this.deleteWhere(TABLE_TRANSCRIPT, 'course_id', courseId);
for (let index: number = 0; index < transcript.length; index++) {
await store.insert(TABLE_TRANSCRIPT, {
id: transcript[index].id,
course_id: courseId,
sort_order: index + 1
});
}
store.commit();
return true;
} catch (error) {
store.rollBack();
throw new Error('Failed to replace classroom transcript.');
}
内存实现不需要数据库事务,但必须保持同样的对外语义:参数无效返回 false,成功后整组替换,不能先修改一半再返回失败。
七、接口让内存降级真正可替换
InMemoryClassroomRepository implements ClassroomRepository 是可替换性的直接证明。它用数组保存课程、字幕、扫描和任务,却仍然支持确认、撤销确认、完成、撤销完成、删除课程和清空全部数据。
初始化逻辑只在组合根选择实现:
export async function initializeClassroomData(context: common.UIAbilityContext): Promise<boolean> {
try {
const repository = new RelationalClassroomRepository();
await repository.initialize(context);
classroomService.useRepository(repository, 'RelationalStore');
return true;
} catch (error) {
classroomService.useRepository(fallbackRepository, '内存降级');
return false;
}
}
如果页面直接 import RelationalClassroomRepository,这个切换就会失效。可替换性不是“有接口文件”即可,而是上层不得绕过接口。
八、页面为什么仍要知道“存储模式”
页面不需要知道数据库 API,但用户需要知道数据是否持久化。听见课堂通过 getStorageMode() 将技术状态映射为可理解文案:
RelationalStore-> “本机存储”;内存降级-> “临时存储”;- 初始化中 -> “正在准备”。
这是接口隔离和产品透明度的平衡。隐藏 SQL 细节,不等于隐藏数据可靠性。若降级后仍显示“字幕、板书和任务会永久保留”,就会形成错误承诺。
九、怎样用同一接口做确定性测试
不引入真实数据库也可以测试 Service 规则。创建一个实现 ClassroomRepository 的测试替身,预置少量任务,然后验证:
getCandidateTasks()只返回confirmed=false;getConfirmedTasks()只返回已确认记录;- 未确认任务不能标记完成;
- 撤销确认会同时清除
completed; - 清空后所有聚合快照进入空态。
这种测试不证明 RelationalStore SQL 正确,却能快速证明业务规则。数据库事务、迁移和 ResultSet 释放仍需要单独的 Repository 验证。
十、新增一种数据源需要哪些步骤
假设未来要增加加密文件或远端只读备份,不应该先改页面。建议按以下顺序:
- 判断现有
ClassroomRepository是否已经表达所需领域动作; - 若必须扩展接口,先定义返回值、错误和状态语义;
- 为现有 RelationalStore 与内存实现补齐新方法;
- 编写新 Repository,不在其中复制 Service 业务规则;
- 仅在组合根选择实现或组合多个数据源;
- 运行两套实现的契约测试;
- 页面只增加必要的存储状态或恢复提示。
如果扩展接口导致几十个页面一起修改,通常说明页面已经越过 Service 边界。
十一、当前接口的真实限制
可替换不等于已经支持所有场景。当前项目以单个当前课程为主:getScanNotes() 和 getTasks() 没有课程参数,Repository 查询也按全表 sort_order 返回。它适合现阶段演示闭环,但若要支持多课程并发、分页历史或大数据量查询,需要演进为:
- 按
courseId查询字幕、扫描和任务; - 为常用筛选增加索引;
- 返回分页或游标,而不是一次加载全部;
- 明确多课程删除和跨课程事务;
- 将错误从单一
boolean扩展为可区分的结果类型。
文章不能因为用了 Repository 就宣称“天然支持多课程”。接口只证明了边界,能力仍以当前实现为准。
十二、常见反模式
反模式 1:页面直接拼 SQL
后果是页面测试困难、迁移逻辑分散、内存降级失效。修复方式是把数据动作收口到 Repository,把业务动作收口到 Service。
反模式 2:Repository 返回数据库对象
如果接口返回 ResultSet 或 ValuesBucket,上层仍被 ArkData 绑定。应该在 Repository 内完成 DTO/领域模型映射并关闭 ResultSet。
反模式 3:两套实现语义不一致
例如内存实现允许编辑已确认任务,而数据库实现拒绝;页面在不同模式下就会表现不同。需要共享契约测试锁定行为。
反模式 4:捕获所有错误后仍返回成功
降级可以保证应用可用,但不能把持久化失败伪装成保存成功。应显示“临时存储”,并让用户理解重启后数据可能丢失。
十三、验收清单
- 页面没有 import ArkData 或具体 Repository;
- Service 只依赖
ClassroomRepository; - 接口使用领域动作,不暴露 SQL;
- 两套实现对无效参数、确认状态和删除语义一致;
- 多记录写入使用事务或等价原子替换;
- ResultSet 在
finally中关闭; - 写入后页面重新读取 canonical data;
- 降级模式对用户可见;
- 当前单课程限制已明确,不夸大扩展能力。
十四、总结
先定义 Repository 接口,价值不在于“多一层”,而在于建立稳定的领域边界。听见课堂让 Service 面向课程、字幕、扫描和任务编程,把 SQL、事务和 ResultSet 留在 RelationalStore 实现,并用内存实现验证可替换性。
下一篇将继续讨论最容易被忽略的问题:当 RelationalStore 初始化失败时,如何使用 InMemoryClassroomRepository 保住最小可演示闭环,同时明确告诉用户数据只是临时保存。
更多推荐



所有评论(0)