在上一篇文章中,我们像绘制建筑蓝图一样,设计了 Project、Contact、Meeting 三张核心表,并理清了它们之间错综复杂的关联关系。我们还决定抛弃简单的 UserPreferences,拥抱强大的 RelationalStore。但这仅仅是纸上谈兵。

现在的数据库还只是躺在代码文件里的几行 SQL 字符串,它既不能存数据,也不能查数据。

今天,我们要当一次泥瓦匠,把这些蓝图变成实实在在的房子。

对于大家来说,调用 API 打开数据库并不难。难的是如何优雅地管理这个数据库连接。你是否遇到过这样的情况:App 运行久了变得卡顿,排查发现是数据库连接打开了无数次却没关闭?或者 App 发布了 2.0 版本,用户更新后一打开就闪退,因为你加了新表却忘记处理旧数据的迁移?

这些都是烂代码埋下的雷。今天我们要写的代码,就是要排掉这些雷。我们要封装一个全局单例的 RdbManager,它不仅能安全地管理数据库连接,还能聪明地处理版本升级。

一、 为什么必须是单例?

在移动端开发中,数据库连接(RdbStore)是一个非常的对象。它对应着底层的文件句柄和内存缓冲区。如果你在每次点击保存按钮时都去 getRdbStore,用完又忘了关,或者在多个页面同时持有多个连接实例,那么很快你的 App 就会因为资源耗尽而崩溃,或者遇到文件锁冲突导致写入失败。

所以,我们必须强制使用单例模式。这意味着,无论你的 App 有多少个页面,无论你何时何地需要查数据,你拿到的永远是同一个数据库连接实例。

让我们打开 entry/src/main/ets/data/db/RdbManager.ts,对上一篇的雏形进行深度改造。这一次,我们要加入一个至关重要的逻辑:STORE_CONFIG 的完整配置。

// entry/src/main/ets/data/db/RdbManager.ts

import { relationalStore } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';
import { 
  DB_NAME, 
  DB_SECURITY_LEVEL, 
  SQL_CREATE_PROJECT, 
  SQL_CREATE_CONTACT, 
  SQL_CREATE_MEETING 
} from './MeetingRdb';

export class RdbManager {
  // 静态私有变量,持有唯一实例
  private static instance: RdbManager;
  // 持有数据库操作对象
  private rdbStore: relationalStore.RdbStore | null = null;
  // 应用上下文,初始化时注入
  private context: common.UIAbilityContext | null = null;

  // 这里的版本号非常关键,后续升级全靠它
  private static readonly DB_VERSION = 1;

  /**
   * 获取单例
   * 无论调用多少次,返回的都是同一个 RdbManager 对象
   */
  public static getInstance(): RdbManager {
    if (!RdbManager.instance) {
      RdbManager.instance = new RdbManager();
    }
    return RdbManager.instance;
  }

  /**
   * 初始化方法
   * 建议在 EntryAbility 的 onCreate 中调用
   * 这样 App 一启动,上下文就准备好了
   */
  public init(context: common.UIAbilityContext): void {
    this.context = context;
    // 可以在这里预热数据库,也可以懒加载
  }

  /**
   * 核心方法:获取 RdbStore
   * 采用了“懒加载”策略,只有真正需要查数据时才打开连接
   */
  public async getRdbStore(): Promise<relationalStore.RdbStore> {
    // 1. 如果已经打开且未关闭,直接返回复用
    if (this.rdbStore) {
      return this.rdbStore;
    }

    // 2. 检查 Context 是否注入
    if (!this.context) {
      throw new Error('[RdbManager] Context is null! Call init() first.');
    }

    // 3. 配置数据库参数
    const config: relationalStore.StoreConfig = {
      name: DB_NAME,
      securityLevel: DB_SECURITY_LEVEL,
      // 开启加密(可选,商业级项目建议开启,这里暂不演示)
      // encrypt: false 
    };

    try {
      // 4. 调用系统 API 打开或创建数据库
      this.rdbStore = await relationalStore.getRdbStore(this.context, config);
      
      // 5. 检查版本并升级
      // 这是一个非常关键的步骤,决定了你的 App 能否平滑更新
      if (this.rdbStore.version === 0) {
        // version 为 0 说明是全新安装,直接建表
        await this.initTables(this.rdbStore);
        this.rdbStore.version = RdbManager.DB_VERSION;
      } else if (this.rdbStore.version < RdbManager.DB_VERSION) {
        // 这里的 version 是旧版本号,说明用户是覆盖安装
        // 我们需要执行升级逻辑
        await this.upgradeTables(this.rdbStore, this.rdbStore.version, RdbManager.DB_VERSION);
        this.rdbStore.version = RdbManager.DB_VERSION;
      }

      return this.rdbStore;
    } catch (e) {
      console.error(`[RdbManager] Get Store Failed: ${JSON.stringify(e)}`);
      // 这里可以抛出自定义异常,让 UI 层捕获并提示用户
      throw new Error('Database init failed');
    }
  }

