调饮台交互工程(第 1 讲):HarmonyOS V2状态建模与订单选择【共创稿事节】
一杯桂花乌龙有饮品、杯型、糖度、冰量、加料和数量六组选择。把它们分别挂在按钮上,页面很快就会出现两个版本的答案:规格卡显示中杯,底部摘要还保留大杯。问题通常不在刷新速度,而在状态究竟由谁持有、一次选择应该修改哪些字段。这套调饮台交互工程先确定订单的所有权,再逐步加入组件拆分、计价、草稿编辑与异步搜索。

一、先画出状态的所有权
工程名使用 MixLab,采用 Stage 模型和 ArkTS 声明式页面。本文选择 API 12 作为能力基线,使用普通应用页面,不涉及卡片,也不申请相机、网络或存储权限。新建 Empty Ability 工程后,保留模板中的 EntryAbility,让其加载 pages/Index;页面清单保留这个入口。后续代码分别放在 entry/src/main/ets/common/DrinkOrder.ets 与 entry/src/main/ets/pages/Index.ets。
这个小页面只保留一份可编辑订单。饮品目录是固定资料,订单是用户当前选择,按钮高亮和摘要是订单的展示结果。三者的修改频率、生命周期和责任不同,没有必要统统放进一个巨大的全局对象。目录可以被多个页面读取;订单先由入口页面持有,关闭页面后随页面释放。需要跨页面保留时,再明确提升它的所有者。
选择状态管理 V2 的理由也很具体:订单里的字段需要分别被观察,页面有时还要整体换掉订单。官方说明中,@Local 负责组件内部状态变量,@ObservedV2 与 @Trace 配合让类属性具备观察能力;这些能力均从 API 12 开始提供。这里让前者管理对象引用,让后者管理字段变化,分别对应“换一张订单”和“修改这张订单”。参见官方的 https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-new-local/@Local 说明 与 https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-new-observedv2-and-trace/@ObservedV2、@Trace 说明。
不能把这种分工简化成“对象加一个装饰器就会自动刷新”。如果订单中再嵌套普通类,里面的字段不会因为外层带了装饰器而天然具备相同能力。现在采用平直模型,正是为了让更新路径可以直接检查。后续出现地址、优惠或复杂配方,再按独立职责拆类,而不是提前建立多层对象树。

