
前言之前我用 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;// ROOM1, CABINET2, LAYER3name: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的原子写策略防止写一半进程被杀导致数据损坏privatewriteArrayT(fileName:string,data:T[]):void{constpaththis.filePath(fileName);consttmpthis.tmpPath(fileName);// 先写 .tmp 文件try{constcontentJSON.stringify(data);constfilefileIo.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.flushTimer0)return;this.flushTimersetTimeout((){this.flushTimer-1;this.flushAll();},300)asnumber;}也支持immediate: true立即写盘用于关键操作比如物品状态变更确保不丢数据。过期提醒逻辑过期提醒是项目的核心功能之一代码在ExpiryCalculator.ets和ReminderService.ets。过期状态计算逻辑很简单但有几个边界要处理好——已用完和已丢弃的物品不应该报过期没保质期的长期物品直接跳过exportfunctiongetExpiryStatus(item:Item,now:number,nearDays:number):ExpiryStatus{if(item.expireDate0)returnExpiryStatus.NONE;// 长期物品if(item.statusItemStatus.EMPTY||item.statusItemStatus.DISCARDED){returnExpiryStatus.NONE;// 已用完/丢弃的不再提醒}constddaysRemaining(item.expireDate,now);if(d0)returnExpiryStatus.EXPIRED;// 已过期if(dnearDays)returnExpiryStatus.NEAR;// 临期returnExpiryStatus.NORMAL;}daysRemaining按本地时区算天数差而不是简单的时间戳除以 86400000避免跨时区导致的差一天问题functionlocalDayOffset():number{returnnewDate().getTimezoneOffset()*-60000;}exportfunctiondaysRemaining(expireDate:number,now:number):number{if(expireDate0)returnNumber.MAX_SAFE_INTEGER;constoffsetlocalDayOffset();constexpireDayMath.floor((expireDateoffset)/86400000);constnowDayMath.floor((nowoffset)/86400000);returnexpireDay-nowDay;}代理提醒用的是kit.BackgroundTasksKit的reminderAgentManager可以发布系统级提醒不需要 App 在前台。但提醒有配额限制源码里写死了QUOTA_LIMIT 20超过就降级为每日汇总提醒constneededCountnearItems.length*2;// 每个物品两条到期前 到期当天if(neededCountReminderService.QUOTA_LIMIT){awaitthis.publishDailySummary(context,nearItems.length);return;}采购清单闭环采购清单不是孤立的它和物品状态是联动的。核心逻辑在ShoppingRepository.ets里形成了一条闭环物品用完 →markAsEmpty()把状态设为 EMPTY → 自动调ensurePendingItem()生成采购项采购完成 →completeShopping()→ 调ItemRepository.restockFromShopping()自动回库补货回库时重置状态为 NORMAL累加数量按shelfLifeDays重算到期日期ensurePendingItem有个小优化如果同一个物品已经有未完成的采购项不新建而是数量累加。避免采购清单里同一个东西出现好几行ensurePendingItem(item:Item):ShoppingItem{constlistthis.getAll();constexistinglist.find(ss.itemIditem.ids.statusShoppingStatus.PENDING);if(existing){existing.quantityexisting.quantityitem.quantity;// 累加数量MemoryCache.getInstance().setShoppingList(list,true);CardService.getInstance().refreshAllCards();returnexisting;}// ... 新建采购项}回库补货restockFromShopping里重算到期日期的代码也值得看一下它以今天为起点算到期日而不是沿用原来的购买日期restockFromShopping(itemId:string,qty:number):Item|null{constitemsthis.getAll();constidxitems.findIndex(itemitem.iditemId);if(idx0)returnnull;constnowDate.now();items[idx].statusItemStatus.NORMAL;items[idx].quantityitems[idx].quantityqty;items[idx].purchaseDatenow;if(items[idx].shelfLifeDays0){constmsPerDay86400000;conststartDayMath.floor(now/msPerDay);items[idx].expireDate(startDayitems[idx].shelfLifeDays)*msPerDay86399999;}items[idx].updatedAtnow;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.pngLocalStorageProp(nearCount)nearCount:number0;LocalStorageProp(expiredCount)expiredCount:number0;LocalStorageProp(hasData)hasData:stringfalse;build(){Stack(){Image(this.hasDatatrue?$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.updateFormasyncrefreshAllCards():Promisevoid{constformIdsPrefStore.getInstance().getActiveFormIds();if(formIds.length0)return;for(constformIdofformIds){try{constformNamePrefStore.getInstance().getFormName(formId)||expiry_card;constdatathis.buildFormBindingData(formName);formProvider.updateForm(formId,data);}catch(e){Logger.error(CardService refresh failed for${formId}:${e});}}}卡片的FormExtensionAbilityNestFormAbility负责生命周期管理onAddForm时构建初始数据并持久化 formIdonUpdateForm时按定时刷新机制重建数据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 层能保证不遗漏。完成上架所有问题修复后就可以提审了。发布流程在 AGCAppGallery Connect里完成填写元服务信息、上传截图、提交审核提交后一天就过审了效率不错至此我已经用 HarmonyOS 开发助手上架了两款元服务接下来准备做第三款。总结这次《归巢》从规划到上架花了两天时间主要时间花在规划书编写和真机调试上代码生成本身非常快。过程中有一些体会规划书决定了代码质量上限。数据模型、存储方案、状态机这些在规划阶段就得定清楚否则助手生成的代码改起来比从头写还费劲。JSON 文件 preferences 的轻量存储方案对元服务够用但数据量上来后需要考虑迁移到 relationalStore源码里预留了migrateLegacyIfNeeded的迁移逻辑做参考。卡片刷新要主动调formProvider.updateForm不能指望定时刷新。定时刷新每天 08:00只解决过了一夜数据要更新的场景实时变更得自己推。代理提醒有配额限制代码里设的 20 条超过后降级为汇总提醒这一点规划时容易忽略。接下来第三款应用具体做什么还没想好,等确定下来在与大家进行分享.