HarmonyOS 开发助手实战:家庭物品管理元服务《归巢》开发笔记
前言
之前我用 HarmonyOS 开发助手做过一款《岁时·中国节》元服务,从立项到上架整体体验不错,相关记录可以看这篇 用 HarmonyOS 开发助手做一个元服务:岁时·中国节的开发与上架记录。这次想换个思路——不搞节日主题,做一个我自己天天都需要的用的工具:一个家庭物品管理 + 过期提醒的元服务,名字叫《归巢》。
说起来也简单,我家里的东西越来越多,有些东西买完塞进某个柜子,过两个月翻箱倒柜找不到,半年后又莫名其妙出现在另一个角落。更头疼的是医药箱,有些药片买的时候没注意保质期,等生病了拆开一看——过期半年了。这种场景靠脑子记不现实,需要一个轻量的工具帮我管起来,正好元服务的卡片能力天然适合做这种信息触达。
项目架构
先放一张整体架构图,下面的内容都围绕这张图展开。

项目整体分了五层,从上到下分别是:
| 层级 | 职责 | 关键文件 |
|---|---|---|
| UI 层(pages/view) | 页面渲染与用户交互 | Index.ets、HomePage.ets、ItemListPage.ets 等 |
| 卡片层(widget) | 元服务卡片展示 | ExpiryCard.ets、ShoppingCard.ets |
| 服务层(service) | 业务逻辑,单例 Repository 模式 | ItemRepository、ShoppingRepository、CardService、ReminderService |
| 存储层(store) | 数据持久化与缓存 | JsonStore、PrefStore、MemoryCache |
| 模型层(model) | 数据结构定义 | Item、StorageLocation、ShoppingItem、ReminderRecord |
这里有一个值得说的设计决策:项目没有用 HarmonyOS 的关系型数据库 @ohos.data.relationalStore,而是用了 JSON 文件持久化 + preferences 轻量存储 + 内存缓存的三层方案。原因是元服务的数据量本身不大(物品几百条、位置几十条),关系型数据库反而增加了复杂度。JSON 文件方案简单直观,启动时一次性加载到内存,读写都走缓存,够用。
项目预览
先看效果,四个动图覆盖了主要功能:
![]() | ![]() |
![]() | ![]() |
从效果预览中能看到《归巢》的核心功能链路:添加物品 → 管理物品存储空间 → 采购清单 → 过期物品提醒。这条链路不是随意拼的,它对应的是家庭物品的一个完整生命周期——买进来、放好、用完补货、过期前预警,每一环都连上了。
项目开发
前面已经把项目整体过了一遍,这一节讲怎么用 HarmonyOS 开发助手把项目从零搭到上架。核心思路和上一篇文章一样:先写一份项目规划书,再把它喂给开发助手,让它按规划生成代码。
规划先行
我坚持先写规划再写代码,是因为助手不会替你做产品决策。如果你自己都没想清楚要分几个页面、数据模型长什么样、存储用哪个方案,直接让助手"写一个家庭物品管理 App",它给你生成的东西大概率是 Demo 级别的,业务逻辑经不起推敲。
下面是规划书的部分截图,里面定义了功能模块、数据模型、存储方案、卡片设计:

规划书的关键内容我列一下,这些后来都直接映射成了源码结构:
- 四个核心 Tab:首页概览 / 物品列表 / 采购清单 / 保质期提醒
- 三级收纳层次:房间 → 柜子 → 层(对应
LocationLevel枚举的 ROOM/CABINET/LAYER) - 物品状态机:NORMAL(正常)→ EMPTY(已用完)→ DISCARDED(已丢弃)/ ARCHIVED(已归档)
- 过期状态:NONE(长期)/ NORMAL(正常)/ NEAR(临期)/ EXPIRED(已过期),临期阈值可配(默认 7 天)
- 两张卡片:保质期卡片(2×2)+ 采购清单卡片(2×4),都是 ArkTS 动态卡片
喂给助手
规划书准备好后,提示词其实不需要花哨,把规划书完整给到助手,让它按规划生成代码就行:

