调饮台交互工程(第 2 讲):HarmonyOS组件参数与事件边界【共创稿事节】
把杯型按钮抽成子组件,看起来只需要剪切一段界面代码。真正容易出错的是剪切之后的赋值:子组件拿到整张订单,点“大杯”时直接改父对象;另一个规格组件又悄悄改糖度。页面虽然正常更新,修改来源却越来越难找。这一篇沿用调饮台订单,把组件之间的关系写成明确的输入和输出。

一、先把要抽取的交互说清楚
我们只抽取杯型选择器,约定它知道当前杯型、提供中杯与大杯两个选项,并把用户的选择交还给页面。它不知道订单价格,不负责折扣,不保存订单,也不决定大杯是否还可以购买。这样的接口比“接收一个订单对象”窄,却更适合重复使用:购物页、草稿编辑页和确认页都能解释同一组输入输出。
工程继续使用 MixLab,基线为 API 12 的普通应用页面。公共文件 common/DrinkOrder.ets 完整沿用第01篇第二节,其中包含 DrinkOrder、DrinkSpec、饮品目录和操作函数。新增 components/SizeSelector.ets,再替换 pages/Index.ets。这一篇不增加权限、不引入第三方依赖,入口仍由模板的 EntryAbility 加载。
官方对 @Param 的定义是接收组件外部输入,@Event 则表达向外输出的回调;两者在 @ComponentV2 中从 API 12 开始提供。这里采用基本类型的杯型字符串,避免子组件通过共享对象引用修改父订单内部字段。注意,禁止直接给参数重新赋值,并不等于复杂参数被深度冻结,组件接口仍然需要设计。相关约束可核对 https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-new-param/@Param 文档 与 https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-new-event/@Event 文档。
还要为事件选择一个表达意图的名字。onSelect 表示用户选中了某个杯型,不保证父页面一定接受。把事件叫作“更新成功”会混淆责任:当父页面发现大杯售罄时,它可以拒绝选择、保留中杯,并显示原因。子组件此时仍应忠实展示父页面传回的当前值。
二、用一个小组件完成输入与输出
下面是选择器的完整代码。参数提供默认值,让组件单独预览时也有合理画面;父页面传入状态时,以外部输入为准。事件给出空默认实现,表示没有消费者时点击不会产生业务写入。用于正式下单的页面仍必须接上事件,不能把默认实现当成完整功能。
// components/SizeSelector.ets
@ComponentV2
export struct SizeSelector {
@Param size: string = '中杯';
@Event onSelect: (next: string) => void = (next: string) => {};
build() {
Column({ space: 12 }) {
Text('杯型').fontSize(18).fontWeight(FontWeight.Medium)
Row({ space: 12 }) {
Button('中杯').layoutWeight(1)
.backgroundColor(this.size === '中杯' ? '#176B5B' : '#E8EEEB')
.fontColor(this.size === '中杯' ? '#FFFFFF' : '#263D36')
.onClick(() => this.onSelect('中杯'))
Button('大杯').layoutWeight(1)
.backgroundColor(this.size === '大杯' ? '#176B5B' : '#E8EEEB')
.fontColor(this.size === '大杯' ? '#FFFFFF' : '#263D36')
.onClick(() => this.onSelect('大杯'))
}.width('100%')
}.width('100%').padding(18).backgroundColor('#FFFFFF')
.borderRadius(18)
}
}
阅读代码时可以沿着两条方向看。由上往下,size 决定两个按钮的选中样式;由下往上,点击只输出候选杯型。组件没有保存第二个 selectedSize,因此父页面重置订单时,不需要再调用子组件的重置方法。少一份状态,就少一个同步入口。
不要在点击回调中写成先调用事件,再读取 this.size 判断父页面是否接受。官方说明了参数从父到子的同步并非在事件返回的同一位置立刻完成。若某段逻辑依赖业务处理结果,应为事件定义明确的结果协议,或者让父页面展示状态;不要依赖一次紧接着的读取猜测结果。
还有一个实用判断:组件内部是否存在“根据传入值复制一份本地值”的初始化。如果只是为了按钮高亮,这份副本通常多余;如果是让用户暂存编辑,它就具有草稿语义,必须额外定义确认、取消与外部输入变化时的策略。后者会在第04篇单独处理,本篇不把这两种场景揉在同一个组件里。

