【听见课堂 HarmonyOS NEXT 实战系列 03】ArkUI 页面为什么不该直接操作数据库:Service 与 Repository 分层实战

在 ArkUI 项目中,直接在页面生命周期里打开数据库、查询列表、筛选结果,再顺手更新计数,看起来是最快的实现方式。问题是,一旦课程首页、复习页、任务中心和历史记录都需要同一批数据,页面就会逐渐拥有多套不同的业务口径。

听见课堂采用 ArkUI Page → Service → Repository → RelationalStore 的分层方式。页面负责展示和交互草稿态,Service 负责业务规则与聚合,Repository 负责数据源读写。下面结合项目中的真实接口,拆解这条数据链路。

ArkUI 页面到本地数据库的分层链路

一、页面直接查库会带来什么问题

假设复习页需要展示“待确认字幕”和“待确认扫描”,任务中心需要展示“今天到期”“已逾期”“已完成”。如果每个页面都直接查询数据库,很快会出现这些问题:

  • 页面重复拼装查询条件,字段口径容易不一致;
  • 保存任务后,要手工更新多个页面的角标与统计数字;
  • 数据库异常、空库和迁移失败被迫在 UI 中处理;
  • 单元测试必须创建页面和系统上下文,难以隔离业务规则;
  • 未来切换内存数据、测试数据或远程数据时,页面需要大面积修改。

更稳妥的做法是让页面只知道“我要一个复习快照”或“我要更新这条任务”,而不关心底层使用了哪张表。

Page、Service、Repository、Local DB 数据流

二、Repository 用接口隔离数据源

听见课堂首先定义 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>;
  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>;
  setTaskCompleted(taskId: string, completed: boolean): Promise<boolean>;
}

接口的价值不只是“面向接口编程”。它明确了数据层的职责边界:Repository 返回领域模型,不返回页面组件需要的临时颜色、按钮文案或弹窗状态。

项目同时可以拥有 RelationalClassroomRepository 和内存实现。前者用于真实本地数据,后者可以作为能力不可用时的降级或测试替身。上层 Service 不需要知道当前使用的是哪种实现。

三、Service 负责业务聚合,不只是转发调用

如果 Service 只是把 Repository 的方法原样包一层,分层并没有产生真正价值。听见课堂在 Service 中完成确认状态筛选、任务统计和能力状态归一化。

例如任务中心需要的内容可以一次聚合为页面快照:

async getTaskCenterSnapshot(): Promise<TaskCenterSnapshot> {
  const tasks: Array<TaskItem> = await this.repository.getTasks();
  const nowMillis: number = Date.now();
  const items: Array<TaskCenterItem> = tasks
    .filter((task: TaskItem) => task.confirmed)
    .map((task: TaskItem): TaskCenterItem => {
      const dueAtMillis = task.dueAtMillis > 0 ? task.dueAtMillis :
        TaskDateResolver.resolveDueText(task.dueText, nowMillis);
      const overdue = TaskDateResolver.isOverdue(dueAtMillis, task.completed, nowMillis);
      const status = task.completed ? '已完成' : (overdue ? '已过期' : '待办');
      return new TaskCenterItem(
        task.id, task.title, task.dueText, task.source, status,
        TaskDateResolver.dateGroup(dueAtMillis, nowMillis),
        task.completed, overdue, '', dueAtMillis,
        TaskDateResolver.dateKey(dueAtMillis)
      );
    });
  // 后续统一排序并计算 pending、completed、overdue 与日历统计
}

任务中心也可以由 Service 统一计算“今日”“逾期”“已完成”等分组。这样手机底部导航角标、平板侧栏统计和任务中心列表都基于同一份 canonical data,而不是在三个页面中各自加减计数。

一个重要原则是:写入完成后重新读取并聚合权威数据,不要手工猜测其他页面应该变成什么状态。

四、初始化逻辑应该只有一个入口

应用启动时,EntryAbility 调用统一初始化方法,而不是自己创建数据库:

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;
  }
}

具体实现可以先尝试创建 RelationalStore Repository,失败时切换到内存实现并记录原因。对页面而言,只要 Service 注册完成,它就能通过稳定接口加载数据。

这也让失败路径更容易解释:

  • 初始化成功:页面加载本地持久化数据;
  • 数据库不可用但降级成功:页面仍可操作,但应提示数据不会持久保存;
  • Service 未初始化:页面进入明确错误态,提供重试,而不是一直显示 loading。

五、页面只负责消费状态和触发动作

在页面层,推荐把一次刷新写成清晰的状态转换:

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();
  } catch (error) {
    this.dataError = '课堂数据暂时不可用,请稍后重试。';
  } finally {
    this.isDataLoading = false;
  }
}

页面仍然需要处理 loadingemptyerrordisabledpressed,但它不负责解释数据库错误码,也不负责决定什么叫“待确认”。

当用户确认一段字幕时,页面触发 Service 动作,动作完成后重新请求复习快照。即使另一个页面或后台流程修改了数据,页面也会回到 Repository 中的真实结果。

六、RelationalStore 负责结构化数据与迁移

字幕、扫描、任务都具备列表、筛选、排序和关联关系,适合使用 RelationalStore。Repository 实现需要额外关注:

  • 首次安装时建表与种子数据;
  • schema 版本升级与迁移;
  • 空库、重复主键和脏数据兜底;
  • 多步写入时的事务一致性;
  • 将数据库行映射回显式 ArkTS 类型;
  • 异常转换成上层可理解的错误,而不是直接把底层对象抛给页面。

用户设置、主题开关或少量最近状态则更适合 Preferences。不要因为 Preferences 调用简单,就把任务列表序列化成一个越来越大的字符串。

七、分层之后如何测试

这套结构可以把验证拆开:

  1. Repository 测试:验证建表、增删改查、排序和迁移;
  2. Service 测试:注入内存 Repository,验证确认筛选、任务统计和边界日期;
  3. 页面测试:验证加载、空态、错误态和交互反馈;
  4. 真机验证:验证 RelationalStore、生命周期恢复和数据持久化。

构建成功只能说明类型、资源和打包链路基本成立,不能替代数据库迁移测试;单元测试通过也不能替代真机生命周期验证。发布文章时同样要如实区分这些证据层级。

总结

Page → Service → Repository 不是为了增加文件数量,而是为了让每一层只回答一种问题:页面决定怎么显示,Service 决定业务规则,Repository 决定数据如何读写。

下一篇将继续向下看领域数据本身,分析 CourseTranscriptScanTask 如何组成一条可追溯的课堂证据链。

Logo

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

更多推荐