最近不少朋友在学完ArkTS基础语法之后,都在纠结同一个问题:下一个练手项目做什么?既要能把声明式UI、状态管理、组件封装这些核心知识点串起来,又希望做完之后真的能用到日常生活中,而不是又一个“课本里的Demo”。
我的建议是:做一个购物折扣计算器。
别小看这个题目。折扣计算器的业务复杂度刚好卡在一个非常舒服的位置——它比“计数器”“待办清单”这类入门项目多出不少真实的计算与交互细节,又不像商城、社交应用那样需要绞尽脑汁设计数据模型和页面流。做完它,你基本就能摸清HarmonyOS声明式开发从“静态页面”走向“可用工具”的全过程。这篇文章就把我实现这个应用时的完整思路、核心代码和踩坑记录都摊开来讲,从需求拆解到数据模型,再到页面实现和本地存储,最后聊几个实测中特别容易翻车的细节。无论你是刚接触HarmonyOS的新手,还是想找个项目练手的中级开发者,这篇都能给你一些可复用的东西。
1. 为什么拿“购物折扣计算器”练手:它刚好覆盖声明式UI的核心三件事
先聊点实在的,为什么要选这个项目。
很多初学者第一个练手项目是从“仿写一个页面”开始的,比如照着系统设置或者某个新闻App的界面临摹一遍。这类项目做完,最大的收获可能是把布局组件用熟了,但状态管理、数据驱动UI更新这些概念往往还是模糊的。因为临摹页面时,页面上的内容大多是写死的,组件之间几乎没有数据流动。
购物折扣计算器不一样。它天然是一个“输入—计算—输出”的应用,你输入商品价格、折扣率和满减规则,页面必须实时反馈最终应付金额。这意味着你躲不开状态管理,躲不开组件之间的数据传递,也躲不开UI随数据变化的联动刷新。这三件事,恰恰是声明式UI开发的核心。
从实际项目结构上看,它还能顺带覆盖下面这些能力点:
| 业务需求 | 对应的HarmonyOS技术点 |
|---|---|
| 多个商品同时录入、分别计算 | List + ForEach 列表渲染,自定义商品行组件 |
| 拖动滑块调整折扣率 | Slider 组件的事件回调与状态同步 |
| 输入原价后实时计算 | TextInput 的 onChange 事件与 @State 驱动 |
| 选择不同的满减规则 | Radio 或 Select 组件的受控用法 |
| 保存最近几次的计算结果 | PersistentStorage + @StorageLink 本地持久化 |
| 计算金额并展示 | 工具函数封装、精度处理、金额格式化 |
这样一拆,它就不再是“一个算折扣的小工具”,而是一堂覆盖了HarmonyOS声明式开发主干知识的实战课。做完之后,你再去学路由跳转、网络请求、自定义弹窗这些进阶内容,会发现底子已经打好了。
1.1 购物场景里的真实痛点:优惠叠加算不明白
聊需求之前,先想想我们平时购物遇到的实际问题。电商平台在促销期给出的优惠名目繁多:限时折扣、店铺满减、跨店满减、优惠券、会员折扣……它们叠加在一起的时候,很少有人能在心里快速算出“到底该付多少钱”。
我见过太多人在购物节熬夜凑单,凑到最后发现实际付款金额和自己预估的差了几十块。这不是算术不好,而是规则本身复杂:满减是按商品原价计算还是折扣后价格计算?优惠券能不能和满减叠加?不同商品的折扣率不一样时,总价怎么算?
购物折扣计算器的第一个核心需求就是解决这个问题:让用户录入商品、折扣和满减规则,应用自动算出最终应付金额、总节省金额和节省比例,把“算不明白”变成一眼就能看懂的结果。
第二个需求是记录需求。购物决策往往不是一次算完的,你可能上午看中一件衣服,晚上又看到另一家店在做活动。把每次计算结果保存下来,方便对比不同方案的优劣,是真实使用中非常高频的场景。
第三个需求是校验需求。用户可能会输入负数、超出合理范围的价格、超过100%的折扣率,这些异常输入如果不在应用层拦截,轻则显示错误结果,重则直接崩溃。
基于这三点,项目的功能范围就清晰了:多商品价格录入、折扣率调节、满减规则选择、实时计算结果展示、历史记录持久化、输入校验。
1.2 用HarmonyOS技术栈逐一映射
需求明确之后,接下来的问题就是怎么用HarmonyOS的技术能力实现。这里面有几个关键决策我想单独说一下,因为它们直接决定了项目的代码结构。
第一,用 @Component 自定义商品行组件,而不是把所有UI堆在主页面里。商品列表可能有多行,每一行都有自己的价格输入框和删除按钮。如果不用子组件,每一行的状态都要由主页面的 @State 统一管理,ForEach 渲染和事件回调会变得非常绕。拆成 GoodsItemComponent 之后,每一行自己管理自己的输入状态,只把“价格变化”这个结果通过回调抛给父组件,代码清晰很多。
第二,用 Slider 来做折扣率调节,而不是让用户手动输入“0.8”这样的数字。滑块的交互更直观,用户拖一下就知道8折、7.5折对应的金额差异,而且滑块天然限制了取值范围,不会出现输入120%折扣这种非法数据。Slider 的值变化是连续的浮点数,这里要处理显示格式的问题,稍后细说。
第三,历史记录用 PersistentStorage.persistProp 做持久化。HarmonyOS 提供了好几种本地存储方案,PersistentStorage 的优点是能和 @StorageLink 结合,数据变化会自动同步到UI。对于历史记录这种结构简单的数据,它比手动读写 Preferences 更省事。
这些决策都不是拍脑袋定的,而是在写代码之前就想好了“数据往哪走、状态归谁管、UI怎么刷新”这三条线。后面所有代码都是围绕这个框架展开的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 先抽象规则再动手写页面:折扣计算的数据模型设计
很多新手写应用有个习惯:上来就拖控件、写UI,界面弄到一半才开始想数据怎么存。我自己也走过这条路,结果就是UI和逻辑耦合在一起,改一个需求要动半页代码。
这个项目我换了个顺序:先把折扣规则抽象清楚,再设计数据结构,最后才写页面。
购物场景里最基础的优惠规则有三种。理解这三种规则,是设计数据模型的前提。
第一种是直接折扣,也就是“打几折”。这个规则最简单,应付金额等于原价乘以折扣率。93折、85折、7折,本质都是这个模型。关键在于折扣率是一个0到1之间的小数,UI上如何展示、用户如何输入,都需要设计。
第二种是满减,也就是“满多少减多少”。典型场景是“满300减50”。满减规则有两个关键参数:满减门槛金额和减免金额。这里还有一个容易被忽略的细节:满减是否跨店、是否叠加,以及是“每满300减50”还是“满300减50封顶”,这两种计算逻辑差异很大。
第三种是叠加。也就是折扣和满减同时存在时,先算哪个再算哪个。不同平台的计算顺序其实不一样,有的先打折再满减,有的先满减再打折。计算结果可能差出好几块钱,所以这个顺序必须让用户可感知、可配置,或者至少在界面上明确标注。
基于这三种规则,我设计了下面这套数据结构:
typescript复制// 满减规则接口
interface FullReductionRule {
threshold: number; // 满足的金额门槛,如 300
reduction: number; // 减免金额,如 50
isCumulative: boolean; // true表示每满threshold减reduction,false表示只减一次
}
// 单个商品条目
interface GoodsItem {
id: number; // 唯一标识,删除时使用
name: string; // 商品名称,默认商品1、商品2
price: number; // 原价(元)
discount: number; // 该商品的折扣率,0.8表示8折
}
// 计算结果
interface CalcResult {
totalOriginal: number; // 所有商品原价合计
totalPay: number; // 最终应付金额
totalSave: number; // 总共节省金额
savePercent: number; // 节省比例
detailItems: string[]; // 每件商品的明细说明
}
2.1 满减规则里容易被忽略的“每满”和“封顶”差异
这里是我想重点展开的一个业务细节,因为它在实际编码中非常容易出错。
“每满300减50”和“满300减50”是两种完全不同的规则。前者的意思是,只要金额达到300就减50,达到600就减100,理论上可以无限累加;后者的意思是,不管金额到了300还是800,都只减一次50。
网上很多计算器的实现只做了封顶版本,一旦遇到“每满”的规则就算错了。其实代码层面两种规则只差一个取整逻辑:
typescript复制function applyFullReduction(originalAmount: number, rule: FullReductionRule): number {
// 金额单位是元,但为了精度计算时转成分处理
let amountCents = Math.round(originalAmount * 100);
let thresholdCents = Math.round(rule.threshold * 100);
let reductionCents = Math.round(rule.reduction * 100);
let reduceTotal = 0;
if (rule.isCumulative) {
// 每满 threshold 减 reduction,向下取整,封顶为金额本身(不能减成负数)
let times = Math.floor(amountCents / thresholdCents);
reduceTotal = Math.min(times * reductionCents, amountCents);
} else {
// 只判断是否达到门槛,达到就减一次
if (amountCents >= thresholdCents) {
reduceTotal = reductionCents;
}
}
return (amountCents - reduceTotal) / 100;
}
这段代码里有两个容易写错的地方。第一是 isCumulative 分支里 Math.min 的使用——如果订单金额是305,按“每满300减50”应该减50,不可能减两次,但极端情况下如果门槛是30、减免是50,减一次就可能超过金额本身,必须用 Math.min 兜底。第二是金额转换成分再计算,这一步是为了规避浮点精度问题,后面第4章详细说。
2.2 把计算逻辑封装成纯函数,和UI彻底解耦
数据模型设计好之后,计算逻辑我单独放了一个工具文件 DiscountCalculator.ts,所有页面代码只负责调用,不直接写计算过程。
typescript复制// DiscountCalculator.ts
import { GoodsItem, FullReductionRule, CalcResult } from './Model';
export function calcTotal(
items: GoodsItem[],
fullReductionRule: FullReductionRule | null
): CalcResult {
let totalOriginalCents = 0;
let detailItems: string[] = [];
// 第一步:先按单个商品的折扣价累加
items.forEach(item => {
let itemOriginalCents = Math.round(item.price * 100);
let itemDiscountCents = Math.round(itemOriginalCents * item.discount);
totalOriginalCents += itemDiscountCents;
detailItems.push(`${item.name}: 原价${formatMoney(item.price)} -> 折后${formatMoney(itemDiscountCents / 100)}`);
});
// 第二步:满减规则整体计算
let payAmount = totalOriginalCents / 100;
if (fullReductionRule) {
payAmount = applyFullReduction(payAmount, fullReductionRule);
}
let totalPayCents = Math.round(payAmount * 100);
let totalSaveCents = totalOriginalCents - totalPayCents;
return {
totalOriginal: totalOriginalCents / 100,
totalPay: totalPayCents / 100,
totalSave: totalSaveCents / 100,
savePercent: totalOriginalCents > 0 ? Math.round(totalSaveCents * 100 / totalOriginalCents) : 0,
detailItems: detailItems
};
}
function formatMoney(value: number): string {
return value.toFixed(2);
}
这里我刻意把计算顺序定成了“先单品折扣,再整体满减”,并且用注释标明。为什么这样定?因为在实际购物场景中,平台计算到手价的顺序通常确实是先计算每个商品自身的优惠,再进行满减级优惠的叠加。而且很多优惠券的适用范围本身就是“折后金额满X可用”,可见“先折后满”是主流。如果你的需求里需要“先满减再打折”,只需要调换一下函数顺序,纯函数的好处就是随时可以改、改了不影响UI。
这样一来,计算逻辑没有依赖任何页面的状态,我可以单独写单元测试验证各种边界输入,页面接到数据直接渲染,互不干扰。这个小细节,在项目功能变复杂之后会带来非常明显的好处。
3. 主界面实现:把状态、组件和交互串起来的完整过程
数据模型和计算函数准备就绪之后,才开始写页面。这一章按照界面的三个主要区域展开:商品列表区、折扣与满减设置区、结果汇总区。
整个页面的结构是这样的:最外层是一个 Column,顶部是标题栏,中间用 Scroll 包裹内容区避免键盘弹起时页面被压缩得没法看,内容区自上而下依次是商品列表、优惠设置卡、结果汇总卡。商品列表用 List 可以支持滑动,但实际项目里商品数量通常不会太多,我用的是 ForEach 渲染,对应到 Column 里逐行显示。
typescript复制@Entry
@Component
struct ShoppingCalculator {
@State goodsList: GoodsItem[] = [
{ id: 1, name: '商品1', price: 299, discount: 1 },
{ id: 2, name: '商品2', price: 129, discount: 0.8 }
];
@State selectedRuleIndex: number = 0; // 选中的满减规则下标
@State isCumulative: boolean = true; // 是否“每满”
@State totalResult: CalcResult = {
totalOriginal: 0, totalPay: 0, totalSave: 0, savePercent: 0, detailItems: []
};
private fullReductionRules: FullReductionRule[] = [
{ threshold: 300, reduction: 50, isCumulative: true },
{ threshold: 500, reduction: 80, isCumulative: true }
];
build() {
Column({ space: 12 }) {
// 标题区
Row() {
Text('购物折扣计算器')
.fontSize(22)
.fontWeight(FontWeight.Bold)
}
.width('100%')
.padding(16)
Scroll() {
Column({ space: 16 }) {
// 商品列表区
This.buildGoodsListArea()
// 优惠设置区
This.buildDiscountSettingArea()
// 结果汇总区
This.buildResultArea()
}
.padding({ left: 16, right: 16, bottom: 24 })
}
.layoutWeight(1)
.align(Alignment.Top)
}
.width('100%')
.height('100%')
.backgroundColor('#F5F6F8')
}
}
3.1 商品行组件的拆法与数据传递方式
商品列表里的每一行,我单独定义了一个子组件 GoodsItemComponent。父组件通过 @Prop 给它传商品数据,子组件通过回调函数把“价格修改”事件抛给父组件。
typescript复制@Component
struct GoodsItemComponent {
@Prop item: GoodsItem;
onChangePrice: (id: number, newPrice: string) => void = () => {};
onDelete: (id: number) => void = () => {};
build() {
Row({ space: 8 }) {
Text(this.item.name)
.fontSize(16)
.width(60)
.textAlign(TextAlign.Center)
TextInput({ placeholder: '请输入价格', text: this.item.price.toString() })
.type(InputType.NUMBER_DECIMAL)
.layoutWeight(1)
.onChange((value: string) => {
this.onChangePrice(this.item.id, value);
})
Text('元')
.fontSize(14)
.fontColor('#999999')
Button('删除', { type: ButtonType.Circle })
.fontSize(14)
.backgroundColor('#E84026')
.width(28)
.height(28)
.onClick(() => {
this.onDelete(this.item.id);
})
}
.width('100%')
.padding(12)
.backgroundColor(Color.White)
.borderRadius(12)
}
}
这里有个设计细节值得说说:TextInput 的 text 参数我绑定了 this.item.price.toString(),而 onChange 事件里并没有直接修改 item.price,只是把字符串抛给了父组件。为什么不在子组件里直接改 this.item.price = parseFloat(value)?
因为 @Prop 修饰的数据虽然可以在子组件内部赋值,但它的修改不会反向同步到父组件。如果我在这里改,父组件拿到的是一个旧价格,最后计算结果就是错的。正确做法是:子组件只负责把用户输入的新值抛出去,父组件收到后更新 goodsList,再通过 @Prop 传回来触发子组件刷新。
理解了这一点,HarmonyOS 父子组件数据流的核心你就掌握了:数据向上流动(通过回调),数据向下传递(通过 @State -> @Prop)。
3.2 折扣率滑块:连续值和离散显示的差值处理
折扣设置区我用了一个 Slider 组件,让用户拖动调节“全场折扣”。设置范围为 0.5 到 1.0,步进 0.05,也就是从5折到原价,每5折一个档位。
typescript复制Row({ space: 12 }) {
Text('折扣')
.fontSize(16)
.width(80)
Slider({ value: this.globalDiscount * 100, min: 50, max: 100, step: 5, style: SliderStyle.OutSet })
.layoutWeight(1)
.onChange((value: number) => {
// 这里把滑块的值从“50~100”映射回“0.5~1.0”
this.globalDiscount = value / 100;
})
Text(`${Math.round(this.globalDiscount * 10)}折`)
.fontSize(16)
.fontColor('#E84026')
.width(50)
}
这里最容易踩坑的就是“折扣率的展示”。用户在界面上看到的价格单位是元,折扣展示用的是“8折”“7.5折”这样的中文习惯,而Slider内部的值是一个浮点数。如果不小心把百分比、折扣、原价这几个单位搞混,计算就会差一个数量级。
我的做法是统一约定:代码内部所有折扣变量一律使用0.8这种小数形式,只有在UI展示时才转成“8折”。Slider控件的 value、min、max 用0到100的整数形式(从视觉和交互角度更顺手),滑到值后立刻除以100转回小数存入 @State。这样约定之后,不管代码里出现多少个地方用到折扣,都不会产生单位歧义。
还有一个细节:Slider 的 onChange 在拖动过程中会高频触发。每次触发都重新调用计算函数完全没问题,因为计算逻辑是纯函数,开销很小。但如果你在回调里做了 console.info 打印,可能会刷屏。调试时注意不要被这个干扰。
3.3 满减规则选择与“每满/封顶”切换
满减规则我做了两组预设:满300减50、满500减80。每组规则旁边有一个“每满”的 Switch 开关,控制是“每满300减50”还是“满300减50封顶”。这个开关直接绑定到当前选中规则对象的 isCumulative 字段上。
typescript复制ForEach(this.fullReductionRules, (rule: FullReductionRule, index: number) => {
Column() {
Row({ space: 12 }) {
Radio({ value: `rule_${index}`, group: 'fullReductionGroup' })
.checked(index === this.selectedRuleIndex)
.onChange((checked: boolean) => {
if (checked) {
this.selectedRuleIndex = index;
}
})
Text(`满${rule.threshold}减${rule.reduction}`)
.fontSize(16)
Blank()
Text('每满')
.fontSize(14)
.fontColor('#666666')
Toggle({ type: ToggleType.Switch, isOn: rule.isCumulative })
.onChange((isOn: boolean) => {
this.fullReductionRules[index].isCumulative = isOn;
// 这里要注意:因为 fullReductionRules 不是 @State,需要重新赋值触发UI刷新
})
}
.width('100%')
}
.padding(12)
.backgroundColor(Color.White)
.borderRadius(12)
}, (rule: FullReductionRule) => rule.threshold.toString())
这里有一个很隐蔽的坑:fullReductionRules 我用的是普通成员变量而不是 @State。当用户切换“每满”开关时,isCumulative 确实被修改了,但UI不会自动刷新,结果区的计算结果也是旧的。
解决方式有两种:一是把 fullReductionRules 声明为 @State,二是每次修改后手动触发一次计算并赋值给 totalResult。我实际采用了后者,因为规则对象本身是预设的常量,没必要让UI去监听它的内部变化。正确做法是在切换时手动调用 this.recalc(),把更新后的规则传给计算函数,重新生成 totalResult。这个细节也是新手容易忽略的“为什么我的数据变了,UI不刷新”的典型场景,关键在于搞清楚 @State 只关心它直接修饰的变量,嵌套对象的内部字段变化需要重新赋值或手动触发。
3.4 结果汇总卡片:把计算函数接进UI
结果汇总区是用户最关注的部分,我用一个白色卡片展示四项核心数据:原价合计、满减后应付、总节省金额、节省比例。当任何一个上游参数发生变化时,都会触发 recalc() 更新。
typescript复制buildResultArea(): void {
Column({ space: 8 }) {
Row() {
Text('原价合计')
.fontSize(14)
.fontColor('#999999')
Blank()
Text(`¥${this.totalResult.totalOriginal.toFixed(2)}`)
.fontSize(14)
.fontColor('#333333')
}
.width('100%')
Row() {
Text('应付金额')
.fontSize(16)
.fontColor('#333333')
Blank()
Text(`¥${this.totalResult.totalPay.toFixed(2)}`)
.fontSize(24)
.fontWeight(FontWeight.Bold)
.fontColor('#E84026')
}
.width('100%')
Row() {
Text('已节省')
.fontSize(14)
.fontColor('#999999')
Blank()
Text(`¥${this.totalResult.totalSave.toFixed(2)}(${this.totalResult.savePercent}%)`)
.fontSize(14)
.fontColor('#22A06B')
}
.width('100%')
// 明细分隔线
Divider().color('#F0F0F0').margin({ top: 8, bottom: 8 })
ForEach(this.totalResult.detailItems, (detail: string) => {
Text(detail)
.fontSize(12)
.fontColor('#999999')
.width('100%')
}, (detail: string) => detail)
}
.width('100%')
.padding(16)
.backgroundColor(Color.White)
.borderRadius(12)
.margin({ top: 8 })
}
recalc() 的逻辑很简单,就是从当前状态里取所有参数,调用工具函数,把结果赋给 totalResult:
typescript复制recalc(): void {
this.totalResult = calcTotal(
this.goodsList,
this.fullReductionRules[this.selectedRuleIndex]
);
}
到这里,主界面的三大区域就全部打通了。回顾一下数据流:用户在商品行输入价格,通过回调更新 goodsList,触发 recalc,totalResult 更新,UI自动刷新。滑块动一下,同样进入这条链路。整条链路非常清晰,这也是声明式UI最舒服的编程体验——你只需要关心数据,不需要手动操作DOM节点的增删改。
4. 精度和边界问题:折扣计算里最容易翻车的三个细节
如果说页面搭建是“求快”,那精度和边界处理就是“求生”。我在这个项目上真正花时间调优的,不是UI布局,而是下面这三个细节。
4.1 浮点精度是折扣计算最大的坑
JavaScript 和 TypeScript 的浮点运算有个著名的瑕疵:0.1 + 0.2 的结果是 0.30000000000000004。在折扣计算里,这个误差会被放大。
举个例子:商品价格 19.99 元,打7折,用浮点直接算:
typescript复制19.99 * 0.7 // 结果是 13.993
如果你直接把这个数显示到界面上,用户看到的将是 13.993 元,非常不专业。更麻烦的是后续的金额相加,每个商品都带一点误差,多件商品累计之后,最终金额可能差出一毛钱。
解决方式业界早有标准:所有金额计算用“分”作为单位,用整数运算。价格从输入框拿到后立刻转成分,计算完毕再转回元展示。
typescript复制function yuanToCents(value: number): number {
return Math.round(value * 100);
}
function centsToYuan(cents: number): number {
return cents / 100;
}
转成分之后,所有计算都是整数加减乘除,彻底规避浮点误差。在后面计算 calcTotal 的代码里,我全程使用 xxxCents 变量,只有最后返回结果时才转回元,就是这个原因。
4.2 金额取整策略:向上取整、向下取整还是四舍五入?
折扣计算的另一个隐藏逻辑是“角分怎么处理”。电商平台通常不会把价格精确到小数点后三位,所以计算结果需要做保留2位小数的取整。
但取整策略是有讲究的。大多数平台采用“四舍五入”,但也有平台对满减金额采用“向下取整”,对用户实付金额采用“向上取整”。这两种策略对平台和用户的影响完全不同。我实现时默认使用 Math.round 四舍五入到分,并在代码注释里标明策略,方便后续按需调整:
typescript复制function roundToCent(value: number): number {
return Math.round(value * 100) / 100;
}
这里还要提一个 toFixed 的坑:(0.005).toFixed(2) 在某些环境下结果是 0.00,而不是 0.01,因为浮点数的二进制表示导致 0.005 实际存储为 0.004999999...。所以不要依赖 toFixed 做四舍五入后再参与计算,它只适合最后的展示格式化。计算过程中一律用 Math.round(value * 100) 处理。
4.3 输入校验:负数、空值和超大金额
第三个容易翻车的是非法输入。TextInput 虽然设置了 InputType.NUMBER_DECIMAL,但用户仍然可能输入空字符串、单个小数点、或者是 999999999 这种远远超过商品实际价格的数字。
我在父组件的 onChangePrice 回调里做了集中校验:
typescript复制onChangePrice(id: number, newPrice: string): void {
// 过滤空字符串和非法字符
if (newPrice === '' || newPrice === '.') {
// 输入过程中允许暂时是空串,等失焦后再兜底为0
this.goodsList = this.goodsList.map(item =>
item.id === id ? { ...item, price: 0 } : item
);
return;
}
let priceNum = Number(newPrice);
if (isNaN(priceNum) || priceNum < 0) {
promptAction.showToast({ message: '请输入合法的商品价格' });
return;
}
if (priceNum > 999999) {
promptAction.showToast({ message: '价格超出合理范围' });
return;
}
this.goodsList = this.goodsList.map(item =>
item.id === id ? { ...item, price: priceNum } : item
);
}
这个设计的核心思路是:用户输入过程中,不要因为他还没输完就弹错误提示;只有输入的值最终无效时才提醒。比如用户输入一个“.”,可能下一个字符马上就要输“5”了,此时直接报“非法输入”会非常打扰。所以空串和“.”我直接默默按0处理,等完整输入数值后再走正常校验。
还有很多同学会在输入框上做“失去焦点时校验”,但移动端体验里,焦点通常在点击计算按钮时才自然移开。你如果既要实时计算结果,又不想频繁弹提示,就得采用“输入中放宽、结果区兜底”的策略。这个小经验,在开发任何表单类应用时都适用。
5. 让计算器记住用户的优惠习惯:历史记录实现
基础计算流程跑通之后,应用已经具备日常使用的价值了。但真正让我觉得它“像个产品”的,是历史记录功能的加入。
试想一个真实场景:购物节期间,你在三个平台各看中一套商品组合。每套组合的折扣力度和满减规则都不相同,你需要先分别算一遍才能决定买哪个。如果没有历史记录,你只能拿张纸抄下来对比,或者往返切换页面反复输入。有了历史记录,每次计算结果自动存档,随时可以回头查看。
5.1 PersistentStorage 和 @StorageLink 的搭配使用
HarmonyOS 提供的 PersistentStorage 可以很方便地把应用级数据持久化到本地。搭配 @StorageLink 装饰器使用时,数据变更会自动写入存储,同时UI自动刷新,和 @State 的体验几乎一致。
用法如下:
typescript复制@Entry
@Component
struct ShoppingCalculator {
// 关联本地存储中的 'historyList' 字段
@StorageLink('historyList') historyList: HistoryItem[] = [];
aboutToAppear(): void {
// 在页面加载时,确保持久化存储里已经有默认值
PersistentStorage.persistProp('historyList', []);
this.recalc();
}
}
这里有个顺序问题需要留意:@StorageLink 的初始化依赖 PersistentStorage.persistProp 已经被调用过。所以我在 aboutToAppear 里先执行 persistProp,再访问 historyList。如果你在组件初始化时就操作 historyList,可能拿到的是默认空数组而不是存储里的真实数据。这个时序问题在文档里写得比较隐晦,实际运行时才容易发现。
HistoryItem 的结构很简单:
typescript复制interface HistoryItem {
id: number;
time: string; // 记录生成时间
originalAmount: number; // 原价合计
payAmount: number; // 应付金额
saveAmount: number; // 节省金额
ruleDesc: string; // 规则描述,如“满300减50(每满) 8折”
}
每次点击结果汇总卡片上的“保存本次结果”按钮时,就组装一个新的 HistoryItem,unshift 到 historyList 最前面。因为 @StorageLink 的自动持久化,这个操作不需要任何额外的文件读写代码。
5.2 历史记录列表的渲染与清空策略
历史记录我放在主页面的最底部,用一个折叠的卡片展示。用户点击“历史记录”标题栏时展开列表,再次点击收起。展开以后用 ForEach 渲染每一条记录。
typescript复制Column({ space: 8 }) {
Row() {
Text('历史记录')
.fontSize(16)
.fontWeight(FontWeight.Bold)
Blank()
if (this.historyList.length > 0) {
Text('清空')
.fontSize(14)
.fontColor('#E84026')
.onClick(() => {
this.historyList = [];
})
}
}
.width('100%')
.padding(12)
if (this.showHistory) {
ForEach(this.historyList, (item: HistoryItem) => {
Row({ space: 8 }) {
Column({ space: 4 }) {
Text(`${item.ruleDesc}`)
.fontSize(14)
.fontColor('#333333')
Text(item.time)
.fontSize(12)
.fontColor('#999999')
}
.align(Alignment.Start)
.layoutWeight(1)
Column({ space: 4 }) {
Text(`¥${item.payAmount.toFixed(2)}`)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#E84026')
Text(`原价¥${item.originalAmount.toFixed(2)},省${item.saveAmount.toFixed(2)}`)
.fontSize(12)
.fontColor('#22A06B')
}
.align(Alignment.End)
}
.width('100%')
.padding(10)
.backgroundColor('#FAFAFA')
.borderRadius(8)
}, (item: HistoryItem) => item.id.toString())
}
}
.width('100%')
.padding(12)
.backgroundColor(Color.White)
.borderRadius(12)
.margin({ top: 8 })
历史记录的限制策略我也考虑过。如果不加控制,list 会无限增长,最终拖慢启动速度。目前我的做法是:每次保存新记录时,如果 historyList.length > 50,就截断到前50条:
typescript复制saveHistory(): void {
let ruleDesc = `满${this.fullReductionRules[this.selectedRuleIndex].threshold}减${this.fullReductionRules[this.selectedRuleIndex].reduction}(${this.fullReductionRules[this.selectedRuleIndex].isCumulative ? '每满' : '封顶'})`;
let now = new Date();
let timeStr = `${now.getMonth() + 1}月${now.getDate()}日 ${now.getHours()}:${now.getMinutes().toString().padStart(2, '0')}`;
let item: HistoryItem = {
id: Date.now(),
time: timeStr,
originalAmount: this.totalResult.totalOriginal,
payAmount: this.totalResult.totalPay,
saveAmount: this.totalResult.totalSave,
ruleDesc: ruleDesc + ` ${Math.round(this.globalDiscount * 10)}折`
};
this.historyList = [item, ...this.historyList];
if (this.historyList.length > 50) {
this.historyList = this.historyList.slice(0, 50);
}
promptAction.showToast({ message: '结果已保存' });
}
看到这里你会发现,历史记录功能几乎没有新增任何复杂的逻辑,它复用了一切已有的数据结构。这就是前期把数据模型设计清楚的好处——新增功能只是加一个存储入口,而不是推翻重来。
6. 实测中踩过的坑与后续演进思路
最后聊聊实测过程中遇到的一些实际问题。这些问题在写代码时完全预料不到,但踩过一次之后,你会对HarmonyOS的开发机制有更深的体感。
6.1 新手最容易踩的三个坑
第一个坑是 TextInput 的回车键类型。默认情况下,软键盘的右下角是“完成”按钮,用户点击后键盘收起,但并不会触发任何提交事件。我一开始没处理这个,导致用户输入价格后还要手动点一下其他地方才能看到结果刷新。后来在 TextInput 上加了 enterKeyType(EnterKeyType.Done) 和 onSubmit 事件,在提交时主动收起键盘并触发一次 recalc,体验好了很多。
第二个坑是 @Prop 和对象引用。@Prop 装饰器对复杂类型(比如对象)的支持是“深拷贝”还是“浅拷贝”容易让人迷惑。实测中,我给子组件传了一个对象作为 @Prop,然后在父组件里通过 this.goodsList[0].price = xxx 直接改字段,发现子组件的UI不会更新。原因是 @Prop 是单向数据流,父组件需要创建新对象重新赋值,子组件才能感知变化。这就是为什么我在 onChangePrice 里统一用 map 返回新数组,而不是直接修改下标元素。
第三个坑是键盘遮挡结果区。商品列表在页面顶部,结果汇总在底部。当用户输入价格时,软键盘弹出会遮挡底部结果区,用户看不到实时计算结果。我的解决方式是给 Scroll 设置了 scrollBar 和键盘避让,同时在输入框聚焦时调用 scrollTo 将结果区滚动到可见区域。HarmonyOS 的 Scroll 组件有内置的键盘避让能力,但需要你把页面布局放在 Scroll 内部而不是根 Column 里,这一点在开发时就应当规划好。
6.2 后续演进:从计算器到“购物决策助手”
项目做完之后,我并没有就此打住,而是在琢磨它的扩展空间。这里分享几个我觉得有价值的演进方向。
第一个方向是接入相机能力,OCR识别购物小票,自动填充商品和价格。HarmonyOS 提供了文本识别服务,识别出文字后正则提取金额和商品名,能省去用户手动输入的麻烦。
第二个方向是接入AGC的云函数,把折扣规则库放到云端。因为满减规则、优惠券规则是动态变化的,写在本地代码里的预设规则无法覆盖所有场景。如果后端维护一个规则配置表,客户端拉取后动态渲染,应用的生命周期会长很多。
第三个方向是把历史记录升级为购物方案对比。当前的历史记录只是按时间排列,彼此独立。如果把多次保存的方案放在同一个维度下对表,直接给出“买A组合比买B组合省38元”的结论,对用户的决策帮助会更大。
这些方向我在开发时都简单验证过,技术上没有不可逾越的障碍。而且有意思的是,每确定一个方向,都会发现当前的代码结构变成了一个可扩展的地基:数据模型是纯函数可测试的,UI和逻辑是解耦的,存储层是独立模块。这就是当初多花心思做设计所换来的回报。
我把这个计算器用了一个购物季,最大的感受是:应用虽小,但它完整经历了一个真实工具从需求分析、数据建模、UI实现到边界处理的全流程。这种“小而完整”的项目,比照着教程敲十个Demo都更能锻炼工程直觉。如果你也正在找HarmonyOS的练手项目,建议从购物折扣计算器开始,把代码写完、跑通、用起来,你学到的比看十篇教程都多。
