HarmonyOS ArkTS 关系型数据库完整部署教程

适用版本: HarmonyOS SDK API≥20 · ArkTS · Stage 模型

参考官方文档: 通过关系型数据库实现数据持久化 (ArkTS)

项目: AI守护星 ——智能老人监护系统


一、前言

本文记录了在 HarmonyOS APP(Stage 模型 + ArkTS)中从零部署关系型数据库(RDB)的完整过程,包含官方文档未提及的多个真实踩坑,覆盖:

  • throw err 在 ArkTS 严格模式下的类型报错
  • Preferences.putSync().flush() 链式调用失败
  • getContext(this) 在页面组件中获取的 Context 类型错误导致数据库不创建
  • 真机上数据库文件路径与 DevEco 数据库检查器的连接方式
  • 数据库初始化与页面加载的竞态条件

如果你只想看最终可用的代码,可以直接跳到第三节。


二、基础概念

2.1 什么是 RDB(关系型数据库)

HarmonyOS 的 RDB 底层是 SQLite,通过 @kit.ArkData 包的 relationalStore 模块操作。数据以表格形式存储,支持完整的 SQL 增删改查。

支持的 ArkTS 数据类型:numberstring、二进制(Uint8Array)、booleanbigint

⚠️ 一条数据建议不超过 2MB,超过则插入成功但读取失败。

2.2 沙箱路径是什么?在哪里?

鸿蒙给每个 APP 分配私有目录,其他 APP 无法访问:

/data/app/el2/100/base/<bundleName>/
  ├── databases/
  │   ├── GuardianStar.db        ← 数据库文件
  │   ├── GuardianStar.db-wal    ← WAL 日志(正常现象)
  │   └── GuardianStar.db-shm    ← 共享内存(正常现象)
  ├── files/
  ├── cache/
  └── preferences/               ← Preferences 持久化目录

如何查看(真机): DevEco Studio → 数据库检查器 → 选择设备和应用包名 → 连接数据库 → 展开 RDB → 刷新表。

注意: 应用首次运行前 databases/ 目录不存在,只有成功调用 getRdbStore() 后才会自动创建。

2.3 核心 API 一览

接口 描述
getRdbStore(context, config) 获取/创建数据库,返回 RdbStore 实例
createTransaction() 创建事务对象,保证多操作原子性
execute(sql) 执行 SQL 语句(建表、PRAGMA 等)
insert(table, values) 插入一行数据,返回 rowId
update(values, predicates) 按条件更新数据
delete(predicates) 按条件删除数据
query(predicates, columns) 查询,返回 ResultSet
querySql(sql, args) 原生 SQL 查询
backup(name) 手动备份到同路径
restore(name) 从备份文件恢复
close() 关闭 RdbStore,释放资源

三、项目结构设计

本教程以「守护星」项目为例,需要持久化三类数据:

表名 用途 保留策略
t_user 用户账号(用户名、手机、密码哈希、头像等) 永久
t_health_event 健康事件(摔倒/久坐),含时间和严重程度 滚动 N 天
t_video_record 监控视频元数据(路径、时长、大小) 滚动 N 天

新建目录 entry/src/main/ets/database/,在其中创建 DatabaseHelper.ets


四、完整代码

4.1 DatabaseHelper.ets(核心文件)

import { relationalStore } from '@kit.ArkData';
import { BusinessError } from '@kit.BasicServicesKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { Context } from '@kit.AbilityKit';

const DOMAIN = 0x0001;
const TAG = 'DatabaseHelper';
const DB_NAME = 'GuardianStar.db';

// 保留天数配置
export const FREE_RETENTION_DAYS = 7;
export const STORAGE_KEY_RETENTION = 'retentionDays';

const STORE_CONFIG: relationalStore.StoreConfig = {
  name: DB_NAME,
  securityLevel: relationalStore.SecurityLevel.S2,
  encrypt: false,
  isReadOnly: false,
};

