☰
HarmonyOS 开发助手实战:家庭物品管理元服务《归巢》开发笔记
2026/10/5 2:34:29 网站建设 项目流程

前言

之前我用 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中:

exportinterfaceItem{id:string;name:string;icon:string;categoryId:string;locationId:string;// 关联 StorageLocationquantity:number;unit:string;purchaseDate:number;// 购买日期(时间戳)expireDate:number;// 到期日期(时间戳)shelfLifeDays:number;// 保质期天数status:ItemStatus;// 物品状态note:string;createdAt:number;updatedAt:number;}

收纳位置StorageLocation支持三级层次,通过parentId构成树形结构,fullPath字段冗余存储完整路径(如"厨房 › 药柜 › 第二层"),避免每次展示都要递归查找:

exportinterfaceStorageLocation{id:string;parentId:string;level:LocationLevel;// ROOM=1, CABINET=2, LAYER=3name: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"的原子写策略,防止写一半进程被杀导致数据损坏:

privatewriteArray<T>(fileName:string,data:T[]):void{constpath=this.filePath(fileName);consttmp=this.tmpPath(fileName);// 先写 .tmp 文件try{constcontent=JSON.stringify(data);constfile=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的preferencesAPI,存轻量的键值数据:应用设置(临期天数、提醒开关、卡片刷新时间)、活跃卡片 formId 列表、搜索历史(最多 10 条)、自定义分类。这些数据量小、读频繁,preferences 比 JSON 文件更合适。

MemoryCache是单例内存缓存,启动时从JsonStore一次性加载到内存,之后所有读写都走内存。写操作标记 dirty,通过setTimeout(300ms)防抖批量落盘——连续操作 10 次只写一次磁盘:

privatescheduleFlush():void{if(this.flushTimer>=0)return;this.flushTimer=setTimeout(()=>{this.flushTimer=-1;this.flushAll();},300)asnumber;}

也支持immediate: true立即写盘,用于关键操作(比如物品状态变更)确保不丢数据。

过期提醒逻辑

过期提醒是项目的核心功能之一,代码在ExpiryCalculator.ets和ReminderService.ets。

过期状态计算逻辑很简单,但有几个边界要处理好——已用完和已丢弃的物品不应该报过期,没保质期的长期物品直接跳过:

exportfunctiongetExpiryStatus(item:Item,now:number,nearDays:number):ExpiryStatus{if(item.expireDate<=0)returnExpiryStatus.NONE;// 长期物品if(item.status===ItemStatus.EMPTY||item.status===ItemStatus.DISCARDED){returnExpiryStatus.NONE;// 已用完/丢弃的不再提醒}constd=daysRemaining(item.expireDate,now);if(d<0)returnExpiryStatus.EXPIRED;// 已过期if(d<=nearDays)returnExpiryStatus.NEAR;// 临期returnExpiryStatus.NORMAL;}

daysRemaining按本地时区算天数差,而不是简单的时间戳除以 86400000,避免跨时区导致的"差一天"问题:

functionlocalDayOffset():number{returnnewDate().getTimezoneOffset()*-60000;}exportfunctiondaysRemaining(expireDate:number,now:number):number{if(expireDate<=0)returnNumber.MAX_SAFE_INTEGER;constoffset=localDayOffset();constexpireDay=Math.floor((expireDate+offset)/86400000);constnowDay=Math.floor((now+offset)/86400000);returnexpireDay-nowDay;}

代理提醒用的是@kit.BackgroundTasksKit的reminderAgentManager,可以发布系统级提醒,不需要 App 在前台。但提醒有配额限制,源码里写死了QUOTA_LIMIT = 20,超过就降级为每日汇总提醒:

constneededCount=nearItems.length*2;// 每个物品两条:到期前 + 到期当天if(neededCount>ReminderService.QUOTA_LIMIT){awaitthis.publishDailySummary(context,nearItems.length);return;}

采购清单闭环

采购清单不是孤立的,它和物品状态是联动的。核心逻辑在ShoppingRepository.ets里,形成了一条闭环:

  1. 物品用完 →markAsEmpty()把状态设为 EMPTY → 自动调ensurePendingItem()生成采购项
  2. 采购完成 →completeShopping()→ 调ItemRepository.restockFromShopping()自动回库补货
  3. 回库时重置状态为 NORMAL,累加数量,按shelfLifeDays重算到期日期

ensurePendingItem有个小优化:如果同一个物品已经有未完成的采购项,不新建,而是数量累加。避免采购清单里同一个东西出现好几行:

ensurePendingItem(item:Item):ShoppingItem{constlist=this.getAll();constexisting=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();returnexisting;}// ... 新建采购项}

回库补货restockFromShopping里重算到期日期的代码也值得看一下,它以"今天"为起点算到期日,而不是沿用原来的购买日期:

restockFromShopping(itemId:string,qty:number):Item|null{constitems=this.getAll();constidx=items.findIndex(item=>item.id===itemId);if(idx<0)returnnull;constnow=Date.now();items[idx].status=ItemStatus.NORMAL;items[idx].quantity=items[idx].quantity+qty;items[idx].purchaseDate=now;if(items[idx].shelfLifeDays>0){constmsPerDay=86400000;conststartDay=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();returnitems[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:

asyncrefreshAllCards():Promise<void>{constformIds=PrefStore.getInstance().getActiveFormIds();if(formIds.length===0)return;for(constformIdofformIds){try{constformName=PrefStore.getInstance().getFormName(formId)||'expiry_card';constdata=this.buildFormBindingData(formName);formProvider.updateForm(formId,data);}catch(e){Logger.error(`CardService refresh failed for${formId}:${e}`);}}}

卡片的FormExtensionAbility(NestFormAbility)负责生命周期管理:onAddForm时构建初始数据并持久化 formId,onUpdateForm时按定时刷新机制重建数据,onRemoveForm时清理 formId。

需求修复

项目跑起来之后发现三个问题,都是状态同步相关的:

  1. 物品删除后,收纳位置的物品数量没更新——还是显示旧数量
  2. 点"已用完/已丢弃"后,保质期页的"已过期/7天内/30天内"数量不更新,切页面再回来才刷新
  3. 采购清单增减后,元服务卡片不刷新

这三个问题根因是一样的:数据变更后没有同步刷新依赖的视图。修复方法就是在每个 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 条),超过后降级为汇总提醒,这一点规划时容易忽略。

接下来第三款应用具体做什么还没想好,等确定下来在与大家进行分享.

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询