HarmonyOS NEXT 企业级记账APP:PersistenceV2 数据库升级
·
title: “PersistenceV2 数据库升级”
series: “HarmonyLedger 鸿蒙记账实战”
article_number: 13
git_tag: v0.1.3
category: HarmonyOS NEXT
tags: [HarmonyOS, PersistenceV2, ORM, 数据库]
PersistenceV2 数据库升级
本文是《HarmonyOS NEXT 企业级开发实战:30篇打造智能记账APP》系列的第 13 篇,对应 Git Tag v0.1.3。承接第 12 篇的 Preferences,本篇接入鸿蒙 PersistenceV2 ORM,把 Bill/Category/Budget 从内存 Map 切换到真实数据库,完成数据持久化。
前言
Preferences 适合少量设置,但 账单数据 是大量、结构化、需复杂查询的——这就需要 PersistenceV2。鸿蒙 PersistenceV2 是 ArkTS 原生 ORM,通过装饰器定义实体,自动建表、索引、CRUD,开发者无需写 SQL。
本文将带你:
- 用
@Entity/@PrimaryKey/@Column装饰器定义实体 - 配置 PersistenceV2 初始化与数据库版本
- 升级 BaseRepository 切换到 PersistenceV2 实现
- Bill/Category/Budget 三个 Repository 完成真实持久化
- 处理数据库版本升级与数据迁移
企业级核心原则:ORM 必须 实体声明式、API 异步化、版本可控。参考 鸿蒙 PersistenceV2 文档 了解官方约定。
一、PersistenceV2 基础
1.1 装饰器概览
| 装饰器 | 用途 | 示例 |
|---|---|---|
@Entity(tableName) |
标记类为实体 | @Entity('bill') |
@PrimaryKey() |
主键 | @PrimaryKey() id: string |
@Column(name?) |
字段映射 | @Column('money') money: number |
@Index(fields) |
索引 | @Index(['date', 'type']) |
1.2 基础用法
import { persistence, type PersistenceV2 } from '@kit.ArkData';
// 初始化
const db: PersistenceV2 = persistence.get();
await db.init(this.context, 'harmonyledger.db', 1);
// CRUD
await db.save<Bill>(Bill, bill); // 新增/更新
const bill = await db.query<Bill>(Bill, 'id = ?', '1'); // 查询
await db.delete<Bill>(Bill, 'id = ?', '1'); // 删除
| API | 说明 |
|---|---|
persistence.get() |
获取 PersistenceV2 单例 |
db.init(ctx, name, version) |
初始化数据库(version 用于升级) |
db.save(T, entity) |
新增或更新(按主键幂等) |
db.query(T, predicate, ...args) |
条件查询 |
db.delete(T, predicate, ...args) |
条件删除 |
db.update(T, predicate, setters) |
字段更新 |
二、实体定义升级
2.1 Bill 实体
// model/Bill.ets(PersistenceV2 版)
import { persistence } from '@kit.ArkData';
import { BillType } from '../constants/BillType';
@ persistence
@Entity('bill')
export class Bill {
@PrimaryKey()
id: string = '';
@Column()
money: number = 0; // 金额(分)
@Column()
type: BillType = BillType.EXPENSE;
@Column()
categoryId: string = '';
@Column()
remark: string = '';
@Column()
date: number = 0;
@Column()
createTime: number = 0;
@Column()
updateTime: number = 0;
// 字段映射示例:源字段为 remarkDb,映射到 remark
@Column('remark')
remarkDb: string = '';
}
// 注册实体到 PersistenceV2
persistence.registerEntities(Bill);
2.2 Category 与 Budget 实体
// model/Category.ets(PersistenceV2 版)
import { persistence } from '@kit.ArkData';
import { BillType } from '../constants/BillType';
@persistence
@Entity('category')
export class Category {
@PrimaryKey()
id: string = '';
@Column()
name: string = '';
@Column()
iconRes: string = ''; // 图片资源名(Resource 无法直接持久化,存字符串名)
@Column()
color: string = '';
@Column()
sort: number = 0;
@Column()
type: BillType = BillType.EXPENSE;
@Column()
builtin: boolean = false;
/** 取图标 Resource */
getIcon(): Resource {
return $r(`app.media.${this.iconRes}`);
}
}
// model/Budget.ets(PersistenceV2 版)
@persistence
@Entity('budget')
export class Budget {
@PrimaryKey()
id: string = '';
@Column()
month: string = ''; // YYYY-MM
@Column()
budget: number = 0;
@Column()
used: number = 0;
@Column()
remain: number = 0;
}
设计要点:
Resource无法直接持久化,需把资源名转字符串存储,运行时通过$r()重建 Resource。
三、数据库初始化
3.1 DatabaseManager 封装
// database/DatabaseManager.ets
import { persistence, type PersistenceV2 } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';
import { Bill } from '../model/Bill';
import { Category } from '../model/Category';
import { Budget } from '../model/Budget';
import { AppConfig } from '../constants/app';
export class DatabaseManager {
private static db: PersistenceV2 | null = null;
/** 初始化数据库 */
static async init(context: common.Context): Promise<void> {
this.db = persistence.get();
// 注册实体
persistence.registerEntities(Bill, Category, Budget);
// 初始化数据库(版本号用于升级)
await this.db.init(context, AppConfig.DATABASE_NAME, AppConfig.DATABASE_VERSION);
}
/** 获取数据库实例 */
static get(): PersistenceV2 {
return this.db!;
}
/** 升级回调(version 增加时触发) */
static async onUpgrade(targetVersion: number): Promise<void> {
hilog.info(DOMAIN, TAG, `数据库升级到 v${targetVersion}`);
// 后续版本在此添加字段迁移逻辑
}
}
3.2 EntryAbility 集入
// entryability/EntryAbility.ets(追加数据库初始化)
import { DatabaseManager } from '../database/DatabaseManager';
async onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): Promise<void> {
await PreferenceUtil.init(this.context);
await DatabaseManager.init(this.context); // 新增:数据库初始化
await ThemeManager.init();
await this.handleFirstLaunch();
// ...
}
四、BaseRepository 升级
4.1 切换为 PersistenceV2 实现
// repository/BaseRepository.ets(升级版)
import { persistence, type PersistenceV2 } from '@kit.ArkData';
import { DatabaseManager } from '../database/DatabaseManager';
export abstract class BaseRepository<T> {
protected db: PersistenceV2 = DatabaseManager.get();
protected abstract entityType: persistence.EntityType<T>;
async findAll(): Promise<T[]> {
return await this.db.query(this.entityType);
}
async findById(id: string): Promise<T | null> {
const results = await this.db.query(this.entityType, 'id = ?', id);
return results.length > 0 ? results[0] : null;
}
async save(entity: T): Promise<boolean> {
try {
await this.db.save(this.entityType, entity);
return true;
} catch (e) {
hilog.error(DOMAIN, TAG, `save failed: ${e.message}`);
return false;
}
}
async saveAll(entities: T[]): Promise<boolean> {
try {
for (let i = 0; i < entities.length; i++) {
await this.db.save(this.entityType, entities[i]);
}
return true;
} catch (e) {
return false;
}
}
async delete(id: string): Promise<boolean> {
try {
await this.db.delete(this.entityType, 'id = ?', id);
return true;
} catch (e) {
return false;
}
}
async exists(id: string): Promise<boolean> {
const e = await this.findById(id);
return e !== null;
}
async clear(): Promise<void> {
await this.db.delete(this.entityType);
}
}
4.2 BillRepository 升级
// repository/BillRepository.ets(升级版)
export class BillRepository extends BaseRepository<Bill> {
private static instance: BillRepository;
protected entityType = Bill;
static getInstance(): BillRepository {
if (!BillRepository.instance) {
BillRepository.instance = new BillRepository();
}
return BillRepository.instance;
}
async findTodayBills(): Promise<Bill[]> {
const start = DateUtil.todayStart();
const end = DateUtil.todayEnd();
return await this.db.query(Bill, 'date >= ? AND date <= ?', start, end);
}
async findByDateRange(start: number, end: number): Promise<Bill[]> {
return await this.db.query(Bill, 'date >= ? AND date <= ?', start, end);
}
async findByType(type: BillType): Promise<Bill[]> {
return await this.db.query(Bill, 'type = ?', type);
}
async findByCategory(categoryId: string): Promise<Bill[]> {
return await this.db.query(Bill, 'categoryId = ?', categoryId);
}
async search(keyword: string): Promise<Bill[]> {
if (keyword.length === 0) return this.findAll();
return await this.db.query(Bill, 'remark LIKE ?', `%${keyword}%`);
}
async sumTodayExpense(): Promise<number> {
const today = await this.findTodayBills();
return today
.filter(b => b.type === BillType.EXPENSE)
.reduce((sum, b) => sum + b.money, 0);
}
async sumTodayIncome(): Promise<number> {
const today = await this.findTodayBills();
return today
.filter(b => b.type === BillType.INCOME)
.reduce((sum, b) => sum + b.money, 0);
}
}
五、CategoryRepository 与 BudgetRepository 升级
5.1 CategoryRepository
// repository/CategoryRepository.ets(升级版)
export class CategoryRepository extends BaseRepository<Category> {
protected entityType = Category;
async findByType(type: BillType): Promise<Category[]> {
const all = await this.findAll();
return all
.filter(c => c.type === type)
.sort((a, b) => a.sort - b.sort);
}
async initBuiltinCategories(): Promise<void> {
const existing = await this.findAll();
if (existing.length > 0) return;
const builtin: Category[] = [
this.makeBuiltin('food', '餐饮', BillType.EXPENSE, 'icon_category_food', AppColors.Expense),
this.makeBuiltin('transport', '交通', BillType.EXPENSE, 'icon_category_transport', AppColors.Expense),
this.makeBuiltin('salary', '工资', BillType.INCOME, 'icon_category_salary', AppColors.Income),
// ... 其他内置分类 ...
];
await this.saveAll(builtin);
}
private makeBuiltin(id: string, name: string, type: BillType, iconRes: string, color: string): Category {
const cat = new Category();
cat.id = id;
cat.name = name;
cat.type = type;
cat.iconRes = iconRes;
cat.color = color;
cat.builtin = true;
cat.sort = 0;
return cat;
}
}
5.2 BudgetRepository
// repository/BudgetRepository.ets(升级版)
export class BudgetRepository extends BaseRepository<Budget> {
protected entityType = Budget;
async findCurrentMonth(): Promise<Budget | null> {
const month = DateUtil.formatDate(Date.now()).substring(0, 7);
const results = await this.db.query(Budget, 'month = ?', month);
return results.length > 0 ? results[0] : null;
}
async setMonthBudget(month: string, budget: number): Promise<Budget> {
const results = await this.db.query(Budget, 'month = ?', month);
if (results.length > 0) {
const existing = results[0];
existing.budget = budget;
await this.save(existing);
return existing;
}
const newBudget = new Budget();
newBudget.id = UUIDUtil.generate();
newBudget.month = month;
newBudget.budget = budget;
await this.save(newBudget);
return newBudget;
}
}
六、数据库版本升级
6.1 版本升级场景
// AppConfig 中管理版本号
static readonly DATABASE_VERSION: number = 1;
// 升级场景:
// v1 → v2:Bill 表新增 imageUrl 字段
// v2 → v3:新增 Setting 表
// v3 → v4:Category 表新增 color 字段
6.2 升级回调处理
// database/DatabaseManager.ets(升级回调)
static async init(context: common.Context): Promise<void> {
this.db = persistence.get();
persistence.registerEntities(Bill, Category, Budget);
await this.db.init(context, AppConfig.DATABASE_NAME, AppConfig.DATABASE_VERSION, {
onUpgrade: async (current: number, target: number) => {
hilog.info(DOMAIN, TAG, `数据库升级 v${current} → v${target}`);
for (let v = current + 1; v <= target; v++) {
await this.applyMigration(v);
}
}
});
}
private static async applyMigration(targetVersion: number): Promise<void> {
switch (targetVersion) {
case 2:
// v2:新增 imageUrl 字段(已在实体声明中,无需手动迁移)
hilog.info(DOMAIN, TAG, '迁移到 v2:新增 imageUrl');
break;
case 3:
hilog.info(DOMAIN, TAG, '迁移到 v3:新增 Setting 表');
break;
default:
break;
}
}
七、最佳实践
7.1 Resource 持久化转换
// ❌ 错误:Resource 无法直接持久化
@Column()
icon: Resource; // 报错
// ✅ 正解:存字符串名,运行时重建
@Column()
iconRes: string = ''; // 存储 'icon_category_food'
getIcon(): Resource {
return $r(`app.media.${this.iconRes}`); // 运行时重建
}
7.2 查询条件参数化
// ❌ 错误:字符串拼接易注入
const bills = await db.query(Bill, `remark LIKE '%${keyword}%'`);
// ✅ 正解:用 ? 占位符
const bills = await db.query(Bill, 'remark LIKE ?', `%${keyword}%`);
7.4 存储引擎对比
| 引擎 | 读写性能 | 事务支持 | 适用场景 |
|---|---|---|---|
| PersistenceV2 | 高 | 是 | 结构化数据 |
| Preferences | 中 | 否 | 键值设置 |
根据数据特征选择正确的存储引擎是数据库设计的核心决策。
7.3 大批量保存优化
// 批量保存时用事务(如果 API 支持)
await db.transaction(async () => {
for (let i = 0; i < bills.length; i++) {
await db.save(Bill, bills[i]);
}
});
// 或并行
await Promise.all(bills.map(b => db.save(Bill, b)));
八、运行验证
8.1 编译检查
hvigorw assembleHap --mode module -p product=default
8.2 功能验证
- 首次启动,hilog 输出"数据库初始化"
- 新增账单后关闭应用,重开首页仍显示该账单
- 删除账单后关闭应用,重开首页不再显示
- 内置分类初始化后,关闭应用重开仍存在