// 建表 SQL
const SQL_CREATE_USER =
  `CREATE TABLE IF NOT EXISTS t_user (
    id            INTEGER PRIMARY KEY AUTOINCREMENT,
    username      TEXT    NOT NULL,
    phone         TEXT,
    password_hash TEXT    NOT NULL,
    avatar_path   TEXT,
    email         TEXT,
    address       TEXT,
    bind_elder_id TEXT,
    create_time   INTEGER NOT NULL
  )`;

const SQL_CREATE_HEALTH_EVENT =
  `CREATE TABLE IF NOT EXISTS t_health_event (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    event_type TEXT    NOT NULL,
    event_time INTEGER NOT NULL,
    date_str   TEXT    NOT NULL,
    time_str   TEXT    NOT NULL,
    severity   INTEGER NOT NULL DEFAULT 1,
    is_handled INTEGER NOT NULL DEFAULT 0,
    remark     TEXT
  )`;

const SQL_CREATE_VIDEO_RECORD =
  `CREATE TABLE IF NOT EXISTS t_video_record (
    id             INTEGER PRIMARY KEY AUTOINCREMENT,
    file_path      TEXT    NOT NULL,
    thumbnail_path TEXT,
    duration_sec   INTEGER NOT NULL DEFAULT 0,
    file_size_kb   INTEGER NOT NULL DEFAULT 0,
    record_time    INTEGER NOT NULL,
    date_str       TEXT    NOT NULL,
    is_exported    INTEGER NOT NULL DEFAULT 0
  )`;

// 数据接口
export interface UserInfo {
  id?: number;
  username: string;
  phone?: string;
  passwordHash: string;
  avatarPath?: string;
  email?: string;
  address?: string;
  bindElderId?: string;
  createTime: number;
}

export interface HealthEvent {
  id?: number;
  eventType: string;   // 'fall' | 'sedentary'
  eventTime: number;
  dateStr: string;
  timeStr: string;
  severity: number;    // 1-低 2-中 3-高
  isHandled: number;
  remark?: string;
}

export interface VideoRecord {
  id?: number;
  filePath: string;
  thumbnailPath?: string;
  durationSec: number;
  fileSizeKb: number;
  recordTime: number;
  dateStr: string;
  isExported: number;
}

export class DatabaseHelper {
  private static instance: DatabaseHelper;
  private store: relationalStore.RdbStore | undefined = undefined;
  private initPromise: Promise<void> | undefined = undefined;

  private constructor() {}

  public static getInstance(): DatabaseHelper {
    if (!DatabaseHelper.instance) DatabaseHelper.instance = new DatabaseHelper();
    return DatabaseHelper.instance;
  }

  /** 获取保留天数(从 AppStorage 读,默认 7 天) */
  public getRetentionDays(): number {
    const v = AppStorage.get<number>(STORAGE_KEY_RETENTION);
    return (v !== undefined && v > 0) ? v : FREE_RETENTION_DAYS;
  }

  /** 设置保留天数 */
  public setRetentionDays(days: number): void {
    AppStorage.setOrCreate<number>(STORAGE_KEY_RETENTION, days);
  }

  /** 幂等初始化(可多次安全调用) */
  public async init(context: Context): Promise<void> {
    if (this.store !== undefined) return;
    if (this.initPromise !== undefined) return this.initPromise;
    this.initPromise = this._doInit(context);
    return this.initPromise;
  }

  private async _doInit(context: Context): Promise<void> {
    try {
      this.store = await relationalStore.getRdbStore(context, STORE_CONFIG);
      hilog.info(DOMAIN, TAG, 'RdbStore 获取成功');
    } catch (e) {
      const err = e as BusinessError;
      hilog.error(DOMAIN, TAG, `getRdbStore 失败 code=${err.code}`);
      // ✅ ArkTS 严格模式:throw 只能抛出 Error 类型,不能直接 throw err
      throw new Error(err.code + ': ' + err.message);
    }
    await this.migrateDatabase();
  }