还有一种抽取方式是让选择器直接接收两个任意按钮文案,再根据数组索引回传结果。这会减少几行代码,却让业务含义变得不稳定:选项插入或重排之后,索引可能指向另一种规格。杯型数量有限时,传递具体值更容易审核。真正需要动态选项时,可以定义包含稳定标识与显示名称的选项类,事件返回标识,父页面再做查找和校验。
组件的默认输入也不是容错规则。若调用方传入“超大杯”,两个按钮都不会高亮,这恰好暴露了不受支持的值;不要悄悄在组件内部把它显示成中杯,否则展示与订单再次分离。是否把非法历史数据迁移为默认值,应由加载或恢复订单的入口决定,组件最多展示一个明确的无匹配状态。
三、让父页面接住意图并写入订单
页面负责最后的写入决定。这里虽然只有两个杯型,也保留候选值检查,因为组件以外的调用方将来可能传入不合法字符串。糖度按钮仍留在页面,用来对照两种组织方式;选择糖度时与杯型共用同一张订单,而不是为每个区域建立各自的数据源。
// pages/Index.ets
import { DrinkOrder } from '../common/DrinkOrder';
import { SizeSelector } from '../components/SizeSelector';
@Entry
@ComponentV2
struct Index {
@Local order: DrinkOrder = new DrinkOrder();
acceptSize(next: string): void {
if (next !== '中杯' && next !== '大杯') {
return;
}
this.order.size = next;
}
build() {
Scroll() {
Column({ space: 18 }) {
Text('调饮台').fontSize(28).fontWeight(FontWeight.Bold)
Text('为桂花乌龙选择规格').fontColor('#727A84')
SizeSelector({
size: this.order.size,
onSelect: (next: string) => this.acceptSize(next)
})
Column({ space: 12 }) {
Text('糖度').fontSize(18)
Row({ space: 10 }) {
Button('无糖').layoutWeight(1)
.backgroundColor(this.order.sugar === 0 ? '#176B5B' : '#E8EEEB')
.fontColor(this.order.sugar === 0 ? '#FFFFFF' : '#263D36')
.onClick(() => { this.order.sugar = 0; })
Button('半糖').layoutWeight(1)
.backgroundColor(this.order.sugar === 50 ? '#176B5B' : '#E8EEEB')
.fontColor(this.order.sugar === 50 ? '#FFFFFF' : '#263D36')
.onClick(() => { this.order.sugar = 50; })
Button('全糖').layoutWeight(1)
.backgroundColor(this.order.sugar === 100 ? '#176B5B' : '#E8EEEB')
.fontColor(this.order.sugar === 100 ? '#FFFFFF' : '#263D36')
.onClick(() => { this.order.sugar = 100; })
}
Text(`当前糖度 ${this.order.sugar}%`)
}.width('100%').padding(18).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.quantity} 杯 · ${this.order.pearls ? '加珍珠' : '不加料'}`)
}.width('100%').padding(18).backgroundColor('#FFFFFF')
.borderRadius(18)
Button('恢复默认规格').width('100%')
.backgroundColor('#176B5B')
.onClick(() => { this.order = new DrinkOrder(); })
}.width('100%').padding(24)
}.width('100%').height('100%').backgroundColor('#F4F6F3')
}
}

图中的界面对应点击“大杯”之后的状态:杯型区域高亮大杯,订单摘要也显示大杯,糖度仍为百分之五十。这个结果不是子组件与摘要互相通知得到的,而是它们共同读取父页面认可的订单值。恢复默认规格时,父页面替换订单实例,子组件继续接收新的杯型。
父页面传入箭头函数,使回调中访问的仍然是页面实例。组件事件不是一个任意的函数仓库,只暴露这次交互需要的参数更容易理解。若日后事件需要说明来源,可以增加明确的类型,例如区分点击和快捷选项;不要把整套页面上下文都塞进回调参数,令子组件与业务流程重新耦合。
糖度按钮也可以抽成组件,但不是所有重复的布局都值得立即抽取。先让杯型组件承担一次独立演进:增加“大杯暂不可选”时,只扩大它的展示输入和事件语义,价格仍由订单层处理。等糖度出现同样的规则结构,再判断是否提炼通用选项组件,避免过早设计包含大量布尔开关的万能控件。
四、沿着事件链检查异常
第一轮验证只测单向输入:临时把页面订单杯型设成大杯,进入页面后应看到大杯高亮。第二轮测事件输出:从中杯点到大杯,再点回中杯,确认摘要跟随父状态更新。第三轮测外部替换:选择大杯之后点恢复,确认子组件不保留旧高亮。这三轮分别覆盖输入、输出和生命周期中的替换,比连续乱点更能定位问题。
遇到“点击了却没有变化”,先看事件是否传入,再看父处理函数是否接受候选值,最后看组件是否使用了参数而非本地副本。不要一看到没有高亮就增加一个 @Local 变量;这样可能让按钮变色,却把拒绝操作的业务规则绕开,摘要仍然维持原来的状态。
另一个典型问题是共享对象。假如把接口改成 @Param order: DrinkOrder,然后在子组件里写 order.size,代码意图就变成了通过引用直接改父数据。即使观察机制能让界面更新,事件日志、校验和权限判断也可能被绕开。本例传基本值不是因为对象参数不可用,而是因为杯型组件没有理由获得整张订单的修改范围。
重复点击选中项需要单独讨论。这里再次选择同一杯型会输出相同候选值,父页面赋回相同结果,业务上没有额外动作。若父页面把该事件直接连接到付费、库存预占或远端提交,同值点击也可能产生副作用。因此,是否忽略重复选择应在对应业务入口决定,不能把视觉上没有变化误认为流程什么也没有发生。
验证禁用能力时也要区分展示与业务判断。将来为大杯按钮增加禁用属性,只能阻止该按钮产生事件,不能阻止其他入口调用 acceptSize。营业规则仍需在写入订单的位置判断。反过来,父页面拒绝选择也应有可理解的反馈,不能只让按钮保持不动,否则用户会以为触控失效。
为了检查复用是否真的成立,可以在开发页面临时放置两个选择器,并给它们传入两份不同的订单。点击第一份的大杯,不应改变第二份的高亮;然后让两个选择器改为读取同一份订单,它们又应同步显示同一结果。前一种检查发现不必要的全局共享,后一种检查发现偷偷存在的本地副本,验证目标并不相同。
如果后续需要记录操作历史,记录点应放在父页面接受选择之后,内容是旧杯型与新杯型,而不是子组件刚发出的候选值。被拒绝的选择可以记录为另一类交互事件,但不应混入已发生的订单变更。这样的分层让调试记录能够解释最终界面,也避免把一次触控误当成一次有效业务修改。
五、组件拆分完成的标志
拆分完成并不以文件变多为标准,而是能否独立回答组件的输入是什么、用户操作输出什么、谁最终修改业务值。这个选择器可以放到另一个容器里,只要得到杯型和回调就能工作;它不需要知道调用方是否正在下单、编辑草稿或展示套餐。
样式边界也应保持清楚。选择器管理自己内部按钮的间距和选中颜色,页面管理不同模块之间的距离。若双方都添加同一圈外边距,组合后就会出现不可解释的大空白;若子组件假设屏幕固定宽度,则很难放进平板侧栏。代码使用父容器宽度和等比分配,让容器变化时仍有合理的排版基础。
这一篇之后,订单仍是第01篇的同一个模型,只是杯型修改经过了显式接口。下一个需要收拢的是价格:大杯、加料和数量的变化不应该各自手动更新总价。第03篇会把计价规则变成派生结果,同时说明什么计算适合缓存,什么计算保持普通函数反而更清楚。
官方参考:https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-new-param/@Param装饰器、https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-new-event/@Event装饰器。
更多推荐
所有评论(0)