  /**
   * 第一次建表逻辑
   * 干净利落,把所有 CREATE TABLE 语句执行一遍
   */
  private async initTables(store: relationalStore.RdbStore) {
    console.info('[RdbManager] Initialize tables...');
    // 使用事务可以加快批量执行速度
    store.beginTransaction();
    try {
      await store.executeSql(SQL_CREATE_PROJECT);
      await store.executeSql(SQL_CREATE_CONTACT);
      await store.executeSql(SQL_CREATE_MEETING);
      store.commit();
    } catch (e) {
      console.error(`[RdbManager] Init tables failed: ${e}`);
      store.rollBack();
      throw e;
    }
  }

  /**
   * 数据库升级逻辑
   * 随着 App 版本迭代,这里会越来越长
   */
  private async upgradeTables(store: relationalStore.RdbStore, oldVersion: number, newVersion: number) {
    console.info(`[RdbManager] Upgrade DB from ${oldVersion} to ${newVersion}`);
    
    // 假设未来我们发布了 2.0 版本,DB_VERSION 变成了 2
    // if (oldVersion < 2) {
    //   // 在 Project 表加一个字段 "sort_order"
    //   await store.executeSql('ALTER TABLE project ADD COLUMN sort_order INTEGER');
    // }
    
    // 假设发布了 3.0 版本
    // if (oldVersion < 3) { ... }
  }
}

通过这段代码,我们把复杂的生命周期管理封装在了一个黑盒子里。对于调用者(比如 ViewModel)来说,它只需要 await RdbManager.getInstance().getRdbStore(),就能拿到一个健康、可用、版本正确的数据库对象,完全不需要关心底下发生了什么。

二、 DAO 层与 CRUD 实战

有了管家,我们还需要工人。在架构设计中,我们通常会引入 DAO(Data Access Object) 层,或者叫 Repository(仓库) 层,专门负责具体的增删改查业务。我们不要把 SQL 语句散落在 App 的各个角落。

请在 entry/src/main/ets/data/db 目录下新建 ProjectRepo.ts。我们以项目(Project)这个最基础的实体为例,来演示如何实现一个标准的 CRUD 流程。

这里有一个痛点:数据库 API 需要的是 ValuesBucket(用于插入)和返回 ResultSet(用于查询),而我们的业务层需要的是 Project 对象。中间的转换代码写起来非常枯燥,但必须写,而且要写得健壮。

我们先写插入(Create)

TypeScript

// entry/src/main/ets/data/db/ProjectRepo.ts

import { relationalStore } from '@kit.ArkData';
import { Project } from '../models/Project';
import { RdbManager } from './RdbManager';
import { TABLE_PROJECT } from './MeetingRdb';

/**
 * 插入一个新项目
 * @param project 业务对象
 * @returns 插入的行 ID
 */
export async function insertProject(project: Project): Promise<number> {
  // 1. 获取数据库实例
  const store = await RdbManager.getInstance().getRdbStore();
  
  // 2. 构建 ValuesBucket
  // 这是鸿蒙 RdbStore 要求的特定格式,类似于一个 Map
  // 键是数据库列名,值是数据
  // 注意:我们这里手动映射了驼峰属性到下划线列名
  const valueBucket: relationalStore.ValuesBucket = {
    'id': project.id,
    'name': project.name,
    'description': project.description || '', // 处理 undefined
    'color': project.color,
    'created_at': project.createdAt,
    'updated_at': project.updatedAt
  };

  // 3. 执行插入
  // relationalStore.ConflictResolution.REPLACE 表示如果 ID 冲突则覆盖
  // 这对于去重非常有用
  const rowId = await store.insert(TABLE_PROJECT, valueBucket, relationalStore.ConflictResolution.REPLACE);
  
  return rowId;
}

接下来是查询(Read)。这是最繁琐的部分,因为 ResultSet 是一个游标,我们需要遍历它,把每一列的数据取出来,填到 Project 对象里。

为了不让重复代码淹没我们,建议把“单行转对象”的逻辑抽离成一个 Helper 函数。

TypeScript

// entry/src/main/ets/data/db/ProjectRepo.ts

/**
 * 查询所有项目
 * 按更新时间倒序排列(最近活跃的在前)
 */
