鸿蒙项目实战 - 度量衡本地化 — 技术实现篇


一、业务需求(为什么这么设计)
产品要做一款面向全球的食谱工具,核心需求是:同一份食材清单,在不同国家用户面前自动变成他们习惯的单位体系。
- 中文用户看到「250 克 无盐黄油」,英文用户看到「8.8 oz unsalted butter」;
- 法文用户看到「250 grammes」(注意不是 grams),日文用户看到「250 グラム」;
- 美国用户看烤箱温度是「347°F」,欧洲用户看是「175°C」;
- 正式文档用「250 grams」,界面标签用「250 g」,图表刻度用「250g」。
这些诉求背后是两个完全独立的工程问题:单位怎么换算(业务逻辑,与语言无关)与换算结果怎么呈现(本地化,交给 Intl)。本应用把两者严格分层,本文记录完整技术方案,所有代码与 UnitConvertPage.ets 一一对应。
二、总体架构
┌─ 语言层:LANGS 元数据(code/name/flag/system)+ STRINGS 文案表 + t() 降级
├─ 数据层:INGREDIENTS 食材表(公制基准值 + 中英文名)+ TEMPS 温度档位
├─ 换算层:toImperial() 克→盎司、毫升→液量盎司;cToF() 摄氏→华氏(非线性特例)
├─ 格式化层:fmtUnit() 统一入口(NumberFormat style:'unit' + unitDisplay 三档)
└─ 状态层:@StorageLink currentLocale + @State displayIdx / tempIdx
分层原则:换算层只做纯数学,格式化层只做纯呈现。toImperial(250, 'gram') 返回 8.81849...,它不知道也不关心用户说什么语言;fmtUnit('fr_FR', 250, 'gram', 'short') 返回 250 g,它不关心这 250 是什么食材。中间没有任何一层把"克""磅"这样的字符串写死在业务代码里。
数据流(切换语言后发生了什么)
用户点击 🇺🇸 English 徽章
→ this.currentLocale = 'en_US'(@StorageLink 写回 AppStorage,PersistentStorage 落盘)
→ isImperial() 变 true(en_US 属于英制体系)
→ 每条食材:nameOf() 改用英文名,amountOf() 走 toImperial() 换算 + fmtUnit() 英文单位
→ 单位显示示例(250g/0.24L)按英文体系重算
→ 烤箱温度:摄氏 175 不变,华氏由 cToF(175) 计算并按英文格式化
→ build() 全树重渲染,一次点击、五处联动
三、语言层:体系元数据与文案表
3.1 语言元数据——把"体系"挂在语言上
这是本应用与系列其他应用最大的数据差异:LangCfg 多了一个 system 字段:
interface LangCfg {
code: string; // locale 代码
name: string; // 母语名
flag: string; // 国旗 emoji
system: string; // 'metric' | 'imperial' ← 度量衡体系
}
private isImperial(): boolean {
return this.curCfg().system === 'imperial';
}
private curCfg(): LangCfg {
return LANGS.find((l: LangCfg) => l.code === this.currentLocale) ?? LANGS[0];
}
设计决策:用 system 显式标注而非靠"US 结尾推断英制"——真实产品中英国部分地区混用、日本用公制但用坪(tsubo)计量面积,靠推断必然出错。显式字段 + 兜底 ?? LANGS[0](找不到 locale 时按默认语言处理),是防御式编码的典型写法。
3.2 文案表(5 语言)
function buildStrings(pairs: Array<[string, string]>): Map<string, string> {
return new Map(pairs as Array<[string, string]>);
}
const STRINGS: Map<string, Map<string, string>> = (() => {
const m = new Map<string, Map<string, string>>();
m.set('zh_CN', buildStrings([
[K.title, '国际食谱'], [K.sub, '一份食谱,全球通用'],
[K.recipe, '经典曲奇 · 食材清单'], [K.convert, '单位转换器'],
[K.oven, '烤箱温度换算'], [K.temp, '温度'], [K.weight, '重量'],
[K.volume, '容量'], [K.display, '单位显示方式'], [K.metric, '公制'], [K.imperial, '英制']
]));
m.set('zh_TW', buildStrings([/* 繁體中文:國際食譜、單位轉換器 … */]));
m.set('en_US', buildStrings([/* English: Global Recipes, Unit converter … */]));
m.set('ja_JP', buildStrings([/* 日本語:国際レシピ、単位変換 … */]));
m.set('fr_FR', buildStrings([/* Français: Recettes du monde, Convertisseur … */]));
return m;
})();
取用仍走本系列统一的三级降级:
function t(code: string, key: string): string {
const v = STRINGS.get(code)?.get(key);
if (v !== undefined) return v;
return STRINGS.get(DEFAULT_LOCALE)?.get(key) ?? key; // 目标 → 默认 → key
}
注意文案表里不包含任何单位词(“克”"磅"都不在 STRINGS 里)——单位词由 fmtUnit() 从 CLDR 数据生成,文案表只管界面通用词(标题/按钮/标签)。这是本应用与纯"文案翻译"应用的本质区别:单位名称不是翻译出来的,是格式化出来的。
四、格式化层:Intl.NumberFormat style:'unit'(核心 API)
4.1 核心用法
function fmtUnit(code: string, value: number, unit: string, display: string): string {
try {
const fmt = new intl.NumberFormat(code, {
style: 'unit',
unit: unit,
unitDisplay: display as 'short',
maximumFractionDigits: 1
});
return fmt.format(value);
} catch (err) {
return `${value.toFixed(1)} ${unit}`; // 兜底:格式化失败时回退纯数字 + 代码
}
}
一个函数输出全部 5 种语言 + 三档显示 + 任意单位,单位名称、复数形态、空格/连字符规则全部由 CLDR(Unicode Common Locale Data Repository)数据驱动:
| locale | 100 kg(long) | 8.8 oz(short) |
|---|---|---|
| en_US | 100 kilograms | 8.8 oz |
| zh_CN | 100千克 | 8.8盎司 |
| ja_JP | 100キログラム | 8.8オンス |
| fr_FR | 100 kilogrammes | 8.8 oz |
| zh_TW | 100公斤 | 8.8盎司 |
三个技术点:
- 复数形态自动处理:
kilograms/grammes的复数、中文/日文无复数形态,全部由 CLDR 规则决定,代码里一个if都不用写; - 空格规则随 locale:英文
8.8 oz用窄空格,法文8,8 oz数字的小数点都变了(8.8vs8,8),日文窄格式8.8oz无空格——排版细节全部正确; - 非法 unit 抛错:
unit传了 CLDR 不支持的代码(如拼错的'killogram')会抛异常,try/catch兜底为纯数字 + 单位代码,保证 UI 永不空白。
4.2 unitDisplay 三档
| 档位 | 示例(en, 0.24 L) | 设计意图 |
|---|---|---|
| long | 0.24 liters | 正式/朗读场景,单位词完整 |
| short | 0.24 L | UI 标签默认,平衡信息与空间 |
| narrow | 0.24L | 图表刻度/极窄空间,去掉所有空格 |
本应用把三档做成运行时切换(displayIdx),用户可实时对比——这也是验证 CLDR 数据完整性最直观的手段。
五、换算层:公制基准 + 两个特例
5.1 数据统一用公制基准
INGREDIENTS 表里所有数值都是公制基准值(克/毫升),英制显示时才换算——这是国际化存储的铁律:存储用 SI 基准单位,展示层换算。
// 换算:公制 → 英制
function toImperial(metric: number, unit: string): number {
if (unit === 'gram') {
return metric / 28.35; // g → oz
}
return metric / 29.5735; // ml → fl oz
}
如果反过来(存储英制、展示换算公制),每次新增地区都要改数据;而存公制后,即使未来加印度(英制)或缅甸(本地单位),数据零改动。
5.2 温度是唯一非线性特例
长度、重量、容量都是 目标值 = 基准值 × 系数 的线性关系,温度不行:
function cToF(c: number): number {
return c * 9 / 5 + 32;
}
175°C → 347°F 而非 175 × 1.8 = 315——差 32 度的偏移量必须单独处理。换算层为温度单开函数而不是硬塞进统一的 rate 表,保证了线性换算表可以保持"纯乘法"的简单性。同理,开尔文与摄氏(K = C + 273.15)也是偏移型,将来扩展同样单开函数。
六、状态联动与重渲染
@StorageLink(STORAGE_LOCALE) currentLocale: string = DEFAULT_LOCALE;
@State displayIdx: number = 1; // 0 long / 1 short / 2 narrow
@State tempIdx: number = 1; // 175°C 默认
三个状态各自独立、互不覆盖:语言切换只改 currentLocale,显示粒度只改 displayIdx,温度档只改 tempIdx。任一变化 → 相关格式化函数重算 → ArkUI 增量渲染。没有"换算结果"这个状态——结果永远是即时计算出来的,这正是声明式 UI 的优势:展示值不落状态、不存缓存,天然避免"数据与展示不同步"的经典 bug。
持久化细节
aboutToAppear(): void {
if (!AppStorage.get<string>(STORAGE_LOCALE)) {
AppStorage.setOrCreate(STORAGE_LOCALE, DEFAULT_LOCALE);
}
PersistentStorage.persistProp(STORAGE_LOCALE, DEFAULT_LOCALE);
}
先 setOrCreate 兜底、再 persistProp 落盘,与系列其他应用一致;displayIdx/tempIdx 不持久化(回到默认档位无伤大雅,且避免 Preferences 写入过于频繁)。
七、数据流复盘(一次完整交互)
启动 → aboutToAppear:AppStorage 初始化 + persistProp
→ build:按 zh_CN 公制渲染食材(250克、240毫升)、单位 short 档、温度 175°C ⟷ 347°F
用户点击 🇺🇸 English
→ currentLocale = 'en_US'(@StorageLink → AppStorage → 落盘)
→ curCfg() 返回 imperial 配置,isImperial() = true
→ nameOf():无盐黄油 → unsalted butter
→ amountOf():250/28.35=8.818… → fmtUnit('en_US', 8.8, 'ounce', 'short') → "8.8 oz"
→ 温度:fmtF() → fmtUnit('en_US', 347, 'fahrenheit', 'short') → "347°F"
→ ArkUI 增量渲染,全程无闪烁
用户点击 narrow 档
→ displayIdx = 2 → 所有 fmtUnit 的 unitDisplay 变 narrow → "250g" / "0.24L"
八、ArkTS 兼容要点
unitDisplay: display as 'short':displayNames()返回string[],传给需要字面量联合类型的选项时需要断言(arkts-no-any-unknown规范下最常见的写法);catch (err)不带类型注解(arkts-no-types-in-catch),格式化异常统一走兜底分支;- 对象字面量全部显式接口(
LangCfg/Ingredient),new Map(pairs as Array<[string, string]>)需要断言避免元组类型推断问题; ForEachkey 生成器返回稳定唯一值:语言用l.code、食材用i.id、温度用`${idx}-${c}`(数值可能有重复);- 页面最外层
Scroll()承载(Column无scrollable属性),scrollBar(BarState.Off)隐藏滚动条保持清爽; TEMPS常量数组用[160, 175, 190, 200, 220]直接量,ForEach回调里(c: number, idx: number)显式标注参数类型。
九、性能与内存
fmtUnit()每次调用都new intl.NumberFormat(...),本页单次渲染约 12 次调用(5 食材 × 2 体系分支 + 2 示例 + 2 温度),毫秒级可接受;若食材上百条,应在模块级按code + unit + display缓存格式化器实例;- 换算函数是纯数学(除法/乘法),无状态、无 IO,重复计算开销可忽略——不要把换算结果存进状态,计算比缓存更便宜且不会过期;
PersistentStorage只存语言字符串,不存对象,符合"小数据用 Preferences"的工程约定;- 页面无定时器、无监听器,后台自动销毁,无内存泄漏风险。
十、小结
本应用把"度量衡本地化"拆成三个互不耦合的层次:语言层管体系归属(谁用公制、谁用英制)、换算层管纯数学(线性表 + 温度特例)、格式化层管呈现(style:'unit' 输出本地化单位)。其中 fmtUnit() 一个函数通吃"语言 × 单位 × 档位"三个维度,是 CLDR 数据驱动能力的最佳示范。
| 应用 | 深化维度 |
|---|---|
| 01 | 多语言文案表 + 三级降级(语言层骨架) |
| 03 | NumberFormat 货币/汇率(金额维度) |
| 04 | NumberFormat 数字/百分比(数值维度) |
| 09 | NumberFormat style:‘unit’(单位维度)← 本文 |
| 13 | 货币 + 数字 + 日期 + 单位综合(生产级账单) |
给生产环境的三条铁律:① 数据永远存 SI 基准单位;② 展示永远走 Intl style:'unit',绝不手拼单位字符串;③ 默认单位跟随 locale,但允许用户手动覆盖并单独持久化——因为"用户偏好"和"系统默认"是两回事。
更多推荐



所有评论(0)