调饮台交互工程(第 3 讲):HarmonyOS计算属性与计价约束【共创稿事节】
总价可以有两种来源:每点一次按钮就更新一个金额变量,或者根据当前规格重新得到金额。前一种方案开始很直观,直到加料、数量、优惠都能改变价格,每个入口都要记得再算一遍。调饮台这一阶段不增加新的写入状态,而是让订单保持原样,把费用明细定义为订单的派生结果。

一、派生值为什么不该成为第二份订单
第01篇已经定义 DrinkOrder,第02篇建立了杯型输入与事件。现在继续沿用 MixLab、API 12 和 Stage 页面,不改公共订单字段,新增 common/Pricing.ets,替换入口 pages/Index.ets,并使用第02篇完整的 components/SizeSelector.ets。这些代码只进行本地规则计算,不需要网络、支付或存储权限。
先把示例规则写成可核对的约定:桂花乌龙基础价十八元,中杯不加价,大杯增加三元,珍珠增加两元;数量一至九杯。三杯起,优惠金额取原价百分之五后向下取整到分,总价为原价减优惠。糖度和冷热不影响金额。这些是示例产品规则,不来自系统接口;计算属性只负责随输入变化更新结果。
关键差别在于写权限。杯型、加料和数量可以被用户改变,总价不能被任何按钮直接修改。如果总价成为一个独立可写字段,恢复订单时就可能漏掉金额,校验失败时也可能留下上一次价格。让派生规则只有输入和输出,错误订单便能明确得到错误结果,而不是显示一笔看起来正常的旧金额。
官方的 @Computed 从 API 12 开始支持,可用于缓存依赖状态的计算结果。文档也提醒,简单计算不一定适合引入它,因为缓存机制本身有开销。本例把校验、费用拆分与优惠计算放在一个结果中,并让多个位置读取同一结果,重点是控制数据关系;是否产生可感知的性能收益,需要在实际页面中另行测量。官方计算属性说明给出了使用范围与限制。
二、把规则写成纯函数
计价函数只读订单,返回新结果,不回写杯型,不修正数量,也不在内部发送请求。非法输入通过结果携带原因。格式化函数只负责把分转成金额文本,不能参与计算;金额字符串一旦被拼入货币符号,就不再适合作为算术输入。
// common/Pricing.ets
import { DrinkOrder } from './DrinkOrder';
export class Quote {
unitFen: number;
subtotalFen: number;
discountFen: number;
totalFen: number;
error: string;
constructor(unit: number, subtotal: number, discount: number,
total: number, error: string) {
this.unitFen = unit;
this.subtotalFen = subtotal;
this.discountFen = discount;
this.totalFen = total;
this.error = error;
}
}
export function money(fen: number): string {
return `¥${(fen / 100).toFixed(2)}`;
}
export function buildQuote(order: DrinkOrder): Quote {
const base: number = order.basePriceFen;
const qty: number = order.quantity;
const size: string = order.size;
const pearls: boolean = order.pearls;
if (!Number.isInteger(base) || base < 0 || base > 1000000 ||
!Number.isInteger(qty) || qty < 1 || qty > 9) {
return new Quote(0, 0, 0, 0, '价格或数量无效');
}
if (size !== '中杯' && size !== '大杯') {
return new Quote(0, 0, 0, 0, '请选择有效杯型');
}
const unit: number = base + (size === '大杯' ? 300 : 0)
+ (pearls ? 200 : 0);
const subtotal: number = unit * qty;
if (!Number.isSafeInteger(subtotal)) {
return new Quote(0, 0, 0, 0, '金额超出处理范围');
}
const discount: number = qty >= 3 ? Math.floor(subtotal * 5 / 100) : 0;
return new Quote(unit, subtotal, discount, subtotal - discount, '');
}
这段代码把四个参与计价的字段先取出,便于核对依赖范围。糖度不在里面,改变糖度无需改变计算结果;饮品名称也不参与金额,但选择另一种饮品时基础价会随选择函数改变。规则与展示文案分离后,修改“桂花乌龙”的名字不会意外成为重算价格的业务条件。
不要把百分比优惠理解成可以任意使用浮点金额。示例的商品价格和数量规模很小,金额始终以分计算,最终优惠按约定取整;如果系统允许极大金额或者更复杂的小数比例,应继续收紧范围,或使用合适的高精度方案。这里的安全整数检查是输入保护,不是完整金融系统精度设计。
函数返回的 Quote 作为结果快照使用,不再被页面修改。它没有观察装饰器,因为更新来自计算结果整体替换,而不是修改结果内部的金额。若消费者拿到结果后又调整折扣,就重新引入了第二个写入入口。价格规则需要变化时,应修改输入规则并重新计算,而不是对结果进行补丁式修正。