  private async migrateDatabase(): Promise<void> {
    if (!this.store) return;
    const tx = await this.store.createTransaction({});
    try {
      let version = (await tx.execute('PRAGMA user_version')) as number;
      if (version === 0) {
        await tx.execute(SQL_CREATE_USER);
        await tx.execute(SQL_CREATE_HEALTH_EVENT);
        await tx.execute(SQL_CREATE_VIDEO_RECORD);
        await tx.execute('PRAGMA user_version = 1');
        hilog.info(DOMAIN, TAG, '数据库升级 0 → 1 完成');
      }
      // 版本 N → N+1:在此追加 if (version === N) { ... } 分支
      await tx.commit();
    } catch (e) {
      const err = e as BusinessError;
      await tx.rollback();
      throw new Error(err.code + ': ' + err.message);
    }
  }

  private getStore(): relationalStore.RdbStore {
    if (!this.store) throw new Error('DatabaseHelper 未初始化');
    return this.store;
  }

  // ── 用户 CRUD ─────────────────────────────────
  public async insertUser(user: UserInfo): Promise<number> {
    const values: relationalStore.ValuesBucket = {
      username: user.username, phone: user.phone ?? null,
      password_hash: user.passwordHash, avatar_path: user.avatarPath ?? null,
      email: user.email ?? null, address: user.address ?? null,
      bind_elder_id: user.bindElderId ?? null, create_time: user.createTime,
    };
    try {
      return await this.getStore().insert('t_user', values);
    } catch (e) {
      const err = e as BusinessError;
      throw new Error(err.code + ': ' + err.message);
    }
  }

  public async queryUserByUsername(username: string): Promise<UserInfo | null> {
    const predicates = new relationalStore.RdbPredicates('t_user');
    predicates.equalTo('username', username);
    let rs: relationalStore.ResultSet | undefined;
    try {
      rs = await this.getStore().query(predicates,
        ['id','username','phone','password_hash','avatar_path',
         'email','address','bind_elder_id','create_time']);
      if (rs.goToNextRow()) {
        return {
          id: rs.getLong(rs.getColumnIndex('id')),
          username: rs.getString(rs.getColumnIndex('username')),
          phone: rs.getString(rs.getColumnIndex('phone')),
          passwordHash: rs.getString(rs.getColumnIndex('password_hash')),
          avatarPath: rs.getString(rs.getColumnIndex('avatar_path')),
          email: rs.getString(rs.getColumnIndex('email')),
          address: rs.getString(rs.getColumnIndex('address')),
          bindElderId: rs.getString(rs.getColumnIndex('bind_elder_id')),
          createTime: rs.getLong(rs.getColumnIndex('create_time')),
        };
      }
      return null;
    } finally {
      rs?.close(); // ⚠️ ResultSet 必须 close,否则内存泄漏
    }
  }

  // ── 健康事件 CRUD(滚动清理) ─────────────────
  public async insertHealthEvent(event: HealthEvent): Promise<number> {
    // 写入前自动清除超出保留天数的旧数据(滚动覆盖核心逻辑)
    const cutoff = event.eventTime - this.getRetentionDays() * 86400000;
    const oldPred = new relationalStore.RdbPredicates('t_health_event');
    oldPred.lessThan('event_time', cutoff);
    try { await this.getStore().delete(oldPred); } catch (_) {}

    const values: relationalStore.ValuesBucket = {
      event_type: event.eventType, event_time: event.eventTime,
      date_str: event.dateStr, time_str: event.timeStr,
      severity: event.severity, is_handled: event.isHandled,
      remark: event.remark ?? null,
    };
    try {
      return await this.getStore().insert('t_health_event', values);
    } catch (e) {
      const err = e as BusinessError;
      throw new Error(err.code + ': ' + err.message);
    }
  }