export async function queryAllProjects(): Promise<Project[]> {
  const store = await RdbManager.getInstance().getRdbStore();
  
  // 1. 构建查询条件
  // RdbPredicates 是鸿蒙提供的强大的查询构建器,类似 SQL 的 WHERE 子句
  const predicates = new relationalStore.RdbPredicates(TABLE_PROJECT);
  predicates.orderByDesc('updated_at'); // 排序

  // 2. 执行查询
  const resultSet = await store.query(predicates);
  
  // 3. 遍历结果集
  const projects: Project[] = [];
  try {
    // goToNextRow() 返回 false 表示游标走到头了
    while (resultSet.goToNextRow()) {
      // 调用转换函数
      projects.push(convertRowToProject(resultSet));
    }
  } finally {
    // 4. 极其重要:关闭 ResultSet 释放内存!
    // 很多内存泄漏都是因为忘了这一步
    resultSet.close();
  }
  
  return projects;
}

/**
 * 辅助函数:将当前游标指向的行转换为 Project 对象
 */
function convertRowToProject(resultSet: relationalStore.ResultSet): Project {
  // getColumnIndex 是为了防范列名写错或者列不存在的情况
  // 虽然稍微损耗一点性能,但更安全
  return {
    id: resultSet.getString(resultSet.getColumnIndex('id')),
    name: resultSet.getString(resultSet.getColumnIndex('name')),
    description: resultSet.getString(resultSet.getColumnIndex('description')),
    color: resultSet.getString(resultSet.getColumnIndex('color')),
    // getLong 对应数据库的 INTEGER
    createdAt: resultSet.getLong(resultSet.getColumnIndex('created_at')),
    updatedAt: resultSet.getLong(resultSet.getColumnIndex('updated_at'))
  };
}

你看,通过这种模式,无论 Project 表有多少列,复杂的取值逻辑都被封装在了 convertRowToProject 这一处。以后如果我们要给项目加个“图标”字段,只需要改这一个函数,而不用去改每一个查询方法。

最后简单带过 更新(Update)删除(Delete)。它们的逻辑和查询类似,都是先构建 RdbPredicates 来锁定要操作的行。

TypeScript

// entry/src/main/ets/data/db/ProjectRepo.ts

export async function updateProjectName(id: string, newName: string): Promise<number> {
  const store = await RdbManager.getInstance().getRdbStore();
  
  const valueBucket: relationalStore.ValuesBucket = {
    'name': newName,
    'updated_at': Date.now() // 记得更新时间戳
  };
  
  const predicates = new relationalStore.RdbPredicates(TABLE_PROJECT);
  predicates.equalTo('id', id); // WHERE id = ?
  
  return store.update(valueBucket, predicates);
}

export async function deleteProject(id: string): Promise<number> {
  const store = await RdbManager.getInstance().getRdbStore();
  
  const predicates = new relationalStore.RdbPredicates(TABLE_PROJECT);
  predicates.equalTo('id', id);
  
  return store.delete(predicates);
}

三、 在 EntryAbility 中激活它

现在,我们的武器库已经准备好了,但它们还没被加载。我们需要在 App 启动的最早时机,把 Context 注入给 RdbManager

打开 entry/src/main/ets/entryability/EntryAbility.ts。这是 Stage 模型中 Ability 的生命周期入口。

// entry/src/main/ets/entryability/EntryAbility.ts

import { UIAbility, AbilityConstant, Want } from '@kit.AbilityKit';
import { window } from '@kit.ArkUI';
import { RdbManager } from '../data/db/RdbManager';

export default class EntryAbility extends UIAbility {
  onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    // 在 Ability 创建时,初始化数据库管理器
    // 传入 context,这样 RdbManager 就能拿到沙箱路径
    RdbManager.getInstance().init(this.context);
    
    console.info('[EntryAbility] RdbManager initialized');
  }

  // ... 其他生命周期方法
}

这里有一个常识性的问题:为什么不在 Index.etsaboutToAppear 里初始化?

因为 Index.ets 是 UI 界面。如果你的 App 支持后台运行或者卡片(Service Widget)启动,有可能 UI 界面根本不需要显示,但后台服务需要读写数据库。

如果在 UI 里初始化,后台任务就会因为拿不到 Context 而崩溃。在 EntryAbility 初始化是目前最稳妥的做法。

四、 总结

今天,我们干了一件非常硬核的事情。

我们不仅写了代码,更是在构建基础设施。我们封装了一个线程安全、支持版本升级的 RdbManager 单例,彻底解决了数据库连接管理的后顾之忧。我们还实践了标准的 DAO 模式,将底层的 ValuesBucketResultSet 转换逻辑屏蔽在 Repo 文件内部,为上层的 ViewModel 提供了干净清爽的 TypeScript 接口。

现在的会议随记 Pro,已经具备了记忆能力。它不再是那个重启就会失忆的 Demo,而是一个能持久化存储用户资产的商业级应用雏形。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