
【听见课堂 HarmonyOS NEXT 实战系列 11】先定义接口再选数据库ClassroomRepository 的可替换数据层很多 ArkUI 项目在第一版里会直接从页面调用 RelationalStore按钮点击后拼RdbPredicates列表刷新时执行 SQL删除时顺手清理几张表。功能少时看似高效等到要增加内存降级、测试替身、迁移或多页面刷新页面就会同时承担展示、业务规则和数据一致性。听见课堂没有让页面认识RdbStore。它先定义ClassroomRepository再提供 RelationalStore 和内存两套实现ClassroomService只依赖接口ArkUI 只依赖 Service。本文结合当前源码拆解接口粒度、状态约束、事务边界和替换步骤。一、先看真实依赖方向项目的数据调用链是ArkUI Page - ClassroomService - ClassroomRepository - RelationalClassroomRepository - InMemoryClassroomRepository这条链最重要的性质是单向依赖。页面不知道 SQLService 不知道表名Repository 不处理页面提示。具体数据库实现可以变化但候选任务必须人工确认、已确认任务才能完成等业务语义保持稳定。二、Repository 接口暴露的不是 CRUD 表名当前ClassroomRepository只有一个领域接口exportinterfaceClassroomRepository{getTodayCourse():PromiseCourseSummary;getTranscript():PromiseArrayTranscriptSegment;replaceTranscript(courseId:string,transcript:ArrayTranscriptSegment):Promiseboolean;getScanNotes():PromiseArrayScanNote;getTasks():PromiseArrayTaskItem;updateCourse(course:CourseSummary):Promiseboolean;deleteCourse(courseId:string):Promiseboolean;clearAllData():Promiseboolean;updateScanText(scanId:string,text:string,source?:string):Promiseboolean;updateTask(taskId:string,title:string,dueText:string,dueAtMillis:number):Promiseboolean;confirmTask(taskId:string):Promiseboolean;unconfirmTask(taskId:string):Promiseboolean;setTaskCompleted(taskId:string,completed:boolean):Promiseboolean;toggleTask(taskId:string):Promiseboolean;}它没有暴露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的构造函数接收ClassroomRepositoryexportclassClassroomService{privaterepository:ClassroomRepository;privatestorageMode:string内存降级;constructor(repository:ClassroomRepository){this.repositoryrepository;}useRepository(repository:ClassroomRepository,storageMode:string):void{this.repositoryrepository;this.storageModestorageMode;}}Service 可以在应用初始化成功后切换到 RelationalStore也可以在初始化失败时继续使用内存实现。页面调用getTaskCenterSnapshot()时无需判断当前数据来自数据库还是内存。五、业务规则应该放在 Service 还是 Repository一个实用判断是规则是否与数据源无关。例如任务标题和截止时间不能为空这是所有实现都要遵守的输入规则放在 ServiceasyncupdateTask(taskId:string,title:string,dueText:string):Promiseboolean{constnormalizedTitle:stringtitle.trim();constnormalizedDueText:stringdueText.trim();if(normalizedTitle.length0||normalizedDueText.length0){returnfalse;}constdueAtMillis:numberTaskDateResolver.resolveDueText(normalizedDueText);returnthis.repository.updateTask(taskId,normalizedTitle,normalizedDueText,dueAtMillis);}而“更新哪张表、如何构造 predicates、影响了几行”与数据源相关属于 Repository。两层都可以做防御当前两套 Repository 都会拒绝编辑已确认任务从而保证即使调用方绕过部分页面流程也不会静默改写稳定事实。六、事务为什么必须留在具体实现替换课堂字幕不是单条 UPDATE而是删除课程旧字幕后按顺序插入新字幕。如果中途失败旧数据和新数据不能各留一半。RelationalStore 实现使用事务store.beginTransaction();try{awaitthis.deleteWhere(TABLE_TRANSCRIPT,course_id,courseId);for(letindex:number0;indextranscript.length;index){awaitstore.insert(TABLE_TRANSCRIPT,{id:transcript[index].id,course_id:courseId,sort_order:index1});}store.commit();returntrue;}catch(error){store.rollBack();thrownewError(Failed to replace classroom transcript.);}内存实现不需要数据库事务但必须保持同样的对外语义参数无效返回false成功后整组替换不能先修改一半再返回失败。七、接口让内存降级真正可替换InMemoryClassroomRepository implements ClassroomRepository是可替换性的直接证明。它用数组保存课程、字幕、扫描和任务却仍然支持确认、撤销确认、完成、撤销完成、删除课程和清空全部数据。初始化逻辑只在组合根选择实现exportasyncfunctioninitializeClassroomData(context:common.UIAbilityContext):Promiseboolean{try{constrepositorynewRelationalClassroomRepository();awaitrepository.initialize(context);classroomService.useRepository(repository,RelationalStore);returntrue;}catch(error){classroomService.useRepository(fallbackRepository,内存降级);returnfalse;}}如果页面直接 importRelationalClassroomRepository这个切换就会失效。可替换性不是“有接口文件”即可而是上层不得绕过接口。八、页面为什么仍要知道“存储模式”页面不需要知道数据库 API但用户需要知道数据是否持久化。听见课堂通过getStorageMode()将技术状态映射为可理解文案RelationalStore- “本机存储”内存降级- “临时存储”初始化中 - “正在准备”。这是接口隔离和产品透明度的平衡。隐藏 SQL 细节不等于隐藏数据可靠性。若降级后仍显示“字幕、板书和任务会永久保留”就会形成错误承诺。九、怎样用同一接口做确定性测试不引入真实数据库也可以测试 Service 规则。创建一个实现ClassroomRepository的测试替身预置少量任务然后验证getCandidateTasks()只返回confirmedfalsegetConfirmedTasks()只返回已确认记录未确认任务不能标记完成撤销确认会同时清除completed清空后所有聚合快照进入空态。这种测试不证明 RelationalStore SQL 正确却能快速证明业务规则。数据库事务、迁移和 ResultSet 释放仍需要单独的 Repository 验证。十、新增一种数据源需要哪些步骤假设未来要增加加密文件或远端只读备份不应该先改页面。建议按以下顺序判断现有ClassroomRepository是否已经表达所需领域动作若必须扩展接口先定义返回值、错误和状态语义为现有 RelationalStore 与内存实现补齐新方法编写新 Repository不在其中复制 Service 业务规则仅在组合根选择实现或组合多个数据源运行两套实现的契约测试页面只增加必要的存储状态或恢复提示。如果扩展接口导致几十个页面一起修改通常说明页面已经越过 Service 边界。十一、当前接口的真实限制可替换不等于已经支持所有场景。当前项目以单个当前课程为主getScanNotes()和getTasks()没有课程参数Repository 查询也按全表sort_order返回。它适合现阶段演示闭环但若要支持多课程并发、分页历史或大数据量查询需要演进为按courseId查询字幕、扫描和任务为常用筛选增加索引返回分页或游标而不是一次加载全部明确多课程删除和跨课程事务将错误从单一boolean扩展为可区分的结果类型。文章不能因为用了 Repository 就宣称“天然支持多课程”。接口只证明了边界能力仍以当前实现为准。十二、常见反模式反模式 1页面直接拼 SQL后果是页面测试困难、迁移逻辑分散、内存降级失效。修复方式是把数据动作收口到 Repository把业务动作收口到 Service。反模式 2Repository 返回数据库对象如果接口返回ResultSet或ValuesBucket上层仍被 ArkData 绑定。应该在 Repository 内完成 DTO/领域模型映射并关闭 ResultSet。反模式 3两套实现语义不一致例如内存实现允许编辑已确认任务而数据库实现拒绝页面在不同模式下就会表现不同。需要共享契约测试锁定行为。反模式 4捕获所有错误后仍返回成功降级可以保证应用可用但不能把持久化失败伪装成保存成功。应显示“临时存储”并让用户理解重启后数据可能丢失。十三、验收清单页面没有 import ArkData 或具体 RepositoryService 只依赖ClassroomRepository接口使用领域动作不暴露 SQL两套实现对无效参数、确认状态和删除语义一致多记录写入使用事务或等价原子替换ResultSet 在finally中关闭写入后页面重新读取 canonical data降级模式对用户可见当前单课程限制已明确不夸大扩展能力。十四、总结先定义 Repository 接口价值不在于“多一层”而在于建立稳定的领域边界。听见课堂让 Service 面向课程、字幕、扫描和任务编程把 SQL、事务和 ResultSet 留在 RelationalStore 实现并用内存实现验证可替换性。下一篇将继续讨论最容易被忽略的问题当 RelationalStore 初始化失败时如何使用InMemoryClassroomRepository保住最小可演示闭环同时明确告诉用户数据只是临时保存。