  public async queryHealthEvents(eventType?: string): Promise<HealthEvent[]> {
    const cutoff = Date.now() - this.getRetentionDays() * 86400000;
    const predicates = new relationalStore.RdbPredicates('t_health_event');
    predicates.greaterThanOrEqualTo('event_time', cutoff);
    if (eventType) predicates.equalTo('event_type', eventType);
    predicates.orderByDesc('event_time');
    let rs: relationalStore.ResultSet | undefined;
    try {
      rs = await this.getStore().query(predicates,
        ['id','event_type','event_time','date_str','time_str','severity','is_handled','remark']);
      const list: HealthEvent[] = [];
      while (rs.goToNextRow()) {
        list.push({
          id: rs.getLong(rs.getColumnIndex('id')),
          eventType: rs.getString(rs.getColumnIndex('event_type')),
          eventTime: rs.getLong(rs.getColumnIndex('event_time')),
          dateStr: rs.getString(rs.getColumnIndex('date_str')),
          timeStr: rs.getString(rs.getColumnIndex('time_str')),
          severity: rs.getLong(rs.getColumnIndex('severity')),
          isHandled: rs.getLong(rs.getColumnIndex('is_handled')),
          remark: rs.getString(rs.getColumnIndex('remark')),
        });
      }
      return list;
    } finally {
      rs?.close();
    }
  }

  public async clearAllHealthEvents(): Promise<void> {
    const predicates = new relationalStore.RdbPredicates('t_health_event');
    predicates.notEqualTo('id', -1);
    await this.getStore().delete(predicates);
  }

  // ── 备份 / 恢复 / 关闭 ───────────────────────
  public async backup(): Promise<void> {
    await (this.getStore() as relationalStore.RdbStore).backup('GuardianStar_backup.db');
  }

  public async restore(): Promise<void> {
    await (this.getStore() as relationalStore.RdbStore).restore('GuardianStar_backup.db');
  }

  public close(): void {
    if (this.store) {
      this.store.close();
      this.store = undefined;
      this.initPromise = undefined;
    }
  }
}

4.2 EntryAbility.ets(关键:正确的初始化时机)

import { UIAbility, Want, AbilityConstant } from '@kit.AbilityKit';
import { window } from '@kit.ArkUI';
import { DatabaseHelper } from '../database/DatabaseHelper';

export default class EntryAbility extends UIAbility {
  onWindowStageCreate(windowStage: window.WindowStage): void {
    // ✅ 正确做法:先等待数据库初始化完成,再加载页面
    // ❌ 错误做法:在 onCreate() 里异步 init,同时立刻 loadContent
    DatabaseHelper.getInstance().init(this.context)
      .then(() => {
        windowStage.loadContent('pages/Index', (err) => {
          if (err.code) console.error('加载页面失败: ' + JSON.stringify(err));
        });
      })
      .catch((e: Error) => {
        // 数据库初始化失败也继续加载页面,不影响基本功能
        console.error('DB init 失败: ' + e.message);
        windowStage.loadContent('pages/Index', () => {});
      });

    // 获取状态栏高度(与 DB init 并行,互不阻塞)
    windowStage.getMainWindow().then((win) => {
      const topHeight = win.getWindowAvoidArea(window.AvoidAreaType.TYPE_SYSTEM).topRect.height;
      AppStorage.setOrCreate('topSafeHeight', px2vp(topHeight) > 0 ? px2vp(topHeight) : 48);
    }).catch(() => AppStorage.setOrCreate('topSafeHeight', 48));
  }

  onDestroy(): void {
    DatabaseHelper.getInstance().close(); // 释放 RdbStore 资源
  }
}

4.3 登录页:账号注册 + 写库 + 持久化登录态

import { preferences } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';
import { DatabaseHelper, UserInfo } from '../database/DatabaseHelper';

const PREF_NAME = 'guardian_auth';
const PREF_KEY_USERNAME = 'lastUsername';

// 接口:ArkTS 禁止 object literal 作为 Promise 返回值,需要显式声明
interface LoginResult {
  name: string;
  isNew: boolean;
}

