鸿蒙全球性项目实战 -跨过商品账单综合格式化-技术实现


一、业务需求(为什么这么设计)
产品是一个面向全球的跨境商城 demo,核心需求是把前面 12 个应用的本地化能力整合进一个真实结算流程:
- 界面语言 5 种(中/繁/英/日/韩),结算币种 4 种(CNY/USD/EUR/JPY),两个维度自由组合;
- 所有金额存人民币整数分,展示时按所选币种汇率换算并本地化格式化;
- 税费 8.5%、满 ¥300 免运费(否则 ¥12)、满 ¥200 减 ¥50——规则在 CNY 分空间计算;
- 支付流程:待支付 → 处理中(1.5s)→ 已支付(含订单号、下单时间、状态);
- 数量用复数规则(英文 “1 item”/“3 items”),订单号带千分位,时间随语言格式化。
本文记录完整技术方案,所有代码与 BillFormatPage.ets 一一对应。
二、总体架构
┌─ 语言层:LANGS + STRINGS(5 语言文案表)+ t() 三级降级
├─ 币种层:CURRENCIES(ISO 4217 码 + 符号 + 汇率 + 小数位)+ fmtMoney() 货币格式化
├─ 商品层:PRODUCTS(5 语言名称/描述 + CNY 整数分价格)
├─ 计算层:整数分运算(小计/税费/运费/满减/合计)
├─ 格式化层:fmtQty() 复数 + fmtOrderNo() 千分位 + fmtTime() 日期时间
└─ 状态层:@StorageLink locale + currency(双持久化)+ @State tab/qty/paying/paidAt
架构核心是两条管线:金额管线(CNY 分 → 汇率换算 → 货币格式化)与文案管线(locale → 文案表 → 界面文本)。两条管线在 build 时汇合,任何一维变化都触发全页重渲染。
三、币种层:ISO 4217 元数据
interface CurrencyCfg {
code: string; // ISO 4217
symbol: string; // 展示符号
rate: number; // 1 CNY = ?
digits: number; // 小数位
}
const CURRENCIES: CurrencyCfg[] = [
{ code: 'CNY', symbol: '¥', rate: 1, digits: 2 },
{ code: 'USD', symbol: '$', rate: 0.14, digits: 2 },
{ code: 'EUR', symbol: '€', rate: 0.13, digits: 2 },
{ code: 'JPY', symbol: '¥', rate: 21.5, digits: 0 }
];
三个关键设计:
code用 ISO 4217(CNY/USD/EUR/JPY):Intl.NumberFormat style:'currency'需要标准货币码,自造符号会被格式化器拒绝;digits独立配置:日元无小数位(¥27,928)、人民币 2 位(¥1,299.00)——虽然style:'currency'会按 CLDR 自动决定小数位,但digits字段用于兜底分支(toFixed(digits)),保证降级路径同样正确;rate是演示固定值:真实产品汇率来自服务端,且必须带时间戳与版本(见"生产铁律")。
四、金额管线:整数分 → 汇率 → 货币格式化
4.1 存储与运算:全部整数分
interface Product {
priceCents: number; // CNY 整数分
}
// 例:耳机 129900 分 = ¥1,299.00
为什么用整数分:浮点数 0.1 + 0.2 !== 0.3,金额用 number 直接运算会产生 1299.0000000000002 之类的尾差。金额以整数分存储与运算(129900),加减乘除全是整数,零误差——这正是页面底部 tip 文案(K.tip)强调的内容。
4.2 换算:toDisplay()
private toDisplay(cnyCents: number): number {
const rate = this.curCfg().rate;
if (rate === 1) {
return cnyCents / 100; // CNY:分 → 元
}
return Math.round(cnyCents * rate) / 100; // 其他币种:先按汇率换算再保留两位
}
Math.round(cnyCents * rate) / 100:先四舍五入到分再除以 100 转元——129900 × 0.14 = 18186分 =$181.86;- 汇率 1(CNY)走快路径避免无谓运算;
- 注意:换算在"分"空间做四舍五入,保证显示精确到目标币种的分。
4.3 格式化:fmtMoney()
private fmtMoney(cnyCents: number): string {
try {
const fmt = new intl.NumberFormat(this.currentLocale, {
style: 'currency',
currency: this.curCfg().code
});
return fmt.format(this.toDisplay(cnyCents));
} catch (err) {
return `${this.curCfg().symbol}${this.toDisplay(cnyCents).toFixed(this.curCfg().digits)}`;
}
}
style:'currency' 的 CLDR 数据驱动能力一览:
| locale | CNY 1299.00 | USD 181.86 | JPY 27928 |
|---|---|---|---|
| zh_CN | ¥1,299.00 | US$181.86 | JP¥27,928 |
| en_US | CN¥1,299.00 | $181.86 | ¥27,928 |
| ja_JP | ¥1,299.00 | $181.86 | ¥27,928 |
- 符号随语言变:中文界面下美元显示
US$(消歧义)、英文界面人民币显示CN¥——CLDR 的"货币符号消歧"规则自动处理; - 千分位/小数点随语言:
1,299.00(英文逗号)vs 德语1.299,00(反); - try/catch 兜底:格式化失败时用
symbol + toFixed(digits)手工拼接——digits字段在此刻派上用场。
五、计算层:规则全部在 CNY 分空间
const TAX_RATE_PERCENT: number = 8.5;
const FREE_SHIPPING_THRESHOLD: number = 30000; // 满 ¥300(分)
const SHIPPING_CENTS: number = 1200; // 未满运费 ¥12
const DISCOUNT_THRESHOLD: number = 20000; // 满 ¥200(分)
const DISCOUNT_CENTS: number = 5000; // 减 ¥50
private taxCents(): number {
return Math.round(this.goodsTotalCents() * TAX_RATE_PERCENT / 100);
}
private shippingCents(): number {
if (this.goodsTotalCents() >= FREE_SHIPPING_THRESHOLD) return 0;
return SHIPPING_CENTS;
}
private discountCents(): number {
return this.goodsTotalCents() >= DISCOUNT_THRESHOLD ? DISCOUNT_CENTS : 0;
}
private totalCents(): number {
return this.goodsTotalCents() + this.taxCents() + this.shippingCents() - this.discountCents();
}
三个要点:
- 规则参数化:门槛/运费/满减全部提为模块级常量,改规则不动逻辑;
Math.round处理 8.5% 税率:129900 × 8.5 / 100 = 11041.5→ 四舍五入11042分——税费必须整数分,舍入在计算层做而非显示层;- 计算与显示彻底分离:
taxCents()返回分,只有fmtMoney()负责呈现——规则改、显示改互不影响。
六、格式化三件套:复数/订单号/时间
6.1 数量复数 fmtQty()
private fmtQty(n: number): string {
try {
const pr = new intl.PluralRules(this.currentLocale);
const cat = pr.select(n);
if (this.currentLocale === 'en_US') {
return cat === 'one' ? `1 ${this.T(K.item)}` : `${n} ${this.T(K.item)}s`;
}
return `${n}`; // 中/日/韩无复数形态
} catch (err) {
return `${n}`;
}
}
PluralRules.select(1)→'one'、select(3)→'other';- 英文
1 item/3 items(item+s伪复数);中/日/韩直接数字(语言层无复数概念); - 与 05 应用(复数规则专项)呼应,这里是它的轻量应用。
6.2 订单号 fmtOrderNo()
private fmtOrderNo(): string {
try {
return new intl.NumberFormat(this.currentLocale).format(ORDER_NO);
} catch (err) {
return `${ORDER_NO}`;
}
}
2026081912345678 → 英文 20,260,819,123,456,78(千分位分组)——订单号这种"数字型标识"也走 NumberFormat,保证分组习惯随语言。
6.3 时间 fmtTime()
private fmtTime(ts: number): string {
try {
const fmt = new intl.DateTimeFormat(this.currentLocale, {
dateStyle: 'long', timeStyle: 'short'
});
return fmt.format(new Date(ts));
} catch (err) {
return new Date(ts).toString();
}
}
中文"2026年8月19日 14:30"、英文 “August 19, 2026 at 2:30 PM”——dateStyle:'long' + timeStyle:'short' 的组合模板由语言决定,与 02 应用一致。
七、状态管理与双持久化
@StorageLink(STORAGE_LOCALE) currentLocale: string = DEFAULT_LOCALE;
@StorageLink(STORAGE_CURRENCY) currentCurrency: string = DEFAULT_CURRENCY;
@State tab: string = 'market';
@State qty: number[] = [0, 0, 0, 0, 0, 0];
@State paying: boolean = false;
@State paidAt: number = -1;
private timer: number = -1;
aboutToAppear(): void {
// locale 与 currency 各做一次 setOrCreate + persistProp
if (!AppStorage.get<string>(STORAGE_LOCALE)) {
AppStorage.setOrCreate(STORAGE_LOCALE, DEFAULT_LOCALE);
}
PersistentStorage.persistProp(STORAGE_LOCALE, DEFAULT_LOCALE);
if (!AppStorage.get<string>(STORAGE_CURRENCY)) {
AppStorage.setOrCreate(STORAGE_CURRENCY, DEFAULT_CURRENCY);
}
PersistentStorage.persistProp(STORAGE_CURRENCY, DEFAULT_CURRENCY);
}
- 两个
@StorageLink独立持久化:i18n_series_13_locale与i18n_series_13_currency两个 key——重启后"中文界面 + USD 结算"的组合被完整记忆; qty用slice()拷贝再赋值(this.qty = arr):ArkUI 的@State数组必须整体替换才能触发渲染,原地this.qty[i]++不生效——这是 ArkTS 状态管理的经典陷阱;- 支付状态机:
paying(处理中)→paidAt(时间戳,-1 表示未支付)——adjust()/clearCart()修改购物车后把paidAt复位,保证"改单后回到待支付"的语义正确; timer在aboutToDisappear清理:setTimeout1.5s 支付模拟,页面销毁时必须clearTimeout。
八、数据流复盘(一次完整交互)
启动 → 双 persistProp(语言 + 币种)→ 中文界面 + CNY
→ 市场页 6 商品:¥1,299.00 / ¥459.00 / ¥899.00 / ¥2,199.00 / ¥299.00 / ¥159.00
用户点击 $ USD 币种徽章
→ currentCurrency = 'USD'(@StorageLink → AppStorage → 落盘)
→ curCfg() = USD(rate 0.14)
→ 全页 fmtMoney 重算:¥1,299.00 → $181.86
→ 汇率提示条变 "1 CNY ≈ $0.14"
用户加购 2 件耳机 → qty[0]=2
→ 购物车 Tab 角标 (2)
→ 小计 $363.72、税费 8.5% → $30.92、运费 满¥300 → 免运费
→ 合计 $394.64
用户点击立即支付 → paying=true(按钮变"支付处理中…")
→ 1.5s 后 paidAt=now → 横幅:🎉 支付成功!订单号 20,260,819,… 下单时间 2026年8月19日 14:30
九、ArkTS 兼容要点
catch (err)不带类型注解,所有intl调用包 try/catch;qty: number[]用slice()产生新数组再赋值,触发@State渲染;ForEach(PRODUCTS, (p: Product, i: number) => ...)key 用p.key(稳定唯一);buildStrings(pairs: Pair)中type Pair = Array<[string, string]>显式类型别名,new Map(pairs)免断言;PRODUCTS的names/descs是Map<string, string>,取用用??链降级(get(code) ?? get(DEFAULT_LOCALE) ?? key);Button(...).enabled(this.paidAt === -1 && !this.paying):条件禁用用enabled而非点击内拦截,语义更清晰。
十、性能与内存
- 每次 build 创建 fmtMoney × 20+ 次(6 商品单价 + 5 费用行 × 2 个视图分支),
NumberFormat实例化毫秒级;生产优化:模块级缓存code+currency → fmt,避免重复创建; qty数组仅 6 个元素,slice()拷贝开销可忽略;- 支付
setTimeout是唯一定时器,aboutToDisappear清理无泄漏; PRODUCTS的 5 语言 Map 在编译期构建,无运行时开销。
十一、小结
本应用是系列的"总集成测试":整数分存储(9 的 SI 基准思想)× 货币格式化(3 的深化)× 复数(5 的应用)× 日期时间(2 的应用)× 文案表(1 的骨架)× 双持久化(1 的扩展)。技术上最值得记的两点:金额永远整数分运算、展示永远走 style:'currency'——前者杜绝浮点误差,后者接管符号/分组/小数位的全部本地化。
| 应用 | 深化维度 |
|---|---|
| 01 | 文案表 + 三级降级 + 持久化 |
| 03 | NumberFormat 货币专项 |
| 05 | PluralRules 复数专项 |
| 13 | 货币+数字+日期+复数综合(生产级账单)← 本文 |
给生产环境的三条铁律:① 金额存整数分(或 decimal 字符串),禁止浮点运算;② 汇率必须带版本与生效时间,换算结果缓存并在汇率更新时失效;③ 货币显示用 ISO 4217 码走 style:'currency',兜底路径才用手拼符号——因为符号随语言消歧(US$ vs $ vs ¥),手拼必然出错。
更多推荐




所有评论(0)