【鸿蒙原生开发会议随记 Pro】 增删改查 封装一个优雅的 SQLite 数据库单例

文章目录

    • [一、 为什么必须是单例?](#一、 为什么必须是单例?)
    • [二、 DAO 层与 CRUD 实战](#二、 DAO 层与 CRUD 实战)
    • [三、 在 EntryAbility 中激活它](#三、 在 EntryAbility 中激活它)
    • [四、 总结](#四、 总结)

在上一篇文章中,我们像绘制建筑蓝图一样,设计了 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.ets 的 aboutToAppear 里初始化?

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

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

四、 总结

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

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

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

相关推荐
专注API从业者2 分钟前
告别复杂页面解析,OpenClaw 快速搭建电商商品监控与数据分析脚本
大数据·数据库·python·数据挖掘·数据分析
naturliche23 分钟前
sgx支持数据库环境配置,编译,debug
数据库·编译·sgx
SelectDB33 分钟前
Apache Doris x Fluss:面向湖流一体的统一查询与分析
大数据·数据库·数据分析
云贝贝贝34 分钟前
Oracle 备份恢复实战:RMAN 备份、恢复与误删闪回
数据库
java1234_小锋35 分钟前
【技术专题】Mysql8 数据库 - Mysql8 触发器
数据库·mysql·mysql8
要吃这碗饭43 分钟前
AI 智能体如何通过 auth.md 注册 Bright Data 账号
数据库·人工智能·php
薛晓刚1 小时前
Oracle ADG 备库归档丢了怎么办?RMAN 增量前滚实战
数据库·oracle
Highcharts.js1 小时前
用Highcharts开发动态主从图表|双图联动的时间序列图表示例
javascript·数据库·信息可视化·highcharts·图表开发·动态图表
阿俊-全栈开发1 小时前
LikeShop 版本升级与备份:从旧版平滑迁移的完整步骤
jvm·数据库·oracle·开源·likeshop·likeshop开源商城
姜鱼问生2 小时前
docker exec 排查容器问题:从日志到进程
运维·网络·数据库·docker·容器