// 登录/注册核心逻辑
private handleLogin(): void {
  const uname = this.username.trim();
  const pwd = this.password.trim();
  if (uname === '' || pwd.length < 4) return;

  const db = DatabaseHelper.getInstance();
  db.queryUserByUsername(uname).then((existing: UserInfo | null) => {
    if (existing === null) {
      // 新用户:自动注册写库
      return db.insertUser({
        username: uname, passwordHash: pwd, createTime: Date.now()
      }).then(() => {
        const r: LoginResult = { name: uname, isNew: true };
        return r;
      });
    } else {
      if (existing.passwordHash !== pwd) {
        this.showAlert('密码不正确');
        return Promise.reject('wrong_password');
      }
      const r: LoginResult = { name: uname, isNew: false };
      return Promise.resolve(r);
    }
  }).then((result: LoginResult) => {
    // 显示登录/注册成功弹窗
    AlertDialog.show({
      title: result.isNew ? '🎉 注册成功' : '✅ 登录成功',
      message: result.isNew
        ? `欢迎加入!账号「${result.name}」已创建成功`
        : `欢迎回来!账号「${result.name}」登录成功`,
      primaryButton: {
        value: '进入应用',
        action: () => this.saveLoginAndJump(result.name)
      },
      alignment: DialogAlignment.Center,
      autoCancel: false,
    });
  }).catch((e: string | Error) => {
    if (e !== 'wrong_password') this.showAlert('登录失败,请重试');
  });
}

// 持久化登录态:写入 Preferences
private saveLoginAndJump(username: string): void {
  try {
    const ctx = getContext(this) as common.UIAbilityContext;
    // ✅ 正确:声明 Options 类型,分开调用 putSync 和 flush
    // ❌ 错误:{ name: PREF_NAME } 字面量会报 arkts-no-untyped-obj-literals
    // ❌ 错误:putSync().flush() 链式调用,putSync 返回 void 没有 flush 方法
    const options: preferences.Options = { name: PREF_NAME };
    const pref = preferences.getPreferencesSync(ctx, options);
    pref.putSync(PREF_KEY_USERNAME, username);
    pref.flush();
  } catch (e) {
    console.warn('持久化失败: ' + JSON.stringify(e));
  }
  this.pathStack.replacePathByName('Layout', {}, false);
}

4.4 Index.ets:启动自动登录检测

import { common } from '@kit.AbilityKit';
import { preferences } from '@kit.ArkData';

// 数据库已在 EntryAbility.onWindowStageCreate 初始化完毕
// Index 只做登录态检测,无需再次 init
private checkAutoLogin(): void {
  try {
    const ctx = getContext(this) as common.UIAbilityContext;
    const options: preferences.Options = { name: 'guardian_auth' };
    const pref = preferences.getPreferencesSync(ctx, options);
    const savedUser = pref.getSync('lastUsername', '') as string;

    if (savedUser !== '') {
      this.pathStack.pushPathByName('Layout', null, false); // 自动登录
    } else {
      this.pathStack.pushPathByName('Login', null, false);
    }
  } catch (e) {
    this.pathStack.pushPathByName('Login', null, false);
  }
}

五、踩坑记录(核心!)

❗ 坑1:throw err 在 ArkTS 严格模式下报错

报错: "throw" statements cannot accept values of arbitrary types (arkts-limited-throw)

原因: ArkTS 要求 throw 只能抛出 Error 类型或其子类。BusinessError 不继承自标准 Error,不能直接 throw。

// ❌ 错误写法
catch (e) {
  const err = e as BusinessError;
  throw err; // ArkTSCheck 报错
}

// ✅ 正确写法
catch (e) {
  const err = e as BusinessError;
  throw new Error(err.code + ': ' + err.message);
}

❗ 坑2:Preferences.putSync().flush() 链式调用失败

报错: Property 'flush' does not exist on type 'void'

原因: putSync() 返回类型是 void,不支持链式调用。deleteSync() 同理。

// ❌ 错误写法
preferences.getPreferencesSync(ctx, { name: 'auth' })
  .putSync('key', 'value')
  .flush(); // void 没有 flush 方法

// ✅ 正确写法:拆成三行
const options: preferences.Options = { name: 'auth' }; // 必须显式类型
const pref = preferences.getPreferencesSync(ctx, options);
pref.putSync('key', 'value');
pref.flush();

❗ 坑3:对象字面量报 arkts-no-untyped-obj-literals

报错: Object literal must correspond to some explicitly declared class or interface

原因: ArkTS 严格模式下,任何对象字面量都必须对应一个已声明的接口。

// ❌ 错误:匿名对象字面量作为参数
preferences.getPreferencesSync(ctx, { name: 'auth' });

// ✅ 正确:声明接口类型后再传入
const options: preferences.Options = { name: 'auth' };
preferences.getPreferencesSync(ctx, options);

