HarmonyOS 本地存储实战:用一个记账本案例吃透 RDB 与 KVStore

HarmonyOS 本地存储实战:用一个记账本案例吃透 RDB 与 KVStore

    • 一、为什么我建议你用"记账本"来学本地存储
    • 二、先看最终案例:一个双存储职责拆分的轻账本
    • [三、RDB 负责结构化数据:把账单明细稳稳接住](#三、RDB 负责结构化数据:把账单明细稳稳接住)
      • [1. 建表逻辑](#1. 建表逻辑)
      • [2. 插入和查询,不只是"会用 API",而是要能喂给页面](#2. 插入和查询,不只是“会用 API”,而是要能喂给页面)
      • [3. 删除动作为什么也值得单独封装](#3. 删除动作为什么也值得单独封装)
    • [四、KVStore 负责轻量偏好:别让昵称和预算也去"占表"](#四、KVStore 负责轻量偏好:别让昵称和预算也去“占表”)
      • [1. 初始化 KVStore](#1. 初始化 KVStore)
      • [2. 轻量存储的读写方式](#2. 轻量存储的读写方式)
    • 五、真正的关键不是存进去,而是页面怎么和存储联动
      • [1. RDB 页面初始化:先拉偏好,再拉账单](#1. RDB 页面初始化:先拉偏好,再拉账单)
      • [2. 新增账单后怎么刷新列表](#2. 新增账单后怎么刷新列表)
      • [3. 为什么筛选逻辑放页面层更合理](#3. 为什么筛选逻辑放页面层更合理)
    • [六、最后补几条实战经验:这部分往往比 API 本身更值钱](#六、最后补几条实战经验:这部分往往比 API 本身更值钱)
      • [1. 什么时候选 RDB,什么时候选 KVStore](#1. 什么时候选 RDB,什么时候选 KVStore)
      • [2. 金额字段在正式项目里更建议用"分"](#2. 金额字段在正式项目里更建议用“分”)
      • [3. ResultSet 一定要及时释放](#3. ResultSet 一定要及时释放)
      • [4. 本地存储不是"存进去"就结束,设计的是数据生命周期](#4. 本地存储不是“存进去”就结束,设计的是数据生命周期)
    • 写在最后

做鸿蒙应用时,本地存储几乎绕不过去。

你可能会遇到这些非常真实的场景:

  • 用户刚打开 App,就想看到上次留下来的数据,而不是一片空白。
  • 页面上有些数据结构很稳定,比如订单、账单、待办列表,这类数据显然适合"表结构"。
  • 页面上还有一类数据很轻,比如用户昵称、开关状态、搜索历史、预算值,这时候如果还强行建表,成本就显得有点高。

所以问题来了:

鸿蒙里到底什么时候该用关系型数据库,什么时候该用键值型存储?

这篇文章我不打算只讲 API,而是基于官方示例写一个"轻账本"应用,然后带你从 0 到 1 把 HarmonyOS 本地存储真正串起来。

这篇文章你会拿到 3 个东西:

  1. 一个可讲、可跑、可扩展的本地存储案例。
  2. 一套 RDB + KVStore 的职责拆分思路。
  3. 一些比 API 本身更重要的实战经验。

一、为什么我建议你用"记账本"来学本地存储

很多本地存储 Demo 最大的问题,不是 API 写错了,而是场景太弱

比如最常见的示例就是:

  • 往数据库里插一个用户名
  • 再查出来
  • 再删掉

当然,这样能说明 API 能用,但它很难让人真正建立"设计意识"。

而记账本这个场景刚好很适合教学:

  • 账单明细是典型的结构化数据,字段稳定,适合放到 RDB。
  • 用户昵称、月预算、提示语这类轻量配置,不需要建表,适合放到 KVStore。
  • 页面上又天然有"新增、删除、查询、统计、筛选、回显"这几类高频动作,非常适合串起完整的数据流。

所以我们这次的设计很明确:

  • RDB 负责账单明细
  • KVStore 负责偏好设置
  • 页面层只关心"怎么展示"和"什么时候刷新"

这个拆法的价值在于:你写的不是一个 Demo,而是一套以后真的能落地到业务里的存储思路。


二、先看最终案例:一个双存储职责拆分的轻账本

这个项目里,我把核心代码拆成了下面几块:

text 复制代码
entry/src/main/ets
├─ constant
│  └─ Constants.ets
├─ models
│  ├─ AppPreference.ets
│  └─ BillRecord.ets
├─ pages
│  ├─ MainPage.ets
│  ├─ KvStorePage.ets
│  └─ RdbStorePage.ets
└─ store
   ├─ LedgerRdbStore.ets
   └─ PreferenceKvStore.ets

这里有一个非常关键的思路:不要把数据库调用散落到页面各个角落。

如果你直接在页面里写:

  • 建表
  • 插入
  • 查询
  • 删除
  • KV 初始化
  • 偏好写入

页面会很快膨胀成一坨。后续你要写博客、讲课、扩展功能,都会非常吃力。

所以我把两类存储分别封装成两个 store:

  • LedgerRdbStore.ets:专门负责账单表
  • PreferenceKvStore.ets:专门负责偏好数据

页面层做的事情就简单很多:

  • 账本页 RdbStorePage:负责展示账单、统计金额、打开录入弹窗、触发刷新
  • 偏好页 KvStorePage:负责展示昵称/预算/提示语,并支持编辑

这套分层其实已经很接近真实项目了。


三、RDB 负责结构化数据:把账单明细稳稳接住

先说为什么账单一定更适合放 RDB。

因为账单天然有明确字段:

  • id
  • title
  • amount
  • type
  • category
  • billDate
  • note

这种数据你后面大概率还会做:

  • 按日期排序
  • 按类型筛选
  • 按标题搜索
  • 做月度汇总
  • 做统计报表

这时候用关系型数据库比 KVStore 更顺手。

1. 建表逻辑

下面这段代码来自我们项目里的真实实现,负责初始化本地数据库并创建账单表:

ts 复制代码
import { common } from '@kit.AbilityKit';
import { relationalStore } from '@kit.ArkData';
import { BusinessError } from '@kit.BasicServicesKit';
import { BillRecord } from '../models/BillRecord';

const LEDGER_TABLE_NAME: string = 'ledger_bills';
const LEDGER_COLUMNS: string[] = ['id', 'title', 'amount', 'type', 'category', 'billDate', 'note'];

class LedgerRdbStore {
  private rdbStore: relationalStore.RdbStore | null = null;

  init(context: common.UIAbilityContext, onReady: () => void): void {
    if (this.rdbStore !== null) {
      onReady();
      return;
    }
    const storeConfig: relationalStore.StoreConfig = {
      name: 'ledger_book.db',
      securityLevel: relationalStore.SecurityLevel.S1
    };
    relationalStore.getRdbStore(context, storeConfig, (err, store) => {
      if (err) {
        console.error(`Failed to get RdbStore. Code:${err.code}, message:${err.message}`);
        return;
      }
      this.rdbStore = store;
      try {
        this.rdbStore.executeSql(
          `CREATE TABLE IF NOT EXISTS ${LEDGER_TABLE_NAME} (` +
          'id INTEGER PRIMARY KEY AUTOINCREMENT, ' +
          'title TEXT NOT NULL, ' +
          'amount REAL NOT NULL, ' +
          'type TEXT NOT NULL, ' +
          'category TEXT NOT NULL, ' +
          'billDate TEXT NOT NULL, ' +
          'note TEXT)'
        );
        onReady();
      } catch (error) {
        const businessError = error as BusinessError;
        console.error(`Failed to create table. Code:${businessError.code}, message:${businessError.message}`);
      }
    });
  }
}

这里有 3 个点值得你记住:

  1. CREATE TABLE IF NOT EXISTS 很重要,这能避免应用重复启动时反复建表报错。
  2. 我把 rdbStore 缓存在类里,避免页面每次打开都重新初始化。
  3. 表结构设计时尽量把"业务最稳定的字段"先定下来,后续扩表会更从容。

2. 插入和查询,不只是"会用 API",而是要能喂给页面

插入账单的逻辑如下:

ts 复制代码
insertBill(title: string, amount: number, type: string, category: string, billDate: string, note: string,
  onComplete: (success: boolean) => void): void {
  if (this.rdbStore === null) {
    onComplete(false);
    return;
  }
  const value: relationalStore.ValuesBucket = {
    title: title,
    amount: amount,
    type: type,
    category: category,
    billDate: billDate,
    note: note
  };
  try {
    this.rdbStore.insert(LEDGER_TABLE_NAME, value);
    onComplete(true);
  } catch (error) {
    const businessError = error as BusinessError;
    console.error(`Failed to insert bill. Code:${businessError.code}, message:${businessError.message}`);
    onComplete(false);
  }
}

查询全部账单的逻辑如下:

ts 复制代码
queryAll(onResult: (bills: BillRecord[]) => void): void {
  if (this.rdbStore === null) {
    onResult([]);
    return;
  }
  const predicates = new relationalStore.RdbPredicates(LEDGER_TABLE_NAME);
  this.rdbStore.query(predicates, LEDGER_COLUMNS, (err: BusinessError, resultSet) => {
    if (err) {
      console.error(`Failed to query bills. Code:${err.code}, message:${err.message}`);
      onResult([]);
      return;
    }
    const bills: BillRecord[] = [];
    while (resultSet.goToNextRow()) {
      bills.push(new BillRecord(
        resultSet.getLong(resultSet.getColumnIndex('id')),
        resultSet.getString(resultSet.getColumnIndex('title')),
        resultSet.getDouble(resultSet.getColumnIndex('amount')),
        resultSet.getString(resultSet.getColumnIndex('type')),
        resultSet.getString(resultSet.getColumnIndex('category')),
        resultSet.getString(resultSet.getColumnIndex('billDate')),
        resultSet.getString(resultSet.getColumnIndex('note'))
      ));
    }
    resultSet.close();
    bills.sort((left: BillRecord, right: BillRecord) => {
      if (left.billDate === right.billDate) {
        return right.id - left.id;
      }
      return left.billDate < right.billDate ? 1 : -1;
    });
    onResult(bills);
  });
}

这段查询代码里,我比较看重两个细节:

  • resultSet.close() 一定别忘,很多初学者会在这一步留下隐患。
  • 查询结果不要原样塞给页面,最好在 store 层就做一轮排序和模型转换。

换句话说,页面层应该拿到"可以直接渲染的数据",而不是"数据库原始结果集"。

3. 删除动作为什么也值得单独封装

真实业务里,删除通常会带条件、确认弹窗、列表刷新,甚至埋点。

所以我没有把删除逻辑直接塞在列表组件里,而是单独放到 store 里:

ts 复制代码
deleteBill(id: number, onComplete: (success: boolean) => void): void {
  if (this.rdbStore === null) {
    onComplete(false);
    return;
  }
  const predicates = new relationalStore.RdbPredicates(LEDGER_TABLE_NAME);
  predicates.equalTo('id', id.toString());
  try {
    this.rdbStore.delete(predicates);
    onComplete(true);
  } catch (error) {
    const businessError = error as BusinessError;
    console.error(`Failed to delete bill. Code:${businessError.code}, message:${businessError.message}`);
    onComplete(false);
  }
}

这件事看起来不大,但它会直接影响你代码后期的可维护性。


四、KVStore 负责轻量偏好:别让昵称和预算也去"占表"

接下来我们看另外一类数据:

  • 账本昵称
  • 月预算
  • 提醒语

这类数据的特点是:

  • 字段少
  • 关系弱
  • 更新频率低
  • 不需要复杂查询

这时候用 KVStore 就非常合适。

1. 初始化 KVStore

下面是项目中的真实代码:

ts 复制代码
import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { distributedKVStore } from '@kit.ArkData';
import { AppPreference } from '../models/AppPreference';

const BUNDLE_NAME: string = 'com.example.localdatabase';
const STORE_ID: string = 'ledger_preference_store';
const KEY_NICKNAME: string = 'pref_nickname';
const KEY_BUDGET: string = 'pref_monthly_budget';
const KEY_MOTTO: string = 'pref_motto';

class PreferenceKvStore {
  private kvStore: distributedKVStore.SingleKVStore | undefined = undefined;
  private kvManager: distributedKVStore.KVManager | undefined = undefined;

  init(context: common.UIAbilityContext, onReady: () => void): void {
    if (this.kvStore !== undefined) {
      onReady();
      return;
    }
    const kvManagerConfig: distributedKVStore.KVManagerConfig = {
      context: context,
      bundleName: BUNDLE_NAME
    };
    try {
      this.kvManager = distributedKVStore.createKVManager(kvManagerConfig);
      const options: distributedKVStore.Options = {
        createIfMissing: true,
        encrypt: false,
        backup: true,
        autoSync: false,
        kvStoreType: distributedKVStore.KVStoreType.SINGLE_VERSION,
        securityLevel: distributedKVStore.SecurityLevel.S1
      };
      this.kvManager.getKVStore<distributedKVStore.SingleKVStore>(STORE_ID, options, (err, store) => {
        if (err) {
          console.error(`Failed to get KVStore. Code:${err.code}, message:${err.message}`);
          return;
        }
        this.kvStore = store;
        onReady();
      });
    } catch (error) {
      const businessError = error as BusinessError;
      console.error(`Failed to create KVManager. Code:${businessError.code}, message:${businessError.message}`);
    }
  }
}

这里我特别想提醒一个真实踩坑点:

bundleName 一定要和工程实际包名一致。

我们这次改项目的时候,就遇到过一个典型问题:原示例里遗留了旧的 bundleName,导致 KVStore 初始化失败。这个错误不算复杂,但很隐蔽,实际项目里非常常见。

2. 轻量存储的读写方式

偏好读取逻辑如下:

ts 复制代码
loadPreferences(onResult: (preference: AppPreference) => void): void {
  if (this.kvStore === undefined) {
    onResult(this.defaultPreference());
    return;
  }
  const query = new distributedKVStore.Query();
  this.kvStore.getResultSet(query, (err, resultSet) => {
    if (err) {
      console.error(`Failed to query preferences. Code:${err.code}, message:${err.message}`);
      onResult(this.defaultPreference());
      return;
    }
    let nickname: string = 'XXX';
    let monthlyBudget: number = 5000;
    let motto: string = 'XXX';
    if (resultSet !== null) {
      resultSet.moveToFirst();
      while (!resultSet.isAfterLast()) {
        const entry = resultSet.getEntry();
        if (entry.key === KEY_NICKNAME) {
          nickname = entry.value.value.toString();
        } else if (entry.key === KEY_BUDGET) {
          monthlyBudget = Number(entry.value.value.toString());
        } else if (entry.key === KEY_MOTTO) {
          motto = entry.value.value.toString();
        }
        resultSet.moveToNext();
      }
    }
    onResult(new AppPreference(nickname, monthlyBudget, motto));
  });
}

偏好保存逻辑如下:

ts 复制代码
savePreferences(nickname: string, monthlyBudget: number, motto: string, onComplete: (success: boolean) => void): void {
  this.putValue(KEY_NICKNAME, nickname, (nicknameSaved: boolean) => {
    if (!nicknameSaved) {
      onComplete(false);
      return;
    }
    this.putValue(KEY_BUDGET, monthlyBudget.toFixed(0), (budgetSaved: boolean) => {
      if (!budgetSaved) {
        onComplete(false);
        return;
      }
      this.putValue(KEY_MOTTO, motto, (mottoSaved: boolean) => {
        onComplete(mottoSaved);
      });
    });
  });
}

你会发现,KVStore 的心智负担明显更低:

  • 不需要建表
  • 不需要字段映射
  • 不需要关系设计

但它也有边界:

  • 不适合复杂查询
  • 不适合多维统计
  • 不适合强结构数据

所以别纠结"哪个更先进",关键是看数据形态。


五、真正的关键不是存进去,而是页面怎么和存储联动

很多教程讲本地存储,到"插入成功"就结束了。

但真实业务里,用户不关心你有没有 insert 成功,他只关心:

  • 为什么我刚记的一笔钱没刷新出来?
  • 为什么我改了预算,首页进度条没变化?
  • 为什么我删了账单,统计金额还是老数据?

所以这一步特别重要:存储层变更后,页面状态怎么联动刷新。

1. RDB 页面初始化:先拉偏好,再拉账单

账本页在 aboutToAppear 里同时初始化两类数据:

ts 复制代码
aboutToAppear(): void {
  if (this.formDate === '') {
    this.formDate = this.todayString();
  }
  preferenceKvStore.init(this.context, () => {
    this.loadPreference();
  });
  ledgerRdbStore.init(this.context, () => {
    this.reloadBills();
  });
  window.getLastWindow(this.context).then((currentWindow) => {
    currentWindow.on('avoidAreaChange', (data) => {
      if (data.type === window.AvoidAreaType.TYPE_KEYBOARD) {
        this.keyHeight = this.getUIContext().px2vp(data.area.bottomRect.height);
      }
    });
  });
}

这里其实已经体现出一个完整的数据流:

  • 偏好页改预算
  • KVStore 持久化
  • 账本页重新打开
  • 重新读取偏好
  • 统计区直接用新的预算值计算进度

2. 新增账单后怎么刷新列表

新增账单不是只调一个 insertBill 就完了,后面还要做 UI 刷新:

ts 复制代码
private saveBill(): void {
  const title = this.formTitle.trim();
  const amount = Number(this.formAmount.trim());
  const billDate = this.formDate.trim();
  if (title === '') {
    this.validationMessage = 'XXX';
    return;
  }
  if (isNaN(amount) || amount <= 0) {
    this.validationMessage = 'XXX';
    return;
  }
  if (billDate === '') {
    this.validationMessage = 'XXX';
    return;
  }
  ledgerRdbStore.insertBill(title, amount, this.formType, this.formCategory, billDate, this.formNote.trim(),
    (success: boolean) => {
      if (!success) {
        this.validationMessage = 'XXX';
        return;
      }
      this.isShowSheet = false;
      this.reloadBills();
    });
}

reloadBills() 的职责就是重新查库并同步页面状态:

ts 复制代码
private reloadBills(): void {
  ledgerRdbStore.queryAll((records: BillRecord[]) => {
    this.bills = records;
    this.applyFilters();
  });
}

这就是我很推崇的一种做法:

  • store 只管存取
  • page 只管状态同步和界面表现

这样你后面要加"编辑账单""按月分组""按分类统计",都不会太痛苦。

3. 为什么筛选逻辑放页面层更合理

我们这里的筛选是:

  • 全部
  • 支出
  • 收入
  • 搜索标题或分类

这类逻辑目前我放在页面层做:

ts 复制代码
private applyFilters(): void {
  let result: BillRecord[] = this.bills;
  if (this.activeType !== 'all') {
    result = result.filter((item: BillRecord) => item.type === this.activeType);
  }
  if (this.keyword !== '') {
    result = result.filter((item: BillRecord) => {
      return item.title.indexOf(this.keyword) > -1 || item.category.indexOf(this.keyword) > -1;
    });
  }
  this.visibleBills = result;
}

为什么不直接下沉到数据库?

因为这个 Demo 的目标是教学,当前数据量也不大。先把结构讲清楚,比一上来就把 SQL 条件拼得很复杂更重要。

等你后面数据量大了,再把筛选条件下沉到 RdbPredicates,也是很自然的一步。


六、最后补几条实战经验:这部分往往比 API 本身更值钱

文章写到这里,RDB 和 KVStore 已经都串起来了。接下来我补几条真实开发里非常有价值的经验。

1. 什么时候选 RDB,什么时候选 KVStore

你可以用一个很简单的判断法:

适合 RDB 的数据:

  • 有明确字段结构
  • 后续需要筛选、排序、统计
  • 记录数量可能持续增长

适合 KVStore 的数据:

  • 数据量小
  • 更像配置项
  • 不依赖复杂查询

一句话记忆就是:

能抽成"表"的,优先考虑 RDB;更像"配置"的,优先考虑 KVStore。

2. 金额字段在正式项目里更建议用"分"

我们这个教学案例里,金额用了 REAL,是为了讲解更直观。

但如果你在正式业务里做支付、账务、对账,建议把金额存成整数分,比如:

  • 3850 表示 38.50

这样可以规避浮点数精度问题。

也就是说,教学案例里追求易懂,生产项目里追求稳定,这两件事不冲突。

3. ResultSet 一定要及时释放

这一点再强调一次。

很多同学在查完数据库后,能成功渲染页面,就以为结束了。但长期来看,ResultSet 不及时关闭是很容易留下资源问题的。

所以这句:

ts 复制代码
resultSet.close();

不要嫌它啰嗦。

4. 本地存储不是"存进去"就结束,设计的是数据生命周期

一个成熟的本地存储设计,至少要想清楚下面几件事:

  • 应用首次启动时默认值是什么
  • 页面重新进入时要不要重查
  • 删除后如何同步统计值
  • 异常场景怎么兜底
  • 后面如果升级表结构怎么办

你会发现,真正拉开差距的从来不是 API 名字记没记住,而是你有没有把数据生命周期想清楚。


写在最后

如果你是刚接触鸿蒙本地存储,我真心建议你不要从"用户名增删改查"停下来,而是尽快进入一个更像业务的案例。

这次我们用一个轻账本,把两类本地存储的职责拆得很清楚:

  • RDB 管结构化账单
  • KVStore 管轻量偏好
  • 页面层负责把存储变化转成用户能看见的状态变化

当你把这一套跑顺之后,再去做类似场景的应用,思路都会很顺。

相关推荐
苗俊祥1 小时前
纯AI打造沐界输入法--简洁、流畅、实用的 HarmonyOS 中文输入法
华为·harmonyos
小成Coder2 小时前
【Jack实战】如何给《时光旅记》接入跨设备拍照和跨设备相册导入
华为·harmonyos·鸿蒙·码上创新
maaath2 小时前
【maaath】Flutter for OpenHarmony 集成应用更新能力
flutter·华为·harmonyos
key_3_feng2 小时前
鸿蒙6.0 Widget服务卡片落地方案
华为·harmonyos
maaath2 小时前
【maaath】 OpenHarmony 设备信息获取能力集成指南
flutter·华为·harmonyos
Hello__77772 小时前
开源鸿蒙 Flutter 实战|帮助中心功能全流程实现
flutter·开源·harmonyos
Hello__77772 小时前
开源鸿蒙 Flutter 实战|用户认证标识功能全流程实现
flutter·开源·harmonyos
Hello__77773 小时前
开源鸿蒙 Flutter 实战|用户详情页按钮布局溢出全流程修复与最佳实践
flutter·开源·harmonyos
Swift社区3 小时前
多端一致性:鸿蒙游戏如何避免状态漂移?
游戏·华为·harmonyos