二、让模型表达一张订单
先写公共模型。金额以分保存,避免把展示字符串当作计算输入;糖度用数值表达,避免比较“半糖”“五分糖”这样的文案。饮品标识在逻辑上稳定,名称只负责展示。目录中的价格、名称和标识通过同一个选择函数进入订单,按钮不应分别维护这三个字段。
// common/DrinkOrder.ets
export class DrinkSpec {
id: string;
name: string;
basePriceFen: number;
constructor(id: string, name: string, basePriceFen: number) {
this.id = id;
this.name = name;
this.basePriceFen = basePriceFen;
}
}
export const DRINKS: DrinkSpec[] = [
new DrinkSpec('osmanthus', '桂花乌龙', 1800),
new DrinkSpec('jasmine', '茉莉鲜奶', 2200),
new DrinkSpec('lemon', '青柠红茶', 1600)
];
@ObservedV2
export class DrinkOrder {
@Trace drinkId: string = 'osmanthus';
@Trace drinkName: string = '桂花乌龙';
@Trace basePriceFen: number = 1800;
@Trace size: string = '中杯';
@Trace sugar: number = 50;
@Trace iced: boolean = true;
@Trace pearls: boolean = false;
@Trace quantity: number = 1;
}
export function selectDrink(order: DrinkOrder, spec: DrinkSpec): void {
order.drinkId = spec.id;
order.drinkName = spec.name;
order.basePriceFen = spec.basePriceFen;
}
export function setQuantity(order: DrinkOrder, next: number): void {
if (!Number.isInteger(next) || next < 1 || next > 9) {
return;
}
order.quantity = next;
}
DrinkSpec 没有观察装饰器,因为本例不会在运行中修改目录项。把不会变化的资料做成响应式对象并不能提升正确性,反而容易让读者误以为它属于订单。若后来从服务端刷新目录,应通过一个清晰的替换入口发布新目录,并考虑已选饮品下架的处理规则,不能直接把目录对象与订单对象混成一份引用。
数量限制放在函数里,而不是只写在按钮的禁用条件里。这一层不是为了抵御攻击,而是保证同一个规则能覆盖按钮、快捷操作和以后加入的恢复入口。页面禁用减号能改善体验,函数校验负责兜底。输入越过边界时,本例保持原值;需要解释原因的业务可以让函数返回明确结果,再由页面显示提示。
模型中允许记录加料和冰量,当前页面只展示它们的初始值。保留这些字段是为了让后续四篇共享同一张订单,而不是在每篇重新发明结构。字段没有出现在输入控件里,并不代表应该删除;但字段没有业务语义、只是为了某张卡片的颜色存在时,就应该放回视图层。
目录与订单之间还存在一个容易忽略的取舍:订单为什么保存名称和基础价格,而不只保存标识?本系列把它们视为当前选择时的快照,这样后面引入编辑草稿时,界面不会被目录的临时变化牵着走。但快照不代表最终交易价格,实际提交仍应由具有定价权的一方复核。若应用需要始终展示最新目录价格,就应改成按标识查找并处理查找失败,不能一边保留快照、一边假设它永远最新。
所有可写字段都集中在一个类里,不等于它们可以任意组合。比如选择函数同时更新三项饮品信息,表达的是一项业务动作。未来加入缺货判断时,应先验证目标饮品可选,再执行这些赋值,避免先更新名称、随后检查失败却忘记恢复价格。页面暂时没有异步参与,所以这个约束容易维护;接入远端数据之后,也应保留先校验再发布的顺序。
三、页面只读取订单并发出修改
入口页面直接持有 DrinkOrder 实例。创建实例要使用 new,不要把一个对象字面量通过类型断言伪装成这个类。类型名称并不会补上实例的运行时行为,后面做草稿克隆时也要保留这一点。
// pages/Index.ets
import { DrinkOrder, DrinkSpec, DRINKS,
selectDrink, setQuantity } from '../common/DrinkOrder';
@Entry
@ComponentV2
struct Index {
@Local order: DrinkOrder = new DrinkOrder();
build() {
Scroll() {
Column({ space: 18 }) {
Text('调饮台').fontSize(28).fontWeight(FontWeight.Bold)
Text('先选一杯,再确定规格').fontColor('#727A84')
Column({ space: 10 }) {
Text('饮品').fontSize(18)
ForEach(DRINKS, (drink: DrinkSpec) => {
Button(drink.name)
.width('100%')
.backgroundColor(this.order.drinkId === drink.id
? '#176B5B' : '#E8EEEB')
.fontColor(this.order.drinkId === drink.id
? '#FFFFFF' : '#263D36')
.onClick(() => selectDrink(this.order, drink))
}, (drink: DrinkSpec) => drink.id)
}.padding(18).width('100%').backgroundColor('#FFFFFF')
.borderRadius(18)
Column({ space: 10 }) {
Text('当前选择').fontSize(18)
Text(`${this.order.drinkName} · ${this.order.size}`)
Text(`糖度 ${this.order.sugar}% · ${this.order.iced ? '冰饮' : '热饮'}`)
Text(this.order.pearls ? '加珍珠' : '不加料')
}.padding(18).width('100%').backgroundColor('#FFFFFF')
.borderRadius(18)
Column({ space: 12 }) {
Text('数量').fontSize(18)
Row({ space: 24 }) {
Button('−').enabled(this.order.quantity > 1)
.onClick(() => setQuantity(this.order, this.order.quantity - 1))
Text(`${this.order.quantity} 杯`).fontSize(22)
Button('+').enabled(this.order.quantity < 9)
.onClick(() => setQuantity(this.order, this.order.quantity + 1))
}
}.padding(18).width('100%').backgroundColor('#FFFFFF')
.borderRadius(18)
Button('重置订单').width('100%')
.backgroundColor('#176B5B')
.onClick(() => { this.order = new DrinkOrder(); })
}.padding(24).width('100%')
}.width('100%').height('100%').backgroundColor('#F4F6F3')
}
}