// ❌ 错误:Promise 中返回匿名对象
return Promise.resolve({ name: uname, isNew: false });

// ✅ 正确:声明 interface 后使用
interface LoginResult { name: string; isNew: boolean; }
const r: LoginResult = { name: uname, isNew: false };
return Promise.resolve(r);

❗ 坑4(最坑):数据库初始化竞态 → 真机无 databases 目录

现象: DevEco 数据库检查器显示「未找到数据库文件」,hdc shell 查看沙箱路径确认 databases/ 目录不存在。

根本原因: 初始化时序错误。

// ❌ 错误的初始化位置(onCreate 里异步,页面几乎同时加载)
EntryAbility.onCreate()
  └── DatabaseHelper.init(this.context)  ← 异步,后台慢慢跑
EntryAbility.onWindowStageCreate()
  └── loadContent('pages/Index')         ← 几乎同时触发!
        └── Index.onAppear()
              └── DB.init(getContext(this))  ← 页面的 context 类型不对!

两个子问题:

  1. getContext(this) 在页面(@Component)里返回的是 UIContext,不是 UIAbilityContext,导致 getRdbStore 无法在正确的沙箱路径创建文件。
  2. 即使 context 类型对了,页面加载和 DB init 是并发的,存在竞态。

✅ 正确做法:在 onWindowStageCreate 里,等 DB init 完成后再 loadContent。

// ✅ 正确:loadContent 在 DB init 的 .then() 回调里
onWindowStageCreate(windowStage: window.WindowStage): void {
  DatabaseHelper.getInstance().init(this.context).then(() => {
    windowStage.loadContent('pages/Index', () => {});
  }).catch(() => {
    windowStage.loadContent('pages/Index', () => {}); // 失败也要加载
  });
}

❗ 坑5:数据库检查器显示「无表数据」但不是「未找到文件」

现象: 路径正确,数据库文件存在,但点开后显示「无表数据」。

原因: 第一次运行成功创建了 databases/ 目录,但建表之前就已经跳转到了主页面,说明 migrateDatabase() 还没执行完(或 context 错误导致建表失败但文件已创建空数据库)。

排查步骤:

  1. 退出应用
  2. hdc shell 检查文件是否存在:ls -la /data/app/el2/100/base/com.example.xxx/databases/
  3. 如果文件存在但空:确认 EntryAbility 里初始化顺序是否正确
  4. 重新安装应用(uninstall + install)让数据库从头创建

❗ 坑6:showActionMenu 的 buttons 类型不兼容

报错: Type '{ text: string; color: string; }[]' is not assignable to type '[Button, Button?, ...]'

原因: promptAction.showActionMenubuttons 参数是严格的 Tuple 类型,不接受 .map() 生成的普通数组,且不支持 color 字段。

解决: 改用 promptAction.showDialog,buttons 写成固定数组字面量。

// ❌ showActionMenu + map 生成数组 → 类型不兼容
showActionMenu({ buttons: options.map(d => ({ text: d + '天', color: '#2563eb' })) })

// ✅ showDialog + 固定字面量数组
showDialog({
  title: '选择保留天数',
  buttons: [
    { text: '7天(免费)', color: '#2563eb' },
    { text: '14天 👑', color: '#64748b' },
  ]
})

六、数据保留策略设计

「滚动覆盖」的正确实现:写入时触发删除,而不是查询时过滤。

第1天写入 → DB 存第1天数据
第2天写入 → DB 存第1-2天数据
...
第7天写入 → DB 存第1-7天数据
第8天写入 → 先删第1天,再写第8天 → DB 始终只有7天

核心代码(insertHealthEvent 写入前自动清理):

// 写入前删除超出保留期的旧数据
const cutoff = event.eventTime - this.getRetentionDays() * 86400000;
const pred = new relationalStore.RdbPredicates('t_health_event');
pred.lessThan('event_time', cutoff);
await this.getStore().delete(pred);
// 然后再 insert

在 APP 内提供天数设置(免费/会员):

  • 天数设置存入 AppStorage(全局可读,重启后由 Preferences 或数据库恢复)
  • 7天免费,更多天数引导开通会员

