HarmonyOS NEXT 企业级记账APP:设计 Repository 与数据模型 设计 Repository 与数据模型本文是《HarmonyOS NEXT 企业级开发实战30篇打造智能记账APP》系列的第07篇对应 Git Tagv0.0.7。承接第 06 篇的 BillCard 组件本篇设计 BaseRepository 抽象基类与三个具体 Repository并完成 Bill/Category/Budget 完整数据模型定义。前言企业级项目的数据访问必须屏蔽存储实现细节。ViewModel 不应关心数据来自 Preferences、PersistenceV2 还是网络接口只需调用 Repository 的标准 API。本章通过BaseRepository 抽象基类统一 CRUD 接口后续切换存储方案时 ViewModel 零改动。本文将带你定义 Bill/Category/Budget/Setting 四个完整数据模型设计 BaseRepository 抽象基类CRUD 查询实现 BillRepository/CategoryRepository/BudgetRepository用 Map 内存缓存 Mock 数据模拟持久层将 HomeViewModel 从 Mock 升级为走 Repository企业级核心原则数据访问层必须抽象统一、可替换、可测试。参考 Repository Pattern 了解业界经典实践。一、数据模型完整定义1.1 Bill 账单模型// model/Bill.ets import { BillType } from ../constants/BillType; export class Bill { id: string; money: number; // 金额分 type: BillType; // 收入/支出 categoryId: string; // 分类ID remark: string ; // 备注 date: number; // 账单日期时间戳 createTime: number; // 创建时间 updateTime: number; // 更新时间 constructor(id: string, money: number, type: BillType, categoryId: string, date: number) { this.id id; this.money money; this.type type; this.categoryId categoryId; this.date date; this.createTime Date.now(); this.updateTime Date.now(); } /** 用于 Mock / 序列化 */ static fromJson(json: Recordstring, Object): Bill { const bill new Bill( json[id] as string, json[money] as number, json[type] as BillType, json[categoryId] as string, json[date] as number ); bill.remark json[remark] as string ?? ; bill.createTime json[createTime] as number ?? Date.now(); bill.updateTime json[updateTime] as number ?? Date.now(); return bill; } toJson(): Recordstring, Object { return { id: this.id, money: this.money, type: this.type, categoryId: this.categoryId, remark: this.remark, date: this.date, createTime: this.createTime, updateTime: this.updateTime }; } }1.2 Category 分类模型// model/Category.ets import { BillType } from ../constants/BillType; export class Category { id: string; name: string; icon: Resource; // 分类图标 color: string; // 分类色hex sort: number 0; // 排序序号 type: BillType; // 收入/支出分类 builtin: boolean false; // 是否内置不可删 constructor(id: string, name: string, type: BillType, icon: Resource, color: string) { this.id id; this.name name; this.type type; this.icon icon; this.color color; } }1.3 Budget 预算模型// model/Budget.ets export class Budget { id: string; month: string; // YYYY-MM budget: number; // 预算金额分 used: number 0; // 已用金额分计算属性 remain: number 0; // 剩余金额分计算属性 constructor(id: string, month: string, budget: number) { this.id id; this.month month; this.budget budget; } /** 计算已用与剩余 */ refresh(used: number): void { this.used used; this.remain this.budget - this.used; } /** 超支判断 */ isOverrun(): boolean { return this.used this.budget; } /** 使用率 0~1 */ usage(): number { if (this.budget 0) return 0; return Math.min(this.used / this.budget, 1); } }1.4 Setting 设置模型// model/Setting.ets export enum ThemeMode { LIGHT light, DARK dark, AUTO auto } export class Setting { theme: ThemeMode ThemeMode.AUTO; language: string zh-CN; currency: string CNY; darkMode: boolean false; firstDayOfWeek: number 1; // 1周一 7周日 static singleton: Setting new Setting(); }1.5 模型关系图┌──────────┐ N:1 ┌────────────┐ │ Bill │───────▶│ Category │ │──────────│ │────────────│ │ id │ │ id │ │ money │ │ name │ │ type │ │ icon │ │ categoryId │ color │ │ date │ │ sort │ └──────────┘ └────────────┘ ┌──────────┐ 独立 ┌────────────┐ │ Budget │ │ Setting │ │──────────│ │────────────│ │ id │ │单例 │ │ month │ │ theme │ │ budget │ │ language │ │ used │ │ currency │ │ remain │ │ darkMode │ └──────────┘ └────────────┘模型字段数关系存储方案Bill8categoryId → CategoryPersistenceV2v0.1.3 接入Category7独立PersistenceV2Budget5独立PersistenceV2Setting5单例Preferencesv0.1.2 接入二、BaseRepository 抽象基类2.1 设计目标统一 CRUD增删改查接口标准化泛型支持子类指定具体 Model 类型异步 API返回 Promise 便于后续切数据库内存缓存内置 Map 缓存减少 IO可替换后续切 PersistenceV2 只改 Base 实现2.2 完整源码// repository/BaseRepository.ets export abstract class BaseRepositoryT { protected cache: Mapstring, T new Map(); /** 查询全部 */ async findAll(): PromiseT[] { return Array.from(this.cache.values()); } /** 按 ID 查询 */ async findById(id: string): PromiseT | null { return this.cache.get(id) ?? null; } /** 新增 */ async save(entity: T): Promiseboolean { const id this.getEntityId(entity); this.cache.set(id, entity); return true; } /** 批量新增 */ async saveAll(entities: T[]): Promiseboolean { for (let i 0; i entities.length; i) { const e entities[i]; this.cache.set(this.getEntityId(e), e); } return true; } /** 删除 */ async delete(id: string): Promiseboolean { return this.cache.delete(id); } /** 是否存在 */ async exists(id: string): Promiseboolean { return this.cache.has(id); } /** 清空全部 */ async clear(): Promisevoid { this.cache.clear(); } /** 子类实现获取实体 ID */ protected abstract getEntityId(entity: T): string; }2.3 关键设计解析方法签名用途findAllPromiseT[]查询全部实体findByIdPromiseT | null按 ID 查询不存在返回 nullsavePromiseboolean新增或更新幂等saveAllPromiseboolean批量保存deletePromiseboolean删除返回是否成功existsPromiseboolean是否存在clearPromisevoid清空测试用设计要点所有方法统一返回 Promise即使当前是同步内存实现后续切 PersistenceV2 时只需改方法体调用方零改动。三、BillRepository 实现3.1 完整源码// repository/BillRepository.ets import { BaseRepository } from ./BaseRepository; import { Bill } from ../model/Bill; import { BillType } from ../constants/BillType; import { DateUtil } from ../utils/DateUtil; import { UUIDUtil } from ../utils/UUIDUtil; export class BillRepository extends BaseRepositoryBill { private static instance: BillRepository; static getInstance(): BillRepository { if (!BillRepository.instance) { BillRepository.instance new BillRepository(); } return BillRepository.instance; } protected getEntityId(bill: Bill): string { return bill.id; } /** 查询今日账单 */ async findTodayBills(): PromiseBill[] { const start DateUtil.todayStart(); const end DateUtil.todayEnd(); return this.findByDateRange(start, end); } /** 按日期范围查询 */ async findByDateRange(start: number, end: number): PromiseBill[] { const all await this.findAll(); return all.filter(b b.date start b.date end); } /** 按类型查询 */ async findByType(type: BillType): PromiseBill[] { const all await this.findAll(); return all.filter(b b.type type); } /** 按分类查询 */ async findByCategory(categoryId: string): PromiseBill[] { const all await this.findAll(); return all.filter(b b.categoryId categoryId); } /** 按关键字搜索备注模糊匹配 */ async search(keyword: string): PromiseBill[] { if (keyword.length 0) return this.findAll(); const all await this.findAll(); return all.filter(b b.remark.includes(keyword)); } /** 新增账单自动生成 ID */ async create(money: number, type: BillType, categoryId: string, remark: string, date: number): PromiseBill { const bill new Bill(UUIDUtil.generate(), money, type, categoryId, date); bill.remark remark; await this.save(bill); return bill; } /** 更新账单 */ async update(bill: Bill): Promiseboolean { bill.updateTime Date.now(); return this.save(bill); } /** 统计今日支出总额分 */ async sumTodayExpense(): Promisenumber { const today await this.findTodayBills(); return today .filter(b b.type BillType.EXPENSE) .reduce((sum, b) sum b.money, 0); } /** 统计今日收入总额分 */ async sumTodayIncome(): Promisenumber { const today await this.findTodayBills(); return today .filter(b b.type BillType.INCOME) .reduce((sum, b) sum b.money, 0); } }3.2 查询方法矩阵方法输入输出用途findTodayBills—Bill[]首页今日数据findByDateRangestart, endBill[]统计分析区间查询findByTypetypeBill[]按收入/支出筛选findByCategorycategoryIdBill[]分类详情searchkeywordBill[]搜索功能sumTodayExpense—number首页支出汇总sumTodayIncome—number首页收入汇总四、CategoryRepository 与 BudgetRepository4.1 CategoryRepository// repository/CategoryRepository.ets import { BaseRepository } from ./BaseRepository; import { Category } from ../model/Category; import { BillType } from ../constants/BillType; import { UUIDUtil } from ../utils/UUIDUtil; import { AppColors } from ../theme/Colors; export class CategoryRepository extends BaseRepositoryCategory { private static instance: CategoryRepository; static getInstance(): CategoryRepository { if (!CategoryRepository.instance) { CategoryRepository.instance new CategoryRepository(); } return CategoryRepository.instance; } protected getEntityId(cat: Category): string { return cat.id; } /** 按类型查询分类 */ async findByType(type: BillType): PromiseCategory[] { const all await this.findAll(); return all .filter(c c.type type) .sort((a, b) a.sort - b.sort); } /** 新增分类 */ async create(name: string, type: BillType, icon: Resource, color: string): PromiseCategory { const category new Category(UUIDUtil.generate(), name, type, icon, color); category.sort this.cache.size; await this.save(category); return category; } /** 调整排序 */ async reorder(id: string, newSort: number): Promiseboolean { const cat await this.findById(id); if (!cat) return false; cat.sort newSort; return this.save(cat); } /** 预置内置分类首次启动调用 */ async initBuiltinCategories(): Promisevoid { if (this.cache.size 0) return; const builtin: Category[] [ this.makeBuiltin(food, 餐, BillType.EXPENSE, $r(app.media.icon_category_food), AppColors.Expense), this.makeBuiltin(transport, 交通, BillType.EXPENSE, $r(app.media.icon_category_transport), AppColors.Expense), this.makeBuiltin(salary, 工资, BillType.INCOME, $r(app.media.icon_category_salary), AppColors.Income), this.makeBuiltin(bonus, 奖金, BillType.INCOME, $r(app.media.icon_category_bonus), AppColors.Income) ]; await this.saveAll(builtin); } private makeBuiltin(id: string, name: string, type: BillType, icon: Resource, color: string): Category { const cat new Category(id, name, type, icon, color); cat.builtin true; cat.sort this.cache.size; return cat; } }4.2 BudgetRepository// repository/BudgetRepository.ets import { BaseRepository } from ./BaseRepository; import { Budget } from ../model/Budget; import { UUIDUtil } from ../utils/UUIDUtil; import { DateUtil } from ../utils/DateUtil; export class BudgetRepository extends BaseRepositoryBudget { private static instance: BudgetRepository; static getInstance(): BudgetRepository { if (!BudgetRepository.instance) { BudgetRepository.instance new BudgetRepository(); } return BudgetRepository.instance; } protected getEntityId(b: Budget): string { return b.id; } /** 查询本月预算 */ async findCurrentMonth(): PromiseBudget | null { const month DateUtil.formatDate(Date.now()).substring(0, 7); // YYYY-MM const all await this.findAll(); return all.find(b b.month month) ?? null; } /** 设置本月预算 */ async setMonthBudget(month: string, budget: number): PromiseBudget { const all await this.findAll(); const existing all.find(b b.month month); if (existing) { existing.budget budget; await this.save(existing); return existing; } const newBudget new Budget(UUIDUtil.generate(), month, budget); await this.save(newBudget); return newBudget; } }五、UUIDUtil 工具类// utils/UUIDUtil.ets export class UUIDUtil { /** 生成 UUID v4 */ static generate(): string { const chars 0123456789abcdef; let uuid ; for (let i 0; i 36; i) { if (i 8 || i 13 || i 18 || i 23) { uuid -; } else if (i 14) { uuid 4; } else if (i 19) { uuid chars[Math.floor(Math.random() * 4) 8]; } else { uuid chars[Math.floor(Math.random() * 16)]; } } return uuid; } }六、HomeViewModel 升级为走 Repository6.1 改造后源码// viewmodel/HomeViewModel.ets升级版 import { Bill } from ../model/Bill; import { BillType } from ../constants/BillType; import { BillRepository } from ../repository/BillRepository; import { CategoryRepository } from ../repository/CategoryRepository; import { MoneyUtil } from ../utils/MoneyUtil; export class HomeViewModel { todayExpense: string 0.00; todayIncome: string 0.00; recentBills: Bill[] []; private billRepo BillRepository.getInstance(); private categoryRepo CategoryRepository.getInstance(); /** 加载今日数据走 Repository */ async loadData(): Promisevoid { // 首次初始化内置分类 await this.categoryRepo.initBuiltinCategories(); // 查询今日账单 this.recentBills await this.billRepo.findTodayBills(); // 汇总金额 const expenseSum await this.billRepo.sumTodayExpense(); const incomeSum await this.billRepo.sumTodayIncome(); this.todayExpense MoneyUtil.format(expenseSum); this.todayIncome MoneyUtil.format(incomeSum); } /** Mock 数据仅开发期使用 */ async loadMockData(): Promisevoid { await this.billRepo.create(3500, BillType.EXPENSE, food, 午餐, Date.now()); await this.billRepo.create(8000, BillType.INCOME, salary, 今日工资, Date.now()); await this.billRepo.create(2500, BillType.EXPENSE, transport, 打车, Date.now()); await this.loadData(); } goBillDetail(billId: string): void { // RouterUtil.go(/bill/detail, { id: billId }); } deleteBill(billId: string): void { // 待 v0.1.4 篇实现 } }6.2 HomeView 改为 async 加载// pages/HomeView.ets关键改动 State isLoading: boolean true; aboutToAppear() { this.loadData(); } private async loadData(): Promisevoid { this.isLoading true; // 开发期用 Mock 数据上线期改为 loadData() await this.viewModel.loadMockData(); this.isLoading false; } build() { if (this.isLoading) { LoadingView() } else { // ... 原内容 } }七、LoadingView 占位组件// components/common/LoadingView.ets import { AppColors } from ../../theme/Colors; Component export struct LoadingView { build() { Column() { Text(加载中...) .fontColor(AppColors.SecondaryText) } .width(100%) .height(100%) .justifyContent(FlexAlign.Center) .alignItems(HorizontalAlign.Center) } }八、最佳实践8.1 单例 Repository// 优点内存缓存全局唯一避免重复初始化 static getInstance(): BillRepository { if (!BillRepository.instance) { BillRepository.instance new BillRepository(); } return BillRepository.instance; }8.2 Promise 统一异步// 即使当前是同步内存实现也用 Promise 包装 async findAll(): PromiseT[] { return Array.from(this.cache.values()); // 同步但返回 Promise } // 后续切 PersistenceV2 时只需改方法体 async findAll(): PromiseT[] { return await PersistenceV2.query(T, ); // 真实异步 }8.3 抽象方法约定// BaseRepository 只抽象 ID 获取其他 CRUD 通用 protected abstract getEntityId(entity: T): string; // 强制子类实现编译期保证完整性设计权衡BaseRepository 的findAll/save等是通用方法但 BillRepository 需要业务专属方法如findTodayBills放在子类更合适。九、运行验证9.1 编译检查hvigorw assembleHap--modemodule-pproductdefault9.2 逻辑验证首次启动CategoryRepository 初始化 4 个内置分类HomeViewModel.loadMockData() 写入 3 条账单HomeViewModel.loadData() 查询今日账单并汇总首页显示 Loading 后渲染出 3 条账单与收支汇总十、常见问题10.1 抽象类未实现编译报错// 错误子类未实现 getEntityId class BillRepository extends BaseRepositoryBill { } // ❌ 缺 getEntityId // 报错Class BillRepository does not implement getEntityId from class BaseRepository10.2 Promise 未 await// 错误忘记 await 导致拿到 Promise 而非数组 const bills this.billRepo.findTodayBills(); // ❌ bills 是 Promise this.recentBills bills; // 正解必须 await this.recentBills await this.billRepo.findTodayBills();10.3 单例线程安全ArkTS 单线程无需加锁。if (!instance)判空即可。十一、Git 提交11.1 Commit Messagegitadd.gitcommit-mfeat(repository): 设计 Repository 与数据模型 - 新增 BaseRepository 抽象基类CRUD 缓存 - 新增 BillRepository 含今日查询/搜索/统计等业务方法 - 新增 CategoryRepository 含预置内置分类 - 新增 BudgetRepository 含月度预算管理 - 完善 Bill/Category/Budget/Setting 四个数据模型 - 新增 UUIDUtil 工具类 - HomeViewModel 升级为走 Repository 异步加载 - 新增 LoadingView 占位组件11.2 CHANGELOG## [v0.0.7] - 2026-07-27 ### Added - repository/BaseRepository.ets抽象基类 - repository/BillRepository.ets账单数据访问 - repository/CategoryRepository.ets分类数据访问 - repository/BudgetRepository.ets预算数据访问 - model/Category.ets、model/Budget.ets、model/Setting.ets完善数据模型 - utils/UUIDUtil.etsUUID 生成 - components/common/LoadingView.ets加载占位 ### Changed - model/Bill.ets新增 fromJson/toJson 序列化 - viewmodel/HomeViewModel.ets改为 Repository 异步加载附录运行效果截图总结本文完整介绍了Repository 层与数据模型设计涵盖四个模型定义、BaseRepository 抽象基类、三个具体 Repository、UUIDUtil、HomeViewModel 升级。通过本篇你可以设计可替换的数据访问层Promise 统一异步用抽象基类 单例模式复用 CRUD为 Repository 添加业务专属查询方法用 Map 内存缓存模拟持久层后续 v0.1.3 切 PersistenceV2ViewModel 与 Repository 的标准协作方式下一篇预告《新增账单页面开发》](./08-新增账单页面开发.md) 将开发 AddBillView 完整页面包含收入/支出切换、金额输入、分类选择、日期选择、备注输入、保存逻辑集成 BillRepository 完成账单持久化。如果这篇文章对你有帮助欢迎点赞、收藏⭐、关注你的支持是我持续创作的动力相关资源本篇源码GitHub Tag v0.0.7Repository Patternmartinfowler.com/eaaCatalog/repositoryArkTS 抽象类abstract-classArkTS 泛型genericPromise 异步编程promise-async单例模式实践singleton-pattern鸿蒙数据存储选择data-storage-options