优惠取整的单位必须在规则评审时说清楚。先给每杯打折再乘数量,与先汇总整单再计算优惠,在存在不足一分的部分时可能产生不同结果。本例明确采用整单优惠,先求原价,再对优惠金额向下取整。以后新增优惠券,也需要决定先减券还是先计算比例优惠;这些顺序属于产品合同的一部分,不能由开发者根据哪个表达式更短临时决定。
代码还把基础价格限制在一百万分以内,这是这个示例的明确输入上限,配合九杯数量限制,让乘法和优惠计算处在可检查范围中。若允许改变上限,不能只修改错误文案,还应重新评估所有中间计算是否安全。范围校验和最终格式化是两件事,小数点后显示两位并不意味着计算过程天然精确。
三、把同一份结果绑定到多个位置
入口页面增加一个计算属性。它同步调用纯函数,读取订单的可观察字段,并把返回结果用于单杯金额、原价、优惠及合计。不要在 getter 中更改订单、弹提示或启动计时器;这些操作会让一次读取拥有额外效果,甚至制造新的状态变更链。
// pages/Index.ets
import { DrinkOrder, setQuantity } from '../common/DrinkOrder';
import { SizeSelector } from '../components/SizeSelector';
import { Quote, buildQuote, money } from '../common/Pricing';
@Entry
@ComponentV2
struct Index {
@Local order: DrinkOrder = new DrinkOrder();
@Computed
get quote(): Quote {
return buildQuote(this.order);
}
build() {
Scroll() {
Column({ space: 18 }) {
Text('调饮台').fontSize(28).fontWeight(FontWeight.Bold)
Text('桂花乌龙 · 费用明细').fontColor('#727A84')
SizeSelector({ size: this.order.size, onSelect: (next: string) => {
if (next === '中杯' || next === '大杯') { this.order.size = next; }
} })
Column({ space: 12 }) {
Text('加料与数量').fontSize(18)
Button(this.order.pearls ? '已加珍珠' : '不加珍珠')
.onClick(() => { this.order.pearls = !this.order.pearls; })
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))
}
Text('满3杯减原价的5%,优惠向下取整到分').fontSize(12)
}.width('100%').padding(18).backgroundColor('#FFFFFF').borderRadius(18)
Column({ space: 12 }) {
Text('费用明细').fontSize(18)
if (this.quote.error.length > 0) {
Text(this.quote.error).fontColor('#B83E3E')
} else {
Text(`单杯 ${money(this.quote.unitFen)}`)
Text(`原价 ${money(this.quote.subtotalFen)}`)
Text(`优惠 ${money(this.quote.discountFen)}`)
Text(`合计 ${money(this.quote.totalFen)}`)
.fontSize(28).fontWeight(FontWeight.Bold).fontColor('#176B5B')
}
}.width('100%').padding(18).backgroundColor('#FFFFFF').borderRadius(18)
Button('恢复默认').width('100%').backgroundColor('#176B5B')
.onClick(() => { this.order = new DrinkOrder(); })
}.padding(24).width('100%')
}.width('100%').height('100%').backgroundColor('#F4F6F3')
}
}

