前言

之前我用 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 文件方案简单直观,启动时一次性加载到内存,读写都走缓存,够用。

项目预览

先看效果,四个动图覆盖了主要功能:

01-compressed02-compressed
03-compressed4-compressed

从效果预览中能看到《归巢》的核心功能链路:添加物品 → 管理物品存储空间 → 采购清单 → 过期物品提醒。这条链路不是随意拼的,它对应的是家庭物品的一个完整生命周期——买进来、放好、用完补货、过期前预警,每一环都连上了。

项目开发

前面已经把项目整体过了一遍,这一节讲怎么用 HarmonyOS 开发助手把项目从零搭到上架。核心思路和上一篇文章一样:先写一份项目规划书,再把它喂给开发助手,让它按规划生成代码。

规划先行

我坚持先写规划再写代码,是因为助手不会替你做产品决策。如果你自己都没想清楚要分几个页面、数据模型长什么样、存储用哪个方案,直接让助手"写一个家庭物品管理 App",它给你生成的东西大概率是 Demo 级别的,业务逻辑经不起推敲。

下面是规划书的部分截图,里面定义了功能模块、数据模型、存储方案、卡片设计:

image

规划书的关键内容我列一下,这些后来都直接映射成了源码结构:

  • 四个核心 Tab:首页概览 / 物品列表 / 采购清单 / 保质期提醒
  • 三级收纳层次:房间 → 柜子 → 层(对应 LocationLevel 枚举的 ROOM/CABINET/LAYER)
  • 物品状态机:NORMAL(正常)→ EMPTY(已用完)→ DISCARDED(已丢弃)/ ARCHIVED(已归档)
  • 过期状态:NONE(长期)/ NORMAL(正常)/ NEAR(临期)/ EXPIRED(已过期),临期阈值可配(默认 7 天)
  • 两张卡片:保质期卡片(2×2)+ 采购清单卡片(2×4),都是 ArkTS 动态卡片

喂给助手

规划书准备好后,提示词其实不需要花哨,把规划书完整给到助手,让它按规划生成代码就行:

image

助手生成代码后,一定要跑真机验证。规划书写得再详细,助手生成的代码也可能有边界问题,这些只有在真机上才能暴露出来。

数据模型设计

源码中数据模型定义在 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 里,形成了一条闭环:

  1. 物品用完 → markAsEmpty() 把状态设为 EMPTY → 自动调 ensurePendingItem() 生成采购项
  2. 采购完成 → completeShopping() → 调 ItemRepository.restockFromShopping() 自动回库补货
  3. 回库时重置状态为 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。

需求修复

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

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

这三个问题根因是一样的:数据变更后没有同步刷新依赖的视图。修复方法就是在每个 Repository 的写操作末尾加 CardService.getInstance().refreshAllCards() 调用,同时页面在 onShown 生命周期重新拉数据。修复前后对比:

01-compressed02-compressed

把问题描述给到助手,它直接在对应的 Repository 方法里补上了 refreshAllCards() 调用。源码里能看到,ItemRepository 的 createItem、updateItem、deleteItem、markAsEmpty、markAsDiscarded、restockFromShopping 方法末尾都有这一行;ShoppingRepository 的 ensurePendingItem、addManualItem、completeShopping、cancelShopping、deleteShopping 也都加了。统一加在 Repository 层而不是 UI 层,是因为 UI 层有多个入口可能触发同一个数据变更,放 Repository 层能保证不遗漏。

完成上架

所有问题修复后就可以提审了。发布流程在 AGC(AppGallery Connect)里完成,填写元服务信息、上传截图、提交审核:

image

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

image

至此我已经用 HarmonyOS 开发助手上架了两款元服务,接下来准备做第三款。

总结

这次《归巢》从规划到上架花了两天时间,主要时间花在规划书编写和真机调试上,代码生成本身非常快。过程中有一些体会:

  • 规划书决定了代码质量上限。数据模型、存储方案、状态机这些在规划阶段就得定清楚,否则助手生成的代码改起来比从头写还费劲。
  • JSON 文件 + preferences 的轻量存储方案对元服务够用,但数据量上来后需要考虑迁移到 relationalStore,源码里预留了 migrateLegacyIfNeeded 的迁移逻辑做参考。
  • 卡片刷新要主动调 formProvider.updateForm,不能指望定时刷新。定时刷新(每天 08:00)只解决"过了一夜数据要更新"的场景,实时变更得自己推。
  • 代理提醒有配额限制(代码里设的 20 条),超过后降级为汇总提醒,这一点规划时容易忽略。

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

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