ArkUI + HarmonyOS 库存台账:让列表更新准确落到每一行 · 01【共创稿事节】
办公室里有三盒中性笔、一批打印纸,以及临时借出的转接头。把这些物品做成一张静态清单只需要几个 Text,但一旦同事点了“领用 1 件”,数据和页面能否同步更新,就决定了这是不是一个真正可用的工具。更难的是:低库存提醒、列表项内部数量和页面顶部汇总,是三个不同位置的数据视图。只让其中一个地方刷新,用户看到的就会互相矛盾。
这套六篇工程连载以 SupplyLedger 办公耗材库存台账 为主线。第一篇先解决最底层的问题:状态所有权、行组件更新与稳定的列表身份。此时不接后端、不读取相机,也不假设存在任何外部仓库服务,所有数据都是本地初始化的示例样例。后续依次推进筛选与编辑、持久化、领用记录、异常处理和多端布局,尽量让后一篇的代码能够直接接在前一篇后面。

一、最容易写错的是“数据变了,哪一层会知道”
设计页面之前先把交互说清楚:初始有三条记录,分别是 A4 打印纸 24 包、中性笔 8 盒、USB-C 转接头 3 个;各自的预警阈值是 10、5、5。顶部显示品类数和低库存品类数。每行有“领用 -1”和“入库 +5”按钮,领用不能使库存小于零;库存低于阈值时,本行变成橙色提醒。页面使用 InventoryPage.ets,物品用 SupplyItem 统一建模。
最简单的写法,是把 @State 数组放在父组件中,然后在 ForEach 中读 item.stock。问题在于:对象的属性可能在子组件里改变,而父组件的普通数组并不会自动获得任意深度的状态追踪。另一个高风险写法是用数组下标作渲染键,未来插入、删除、排序后,同一个下标可能已经对应另一件物品。若行组件还保存局部编辑状态,就可能出现“纸张的输入框跑到了中性笔那一行”的现象。
这里采用 ArkUI 状态管理 V1 的一套明确关系:父组件用 @State 拥有物品数组,数据类用 @Observed 标记,子行用 @ObjectLink 接收对应实例。每行的身份由物品业务 id 决定,数量在同一个实例上更新。V1 并非唯一的状态管理方案,官方也提供 V2;本篇选择 V1 是为了把现有项目里常见的数组对象、父子同步问题解释透,后续可在独立阶段评估 V2 迁移。