示例界面选择大杯、加珍珠和两杯:单杯二十三元,原价四十六元,优惠零元,合计四十六元。增加到三杯后,原价六十九元,优惠三元四角五分,合计六十五元五角五分。这些数值可以直接按代码规则演算,适合作为操作后的预期结果,也能作为计价规则的测试基准。
界面多次读取 quote,表达的是多个控件使用同一个派生结果。若只是在某一处展示两个数相加,普通表达式就足够;若需要跨页面复用计价规则,保留纯函数也比把全部逻辑锁进页面 getter 更方便。计算属性和纯函数各自承担不同职责,并不互相替代。
另外,不要在读取完 quote 后把它存入一个长期保留的普通变量,再期待这个变量自动跟随订单改变。缓存属于计算属性自身,调用方保存的旧结果仍然是旧结果。页面应直接绑定计算属性;需要提交快照时,则在明确的提交入口读取一次并与当前订单版本一起处理。
四、比较的是依赖关系,不是装饰器数量
手动维护总价的方案,每个输入入口都要承担修改规格和更新金额两项责任;普通纯函数在读取时计算,适合调用次数少、成本低的场景;计算属性将依赖与缓存结合,适合同一派生结果被重复使用。三者没有统一的“最快”结论,应先判断一致性和调用形态,再考虑实测成本。

做验证时,按输入维度而不是按按钮排列测试。基础价变化影响单价和总价;杯型变化增加或减少三元;加料变化增加或减少两元;数量在二变三时跨过优惠边界;重置必须同时让规格和费用回到默认。再构造非法杯型、零杯和非整数数量,检查费用区显示原因,不残留上一次合计。
这里没有在计价函数里检查糖度,因为它不影响计价合法性;订单是否可提交属于更完整的业务校验,需要同时检查饮品仍可售、配方可组合等条件。把两层检查混为一谈,会让计算金额的函数不得不访问库存和网络。费用预览应该可重复计算,最终交易则需要单独的权威确认。
还要观察一个容易误读的现象:修改不参与计价的糖度后,界面有变化,不代表价格计算应该变化。验证可以给计价函数设置断点,检查实际依赖行为,但不要为此在 getter 中累加一个参与界面显示的计数器。观察手段如果又写入响应式状态,可能改变被观察的执行关系。
还有一组值得保留的边界样例:中杯无珍珠一杯应为十八元,大杯无珍珠一杯应为二十一元,中杯有珍珠一杯应为二十元。先用它们分别验证两个附加项,再验证大杯有珍珠,能够发现加价写错方向或重复叠加的问题。不要只检查四十六元这一种组合,因为某些相互抵消的错误也可能碰巧得到同一个合计。
错误状态的展示同样属于正确性。假如订单被恢复成无效杯型,费用区应给出原因并隐藏金额,不能同时显示红色错误和上一次绿色合计。否则用户很难判断哪一个信息有效。本例用互斥分支表达这一点;等加入提交按钮时,按钮可用性也应来自同一校验结果,而不是再复制一套不完全相同的判断。
计算属性保持同步还有一个好处:它的结果对应同一时刻读取到的本地输入。把远端折扣请求直接放进 getter,会引入请求排序、失败、取消与页面离开等问题,而且一次读取可能产生多次外部动作。远端规则应先以显式异步流程获得并更新状态,再由同步计价逻辑消费;第05篇会专门处理异步结果发布的顺序。
五、让计价结果保持可解释
这次改造让费用区与订单之间形成单向关系,订单改变时得到新费用,费用本身没有反向控制订单的职责。用户看到的原价、优惠和合计都可以追溯到同一条规则,产品调整优惠时也能集中修改,而不是寻找散落在不同按钮里的赋值。
真正接入交易之前,还需要把界面估价与最终结算分开。界面估价允许离线响应,最终结算必须遵循实际价格来源;服务端返回新价格时应提示用户确认,不能静默覆盖已展示的承诺。这个边界与使用哪一种状态管理机制无关,却决定了价格变化是否能够被理解。
下一篇会加入“修改后尚未确认”的编辑草稿。价格计算不必重写,只要给纯函数传入草稿,就能得到草稿费用;正式订单则在用户确认之前维持原状。这正是把规则从点击回调中抽出来的长期价值:输入边界变了,规则仍然保持同一份。
官方参考:https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-new-computed/@Computed计算属性、https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-new-observedv2-and-trace/@ObservedV2与@Trace。
更多推荐
所有评论(0)