七、数据库版本升级

当需要修改表结构时,不能重建表,需要用 ALTER TABLE 并递增 user_version

private async migrateDatabase(): Promise<void> {
  const tx = await this.store!.createTransaction({});
  try {
    let version = (await tx.execute('PRAGMA user_version')) as number;

    // v0 → v1:建立初始表
    if (version === 0) {
      await tx.execute(SQL_CREATE_USER);
      await tx.execute(SQL_CREATE_HEALTH_EVENT);
      await tx.execute(SQL_CREATE_VIDEO_RECORD);
      await tx.execute('PRAGMA user_version = 1');
      version = 1;
    }

    // v1 → v2:t_user 表新增 nickname 列
    if (version === 1) {
      await tx.execute('ALTER TABLE t_user ADD COLUMN nickname TEXT');
      await tx.execute('PRAGMA user_version = 2');
      version = 2;
    }

    // 继续追加...
    await tx.commit();
  } catch (e) {
    await tx.rollback();
    throw new Error((e as BusinessError).message);
  }
}

⚠️ 推荐使用事务包裹整个迁移过程,保证升级的原子性。


八、用 hdc 命令行排查数据库问题

真机调试时,如果数据库检查器无法使用,可以用 hdc 命令排查:

# 1. 检查设备连接
hdc list targets

# 2. 查找你的应用 bundle name
hdc shell bm dump -a | grep 你的应用关键字

# 3. 检查沙箱目录结构
hdc shell ls -la /data/app/el2/100/base/com.example.yourapp/

# 4. 检查 databases 目录是否存在
hdc shell ls /data/app/el2/100/base/com.example.yourapp/databases/

# 5. 如果有数据库文件,拉到本地用 SQLite 工具查看
hdc file recv /data/app/el2/100/base/com.example.yourapp/databases/GuardianStar.db .\\GuardianStar.db

hdc.exe 路径:{DevEco Studio 安装目录}\\sdk\\default\\openharmony\\toolchains\\hdc.exe


九、完整数据流

APP 启动
  └── EntryAbility.onWindowStageCreate()
        └── DatabaseHelper.init(this.context)   ← 用 Ability context!
              ├── getRdbStore() → 创建 databases/GuardianStar.db
              └── migrateDatabase() → 建表 → commit
                    ↓ 完成后
              └── windowStage.loadContent('pages/Index')
                    └── Index.checkAutoLogin()
                          ├─ Preferences 有记录 → 直接进主界面
                          └─ 无记录 → Login 页

用户登录
  └── Login.handleLogin()
        ├── queryUserByUsername()  → 查 t_user
        ├── 无记录 → insertUser()  → 写 t_user
        ├── 有记录 → 校验密码
        └── 成功 → Preferences.putSync() → 持久化登录态

MQTT 收到健康告警
  └── MqttManager.addFallRecord()
        ├── 更新内存数组(UI 实时响应)
        └── DatabaseHelper.insertHealthEvent()  ← 异步写库,不阻塞 UI
              └── 先删 N 天前旧数据(滚动覆盖)
              └── insert 新记录

退出登录
  └── Preferences.deleteSync('lastUsername') → flush()
  └── AppStorage 清空
  └── 跳转 Login 页

十、小结

注意事项 说明
throw 类型限制 ArkTS 只能 throw Error,不能 throw BusinessError
Preferences 链式调用 putSync/deleteSync 返回 void,flush 必须另起一行
对象字面量 所有对象字面量必须有对应的 interface 声明
数据库初始化位置 必须在 onWindowStageCreate 里,loadContent 放在 .then() 里
Context 类型 只有 EntryAbility.this.context 是正确的 UIAbilityContext
ResultSet 关闭 查询后必须在 finally 里 close(),否则内存泄漏
滚动覆盖 写入时删除旧数据,不是查询时过滤
版本升级 用 PRAGMA user_version + ALTER TABLE,事务包裹

完整项目代码见:AI守护星 GitHub 仓库

如有问题欢迎评论区交流 🙌

Logo

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

更多推荐