助手生成代码后,一定要跑真机验证。规划书写得再详细,助手生成的代码也可能有边界问题,这些只有在真机上才能暴露出来。
数据模型设计
源码中数据模型定义在 entry/src/main/ets/model/ 目录下,核心模型如下。
物品模型 Item 是整个项目的核心,定义在 Item.ets 中:
export interface Item {
id: string;
name: string;
icon: string;
categoryId: string;
locationId: string; // 关联 StorageLocation
quantity: number;
unit: string;
purchaseDate: number; // 购买日期(时间戳)
expireDate: number; // 到期日期(时间戳)
shelfLifeDays: number; // 保质期天数
status: ItemStatus; // 物品状态
note: string;
createdAt: number;
updatedAt: number;
}
收纳位置 StorageLocation 支持三级层次,通过 parentId 构成树形结构,fullPath 字段冗余存储完整路径(如"厨房 › 药柜 › 第二层"),避免每次展示都要递归查找:
export interface StorageLocation {
id: string;
parentId: string;
level: LocationLevel; // ROOM=1, CABINET=2, LAYER=3
name: string;
fullPath: string; // 冗余路径,空间换时间
sortOrder: number;
isSystem: boolean; // 系统预设位置不可删
createdAt: number;
updatedAt: number;
}
采购清单项 ShoppingItem 有个 autoGenerated 字段,用来区分是用户手动添加的还是物品用完后系统自动生成的——这个区分很重要,后面讲采购闭环时会提到。
存储方案:JSON 文件 + preferences + 内存缓存
存储层分了三个类,各管各的:
JsonStore 负责主数据持久化,用 @kit.CoreFileKit 的 fileIo 读写 JSON 文件。主数据拆成四个文件:nest_locations.json、nest_items.json、nest_shopping.json、nest_reminders.json,单独文件单独读写,避免一个文件存全部数据的锁问题。
写入采用了"临时文件 + rename"的原子写策略,防止写一半进程被杀导致数据损坏:
private writeArray<T>(fileName: string, data: T[]): void {
const path = this.filePath(fileName);
const tmp = this.tmpPath(fileName); // 先写 .tmp 文件
try {
const content = JSON.stringify(data);
const file = fileIo.openSync(tmp, fileIo.OpenMode.CREATE | fileIo.OpenMode.TRUNC | fileIo.OpenMode.WRITE_ONLY);
fileIo.writeSync(file.fd, content);
fileIo.closeSync(file.fd);
if (fileIo.accessSync(path)) {
fileIo.unlinkSync(path); // 删旧文件
}
fileIo.renameSync(tmp, path); // 原子替换
} catch (e) {
// 写失败清理临时文件,旧数据不受影响
}
}
读取时如果 JSON 解析失败,会把损坏文件重命名为 .corrupt.时间戳.json 然后返回空数组,不会让 App 直接崩——损坏数据留个底,方便事后排查。
PrefStore 用 @kit.ArkData 的 preferences API,存轻量的键值数据:应用设置(临期天数、提醒开关、卡片刷新时间)、活跃卡片 formId 列表、搜索历史(最多 10 条)、自定义分类。这些数据量小、读频繁,preferences 比 JSON 文件更合适。
MemoryCache 是单例内存缓存,启动时从 JsonStore 一次性加载到内存,之后所有读写都走内存。写操作标记 dirty,通过 setTimeout(300ms) 防抖批量落盘——连续操作 10 次只写一次磁盘:
private scheduleFlush(): void {
if (this.flushTimer >= 0) return;
this.flushTimer = setTimeout(() => {
this.flushTimer = -1;
this.flushAll();
}, 300) as number;
}
也支持 immediate: true 立即写盘,用于关键操作(比如物品状态变更)确保不丢数据。
过期提醒逻辑
过期提醒是项目的核心功能之一,代码在 ExpiryCalculator.ets 和 ReminderService.ets。
过期状态计算逻辑很简单,但有几个边界要处理好——已用完和已丢弃的物品不应该报过期,没保质期的长期物品直接跳过:
export function getExpiryStatus(item: Item, now: number, nearDays: number): ExpiryStatus {
if (item.expireDate <= 0) return ExpiryStatus.NONE; // 长期物品
if (item.status === ItemStatus.EMPTY || item.status === ItemStatus.DISCARDED) {
return ExpiryStatus.NONE; // 已用完/丢弃的不再提醒
}
const d = daysRemaining(item.expireDate, now);
if (d < 0) return ExpiryStatus.EXPIRED; // 已过期
if (d <= nearDays) return ExpiryStatus.NEAR; // 临期
return ExpiryStatus.NORMAL;
}
daysRemaining 按本地时区算天数差,而不是简单的时间戳除以 86400000,避免跨时区导致的"差一天"问题:
function localDayOffset(): number {
return new Date().getTimezoneOffset() * -60000;
}
export function daysRemaining(expireDate: number, now: number): number {
if (expireDate <= 0) return Number.MAX_SAFE_INTEGER;
const offset = localDayOffset();
const expireDay = Math.floor((expireDate + offset) / 86400000);
const nowDay = Math.floor((now + offset) / 86400000);
return expireDay - nowDay;
}
代理提醒用的是 @kit.BackgroundTasksKit 的 reminderAgentManager,可以发布系统级提醒,不需要 App 在前台。但提醒有配额限制,源码里写死了 QUOTA_LIMIT = 20,超过就降级为每日汇总提醒:
const neededCount = nearItems.length * 2; // 每个物品两条:到期前 + 到期当天
if (neededCount > ReminderService.QUOTA_LIMIT) {
await this.publishDailySummary(context, nearItems.length);
return;
}
采购清单闭环
采购清单不是孤立的,它和物品状态是联动的。核心逻辑在 ShoppingRepository.ets 里,形成了一条闭环:
- 物品用完 →
markAsEmpty()把状态设为 EMPTY → 自动调ensurePendingItem()生成采购项 - 采购完成 →
completeShopping()→ 调ItemRepository.restockFromShopping()自动回库补货 - 回库时重置状态为 NORMAL,累加数量,按
shelfLifeDays重算到期日期
ensurePendingItem 有个小优化:如果同一个物品已经有未完成的采购项,不新建,而是数量累加。避免采购清单里同一个东西出现好几行:
ensurePendingItem(item: Item): ShoppingItem {
const list = this.getAll();
const existing = list.find(s => s.itemId === item.id && s.status === ShoppingStatus.PENDING);
if (existing) {
existing.quantity = existing.quantity + item.quantity; // 累加数量
MemoryCache.getInstance().setShoppingList(list, true);
CardService.getInstance().refreshAllCards();
return existing;
}
// ... 新建采购项
}
回库补货 restockFromShopping 里重算到期日期的代码也值得看一下,它以"今天"为起点算到期日,而不是沿用原来的购买日期:
restockFromShopping(itemId: string, qty: number): Item | null {
const items = this.getAll();
const idx = items.findIndex(item => item.id === itemId);
if (idx < 0) return null;
const now = Date.now();
items[idx].status = ItemStatus.NORMAL;
items[idx].quantity = items[idx].quantity + qty;
items[idx].purchaseDate = now;
if (items[idx].shelfLifeDays > 0) {
const msPerDay = 86400000;
const startDay = Math.floor(now / msPerDay);
items[idx].expireDate = (startDay + items[idx].shelfLifeDays) * msPerDay + 86399999;
}
items[idx].updatedAt = now;
MemoryCache.getInstance().setItems(items, true);
CardService.getInstance().refreshAllCards();
return items[idx];
}
这里 86399999 是一天的毫秒数减一,让到期日期落在当天的最后一秒,而不是第二天零点——避免"今天买保质期 7 天的东西,7 天后就显示过期"的偏差。
元服务卡片
项目做了两张动态卡片,配置在 resources/base/profile/form_config.json 里:
{
"name": "expiry_card",
"uiSyntax": "arkts",
"isDynamic": true,
"updateEnabled": true,
"scheduledUpdateTime": "08:00", // 每天早8点定时刷新
"defaultDimension": "2*2",
"formConfigAbility": "ability://EntryAbility"
}
卡片 UI 用 @LocalStorageProp 接收数据,这个装饰器会在卡片数据更新时自动触发重新渲染。保质期卡片会根据有没有过期/临期物品切换背景图——有异常用警示背景 bg_expiry_alert.png,一切正常用安全背景 bg_expiry_safe.png:
@LocalStorageProp('nearCount') nearCount: number = 0;
@LocalStorageProp('expiredCount') expiredCount: number = 0;
@LocalStorageProp('hasData') hasData: string = 'false';
build() {
Stack() {
Image(this.hasData === 'true' ? $r('app.media.bg_expiry_alert') : $r('app.media.bg_expiry_safe'))
.width('100%').height('100%').objectFit(ImageFit.Cover)
// ... 内容层
}
.onClick(() => {
postCardAction(this, {
action: 'router',
abilityName: 'EntryAbility',
params: { 'route': 'expiry' } // 点击卡片直接跳保质期页
});
})
}
卡片数据由 CardService 单例构建,从 MemoryCache 聚合数据。每次物品或采购清单变更,Repository 都会主动调 CardService.getInstance().refreshAllCards() 刷新所有活跃卡片——遍历 PrefStore 里存的 formId 列表,逐个调 formProvider.updateForm:
async refreshAllCards(): Promise<void> {
const formIds = PrefStore.getInstance().getActiveFormIds();
if (formIds.length === 0) return;
for (const formId of formIds) {
try {
const formName = PrefStore.getInstance().getFormName(formId) || 'expiry_card';
const data = this.buildFormBindingData(formName);
formProvider.updateForm(formId, data);
} catch (e) {
Logger.error(`CardService refresh failed for ${formId}: ${e}`);
}
}
}
卡片的 FormExtensionAbility(NestFormAbility)负责生命周期管理:onAddForm 时构建初始数据并持久化 formId,onUpdateForm 时按定时刷新机制重建数据,onRemoveForm 时清理 formId。
需求修复
项目跑起来之后发现三个问题,都是状态同步相关的:
- 物品删除后,收纳位置的物品数量没更新——还是显示旧数量
- 点"已用完/已丢弃"后,保质期页的"已过期/7天内/30天内"数量不更新,切页面再回来才刷新
- 采购清单增减后,元服务卡片不刷新
这三个问题根因是一样的:数据变更后没有同步刷新依赖的视图。修复方法就是在每个 Repository 的写操作末尾加 CardService.getInstance().refreshAllCards() 调用,同时页面在 onShown 生命周期重新拉数据。修复前后对比:
![]() | ![]() |
把问题描述给到助手,它直接在对应的 Repository 方法里补上了 refreshAllCards() 调用。源码里能看到,ItemRepository 的 createItem、updateItem、deleteItem、markAsEmpty、markAsDiscarded、restockFromShopping 方法末尾都有这一行;ShoppingRepository 的 ensurePendingItem、addManualItem、completeShopping、cancelShopping、deleteShopping 也都加了。统一加在 Repository 层而不是 UI 层,是因为 UI 层有多个入口可能触发同一个数据变更,放 Repository 层能保证不遗漏。
完成上架
所有问题修复后就可以提审了。发布流程在 AGC(AppGallery Connect)里完成,填写元服务信息、上传截图、提交审核:

提交后一天就过审了,效率不错:

至此我已经用 HarmonyOS 开发助手上架了两款元服务,接下来准备做第三款。
总结
这次《归巢》从规划到上架花了两天时间,主要时间花在规划书编写和真机调试上,代码生成本身非常快。过程中有一些体会:
- 规划书决定了代码质量上限。数据模型、存储方案、状态机这些在规划阶段就得定清楚,否则助手生成的代码改起来比从头写还费劲。
- JSON 文件 + preferences 的轻量存储方案对元服务够用,但数据量上来后需要考虑迁移到 relationalStore,源码里预留了
migrateLegacyIfNeeded的迁移逻辑做参考。 - 卡片刷新要主动调
formProvider.updateForm,不能指望定时刷新。定时刷新(每天 08:00)只解决"过了一夜数据要更新"的场景,实时变更得自己推。 - 代理提醒有配额限制(代码里设的 20 条),超过后降级为汇总提醒,这一点规划时容易忽略。
接下来第三款应用具体做什么还没想好,等确定下来在与大家进行分享.
更多推荐








所有评论(0)