页面没有再保存 selectedName、selectedColor 或 summary。选中颜色读取饮品标识,摘要读取当前订单,数量读取同一字段,因此一次变化有一条可追踪的来源。这样的代码并不保证界面设计不会出错,但能缩小排查范围:先确认字段是否变化,再确认对应组件是否读取了那个字段。
这里的 ForEach 使用饮品标识作为键。标识描述条目身份,显示名称描述内容;将名称或数组索引当键,会把改名或重排误当成身份变化。当前目录只有三项,问题不容易暴露,但越是在小例子里越应保留正确约定,后续接入动态目录时才能沿用。
整个内容放入 Scroll,因为页面高度、系统字体和窗口尺寸都可能变化。不要为了让截图好看把内容固定到一个设备高度。每块卡片只约束横向宽度,竖向交给内容决定;数量行也没有把文字压成固定宽度,以免用户放大字体后出现按钮与文本重叠。
四、用变化路径验证,而不是凭感觉点几次
验证从初始订单开始:应看到桂花乌龙、中杯、糖度百分之五十、冰饮、不加料和一杯。随后选择茉莉鲜奶,检查按钮选中态与摘要同时切换;数量连加到九杯后,加号禁用,再调用一次越界的数量函数,订单也不应被改成十杯。最后点重置,整张订单恢复默认对象。
这组操作同时覆盖了属性修改、无效输入和对象整体替换。若饮品名称变化而高亮不变,重点核对 selectDrink 是否更新标识;若修改数量没有反映到页面,核对是否给正确实例的字段赋值;若重置后再操作无效,则检查有没有把旧实例缓存到另一个普通变量里继续修改。
调试时可以在 selectDrink 和 setQuantity 下断点观察参数。模型问题不必一开始就用动画、强制刷新或延迟函数处理。延迟偶尔能改变画面出现的时机,却不会修复错误的所有权。只有确认数据与依赖正确后,才值得继续检查布局和交互反馈。
还要把“页面重建”与“对象更新”分开验证。当前订单属于页面内存,重启应用回到默认值符合本篇边界。如果产品要求恢复上次配置,需要新增持久化设计并明确保存时机,不能把观察装饰器当成自动存储功能。同样,价格字段在此只是后续计价的输入,不代表订单已支付或已提交。
还可以做一次针对实例身份的反向检查:把重置按钮改成新建对象之后,分别选择另一杯饮品和调整数量,确认两条交互都读取新的订单引用。这个检查针对的是闭包或工具类保存旧引用的风险,与检查默认值是否正确不同。如果后续抽取控制器,尽量让操作接收当前订单参数,而不是在构造控制器时永久绑定第一页产生的实例。
在代码评审中,数值边界也应覆盖非整数。数量按钮只会生成整数,但公开函数将来可能被文本输入调用,因此函数内部保留整数判断有实际价值。不要在底层悄悄把一点五杯截成一杯,否则上层无法区分成功与纠错。当前实现拒绝非法值,下一步若增加提示,可以返回布尔结果或者带原因的结果对象,保持原来的数据不被污染。
五、给后续变化留出明确入口
到这里,调饮台形成了一个可继续扩展的最小闭环:目录提供可选项,页面持有当前订单,操作函数约束写入,界面从订单读取结果。没有借助全局单例,也没有为每个展示结果创建一份状态。随着功能增加,最容易失守的部分是子组件开始直接修改父订单,下一篇会把这条边界改造成明确的参数与事件接口。
这一阶段可以保留一项评审约定:每增加一个状态字段,都回答它由谁创建、允许谁修改、何时失效。若答案只是“放在这里方便”,通常还需要继续讨论。一个小型调饮页面真正需要的架构,并不是更多抽象层,而是让修改发生的位置与业务责任对应。
对于运行中会变化的营业规则,本例的常量目录还不够。应先定义新规则如何影响已有订单,再决定目录与订单如何同步;不同步、静默覆盖和要求用户重新确认是三种不同产品行为。状态管理能够传播变化,不能替代这些业务决策。把这一点留在模型边界上,后续增加优惠、缺货或多杯组合时,页面才不会依靠零散条件勉强维持一致。
官方参考:状态管理V2的@Local、https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-new-observedv2-and-trace/@ObservedV2与@Trace。
更多推荐
所有评论(0)