九、常见问题
9.1 实体未注册
// 错误:未调用 registerEntities
await db.save(Bill, bill); // 报错:Entity not registered
// 解决:init 前注册
persistence.registerEntities(Bill, Category, Budget);
await db.init(...);
9.2 Resource 持久化失败
// 报错:Cannot serialize Resource
// 解决:改存字符串名
9.3 查询条件错误
// 错误:占位符数量不匹配
db.query(Bill, 'date >= ? AND date <= ?', start); // 缺第二个参数
// 正解:占位符与参数一一对应
db.query(Bill, 'date >= ? AND date <= ?', start, end);
9.4 版本升级失败
// 原因:实体字段变更但未声明升级回调
// 解决:在 onUpgrade 中处理字段迁移
十、Git 提交
10.1 Commit Message
git add .
git commit -m "feat(database): 接入 PersistenceV2 ORM 完成数据持久化
- 升级 Bill/Category/Budget 为 @Entity 装饰器实体
- 新增 DatabaseManager 封装数据库初始化与升级回调
- BaseRepository 切换为 PersistenceV2 实现(findAll/save/delete/query)
- BillRepository 升级为真实数据库查询
- CategoryRepository/BudgetRepository 升级为真实持久化
- EntryAbility 集入数据库初始化"
10.2 CHANGELOG
## [v0.1.3] - 2026-07-27
### Added
- database/DatabaseManager.ets:数据库管理器
- 数据库版本升级回调机制
### Changed
- model/Bill.ets、Category.ets、Budget.ets:升级为 @Entity 实体
- repository/*Repository.ets:切换为 PersistenceV2 实现
- entryability/EntryAbility.ets:集入数据库初始化
总结
本文完整介绍了 PersistenceV2 数据库升级,涵盖实体装饰器、DatabaseManager、BaseRepository 切换、三个 Repository 升级、版本迁移。通过本篇你可以:
- 用
@Entity/@PrimaryKey/@Column声明式定义实体 - 封装 DatabaseManager 处理初始化与升级
- BaseRepository 切换持久化实现零侵入
- 处理 Resource 无法持久化的转换
- 用占位符查询避免 SQL 注入
下一篇预告:《账单编辑与删除》将开发 EditBillView 编辑账单页面,支持长按列表项弹删除菜单、滑动删除手势、二次确认对话框。
如果这篇文章对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,你的支持是我持续创作的动力!
相关资源
- 本篇源码:GitHub Tag v0.1.3
- 鸿蒙 PersistenceV2 文档:data-persistenceV2
- ArkData 模块:ark-data
- ORM 模式介绍:orm-pattern
- 数据库版本迁移:database-migration
- PersistenceV2 实践:persistencev2-practice
- ArkTS 装饰器规范:arkts-decorators
更多推荐


所有评论(0)