二、让数据模型承载业务约束,而不是让按钮猜规则
创建普通 Stage 模型的 ArkTS 页面,使用 entry/src/main/ets/pages/InventoryPage.ets。在 entry/src/main/resources/base/profile/main_pages.json 中将该页面加入路径列表,或者将示例页面内容直接放在默认生成的入口页。以下代码使用 @Entry、@Component、@State、@Observed 和 @ObjectLink,不需要网络权限。数据类型单独声明,构造函数负责设置全部字段,避免在页面里散落缺省值。
@Observed
class SupplyItem {
id: string;
name: string;
unit: string;
stock: number;
minimum: number;
constructor(id: string, name: string, unit: string,
stock: number, minimum: number) {
this.id = id;
this.name = name;
this.unit = unit;
this.stock = stock;
this.minimum = minimum;
}
}
stock 表示非负整数库存,minimum 是业务预警阈值,不代表系统权限或硬件能力。正式项目里库存修改要记录来源、操作人及版本号,此轮只是为了先把 UI 变化规则建立起来。不要把“库存减一”直接分散到多个按钮回调:如果某处遗漏下限校验,页面很容易出现负数;若后续接入扫码出库与批量操作,这类分散代码也不利于统一验证。
行组件接收一个可观察对象和一个向父组件报告用户动作的回调。子组件只负责显示与触发事件,不自行决定“扣减失败要不要弹窗”等全局规则:
@Component
struct StockCard {
@ObjectLink item: SupplyItem;
onAdjust: (id: string, delta: number) => void = () => {};
build() {
Column({ space: 12 }) {
Row() {
Text(this.item.name).fontSize(18).fontWeight(FontWeight.Bold)
Blank()
Text(this.item.stock < this.item.minimum ? '库存偏低' : '库存正常')
.fontColor(this.item.stock < this.item.minimum ? '#C46A22' : '#287B67')
}.width('100%')
Row({ space: 8 }) {
Text(`剩余 ${this.item.stock} ${this.item.unit}`)
.fontSize(16).fontColor('#475569')
Blank()
Button('领用 -1').onClick(() => this.onAdjust(this.item.id, -1))
Button('入库 +5').onClick(() => this.onAdjust(this.item.id, 5))
}.width('100%')
}
.padding(16)
.backgroundColor('#FFFFFF')
.borderRadius(18)
}
}
这里特意没有在子组件里另设一份 @State stock。一旦父、子各自管理数量,就会出现两个事实来源:父组件统计用一个值,子组件展示用另一个值。@ObjectLink 让行组件观察同一个 SupplyItem,按钮只把 id 与变化量传给父组件,数据的真正修改集中在父组件。
三、父组件把“修改、汇总、刷新”接成闭环
父组件先初始化三条记录,并显式维护 lowStockCount。这里的汇总不是永久保存的第二份库存,它只是为了让父级页面在修改行对象属性后获得确定的状态刷新;实际业务中也可以把汇总放在更明确的 store 或派生状态模型中。
@Entry
@Component
struct InventoryPage {
@State private items: SupplyItem[] = [
new SupplyItem('paper', 'A4 打印纸', '包', 24, 10),
new SupplyItem('pen', '中性笔', '盒', 8, 5),
new SupplyItem('adapter', 'USB-C 转接头', '个', 3, 5)
];
@State private lowStockCount: number = 1;
@State private notice: string = '点击按钮记录领用或入库';
private adjust(id: string, delta: number): void {
const target: SupplyItem | undefined = this.items.find(
(item: SupplyItem) => item.id === id
);
if (!target) {
this.notice = '未找到对应物品';
return;
}
const next: number = target.stock + delta;
if (next < 0) {
this.notice = `${target.name} 库存不足`;
return;
}
target.stock = next;
this.lowStockCount = this.items.filter(
(item: SupplyItem) => item.stock < item.minimum
).length;
this.notice = `${target.name}:库存调整为 ${next}`;
}
build() {
Column({ space: 16 }) {
Text('办公耗材台账').fontSize(28).fontWeight(FontWeight.Bold)
Row({ space: 18 }) {
Text(`品类 ${this.items.length}`)
Text(`低库存 ${this.lowStockCount}`)
.fontColor('#C46A22')
}.width('100%')
Text(this.notice).fontSize(13).fontColor('#64748B')
.width('100%')
List({ space: 12 }) {
ForEach(this.items, (item: SupplyItem) => {
ListItem() {
StockCard({ item: item,
onAdjust: (key: string, diff: number) => this.adjust(key, diff) })
}
}, (item: SupplyItem) => item.id)
}.layoutWeight(1).width('100%')
}
.padding(20).width('100%').height('100%')
.backgroundColor('#F3F6FA')
}
}
find 找不到对象时直接结束,避免对 undefined 写属性;库存不足时也不修改原对象。先计算 next 再决定是否提交,属于最小的事务思想:无效操作不产生中间状态。items.length 在本轮保持不变,如果后续支持新增和删除,数组的增删也必须由父组件统一完成,并保证每条记录的 id 不重复。
ForEach 的第三个参数返回 item.id,不是位置下标、当前库存或者当前时间。位置可能移动;库存会变;时间每秒都变。它们都不适合作为行的长期身份。稳定键让框架能够分辨“某件物品发生了变化”和“这是一件新物品”,也为下一篇的过滤、排序和编辑弹窗保留明确关联。

四、按操作序列验证,比只看截图可靠
验证可分成四条路径。第一条,首次打开页面应看到三种物品、低库存计数为 1,其中转接头被标记为“库存偏低”。第二条,连续点击转接头的“入库 +5”,其库存由 3 变成 8,预警标签切换为“库存正常”,顶部低库存计数从 1 变成 0。第三条,多次领用中性笔,数量从 8 变成 4 时,预警计数从 0 变成 1;继续领用至 0,再点一次“领用 -1”,应显示“库存不足”,库存保持 0。第四条,在插入或调整列表顺序的后续开发中,确认业务 id 与行组件仍然对应。
调试时先观察三个值:target.stock、lowStockCount 与 notice。如果行数量在变、顶部数字没变,检查聚合值是否被写回被观察的状态属性;如果列表内容串行,优先核对 ForEach 的键是否来自稳定业务 id;如果按钮没有反应,检查 onAdjust 是否传入了子组件,以及回调是否到达 adjust。代码示例可以通过 DevEco Studio 的 Previewer 或预览环境人工验证,但不能把预期结果写成经过真机测量的性能指标。
还要注意两个容易忽略的边界。第一,后续从持久层读回的数据要重建为 SupplyItem 实例,而不是直接拿原始 JSON 字符串当作受观察类;第二,当列表很长时,不应把沉重的排序与全量统计放到每个子行的 build 中,避免让每个行刷新都承担全局计算。本轮的三条样本数量很少,重点是关系正确,而不是提前夸大性能效果。
还有一种容易被忽略的情况:业务阈值和实际库存可能在同一次操作中跨越边界。例如转接头库存为 3,阈值是 5,此时入库 5 件后不能继续保留“库存偏低”;反过来,从 8 件领用到 4 件时,也不能只更新数字而漏掉预警标签。换句话说,库存变更并不是“更新一个文本组件”,而是从同一个事实推导出数量、颜色、提示语和汇总。把这些结果分别写在不同按钮里,只会增加它们不一致的机会。
在更大规模的项目里,还应当把数量类型限制在整数范围,例如针对扫码识别结果和导入文件统一执行 Number.isInteger 与非负校验。本轮按钮始终传入 -1 或 +5,所以可以先保持逻辑简洁,但对外部输入的防线不能因此省略。若以后增加“按箱入库”“批量领用”,还要让每次操作携带单位换算规则,不能因为页面展示单位是“盒”,就默认为所有物品都是同一个最小计数单位。前期把这一类边界写清楚,比后期在多个页面逐个修复数字错误更省力。
五、把复杂性留给真正需要它的下一步
这一阶段建立了三条约束:库存只在一个位置被修改;每一行通过稳定 id 识别;界面更新依赖明确的可观察状态。 它们比一张漂亮的库存页面更重要,因为后面新增筛选、保存、批量领用、异常恢复都会改变数据流。如果初始结构已经把对象复制成三份,增加功能时排查成本会更高。
下一篇将保持 SupplyItem 与 InventoryPage 的名称和业务 id 不变,增加搜索、分类过滤和编辑入口,讨论筛选后的列表引用究竟应该指向原对象还是复制对象,并在同一个工程里把“显示的集合”与“真正持有的数据”分开。当前实现仍是内存态,退出应用后数据会回到初始化样本;这正是随后讨论本地存储和故障恢复时需要解决的边界。
参考资料:华为开发者文档 状态管理概述、https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-observed-and-objectlink/@Observed 与 @ObjectLink、ForEach 列表渲染。
在真实业务中还应区分“库存数字发生变化”与“视图看起来发生变化”。例如,用户先搜索“中性笔”,随后给 A4 复印纸增加库存,页面当前筛选结果不应意外出现纸张卡片;清空搜索条件后,纸张行应显示更新的真实数量。这个场景可以检验业务 ID、筛选派生数组与对象引用是否相互独立。同时,更新失败时不能为了让列表动画顺滑而提前宣称写盘成功,后续持久化模块需要有明确的提交边界。
更多推荐



所有评论(0)