鸿蒙项目实战 - 度量衡本地化 — 技术实现篇
2026/8/22 4:14:59 网站建设 项目流程


一、业务需求(为什么这么设计)

产品要做一款面向全球的食谱工具,核心需求是:同一份食材清单,在不同国家用户面前自动变成他们习惯的单位体系

  • 中文用户看到「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字段:

interfaceLangCfg{code:string;// locale 代码name:string;// 母语名flag:string;// 国旗 emojisystem:string;// 'metric' | 'imperial' ← 度量衡体系}
privateisImperial():boolean{returnthis.curCfg().system==='imperial';}privatecurCfg():LangCfg{returnLANGS.find((l:LangCfg)=>l.code===this.currentLocale)??LANGS[0];}

设计决策:用system显式标注而非靠"US 结尾推断英制"——真实产品中英国部分地区混用、日本用公制但用坪(tsubo)计量面积,靠推断必然出错。显式字段 + 兜底?? LANGS[0](找不到 locale 时按默认语言处理),是防御式编码的典型写法。

3.2 文案表(5 语言)

functionbuildStrings(pairs:Array<[string,string]>):Map<string,string>{returnnewMap(pairsasArray<[string,string]>);}constSTRINGS:Map<string,Map<string,string>>=(()=>{constm=newMap<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 … */]));returnm;})();

取用仍走本系列统一的三级降级:

functiont(code:string,key:string):string{constv=STRINGS.get(code)?.get(key);if(v!==undefined)returnv;returnSTRINGS.get(DEFAULT_LOCALE)?.get(key)??key;// 目标 → 默认 → key}

注意文案表里不包含任何单位词(“克”"磅"都不在 STRINGS 里)——单位词由fmtUnit()从 CLDR 数据生成,文案表只管界面通用词(标题/按钮/标签)。这是本应用与纯"文案翻译"应用的本质区别:单位名称不是翻译出来的,是格式化出来的

四、格式化层:Intl.NumberFormat style:'unit'(核心 API)

4.1 核心用法

functionfmtUnit(code:string,value:number,unit:string,display:string):string{try{constfmt=newintl.NumberFormat(code,{style:'unit',unit:unit,unitDisplay:displayas'short',maximumFractionDigits:1});returnfmt.format(value);}catch(err){return`${value.toFixed(1)}${unit}`;// 兜底:格式化失败时回退纯数字 + 代码}}

一个函数输出全部 5 种语言 + 三档显示 + 任意单位,单位名称、复数形态、空格/连字符规则全部由 CLDR(Unicode Common Locale Data Repository)数据驱动:

locale100 kg(long)8.8 oz(short)
en_US100 kilograms8.8 oz
zh_CN100千克8.8盎司
ja_JP100キログラム8.8オンス
fr_FR100 kilogrammes8.8 oz
zh_TW100公斤8.8盎司

三个技术点:

  1. 复数形态自动处理kilograms/grammes的复数、中文/日文无复数形态,全部由 CLDR 规则决定,代码里一个if都不用写;
  2. 空格规则随 locale:英文8.8 oz用窄空格,法文8,8 oz数字的小数点都变了(8.8vs8,8),日文窄格式8.8oz无空格——排版细节全部正确;
  3. 非法 unit 抛错unit传了 CLDR 不支持的代码(如拼错的'killogram')会抛异常,try/catch兜底为纯数字 + 单位代码,保证 UI 永不空白。

4.2 unitDisplay 三档

档位示例(en, 0.24 L)设计意图
long0.24 liters正式/朗读场景,单位词完整
short0.24 LUI 标签默认,平衡信息与空间
narrow0.24L图表刻度/极窄空间,去掉所有空格

本应用把三档做成运行时切换(displayIdx),用户可实时对比——这也是验证 CLDR 数据完整性最直观的手段。

五、换算层:公制基准 + 两个特例

5.1 数据统一用公制基准

INGREDIENTS表里所有数值都是公制基准值(克/毫升),英制显示时才换算——这是国际化存储的铁律:存储用 SI 基准单位,展示层换算

// 换算:公制 → 英制functiontoImperial(metric:number,unit:string):number{if(unit==='gram'){returnmetric/28.35;// g → oz}returnmetric/29.5735;// ml → fl oz}

如果反过来(存储英制、展示换算公制),每次新增地区都要改数据;而存公制后,即使未来加印度(英制)或缅甸(本地单位),数据零改动。

5.2 温度是唯一非线性特例

长度、重量、容量都是目标值 = 基准值 × 系数的线性关系,温度不行:

functioncToF(c:number):number{returnc*9/5+32;}

175°C → 347°F而非175 × 1.8 = 315——差 32 度的偏移量必须单独处理。换算层为温度单开函数而不是硬塞进统一的rate表,保证了线性换算表可以保持"纯乘法"的简单性。同理,开尔文与摄氏(K = C + 273.15)也是偏移型,将来扩展同样单开函数。

六、状态联动与重渲染

@StorageLink(STORAGE_LOCALE)currentLocale:string=DEFAULT_LOCALE;@StatedisplayIdx:number=1;// 0 long / 1 short / 2 narrow@StatetempIdx: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 兼容要点

  1. unitDisplay: display as 'short'displayNames()返回string[],传给需要字面量联合类型的选项时需要断言(arkts-no-any-unknown规范下最常见的写法);
  2. catch (err)不带类型注解(arkts-no-types-in-catch),格式化异常统一走兜底分支;
  3. 对象字面量全部显式接口(LangCfg/Ingredient),new Map(pairs as Array<[string, string]>)需要断言避免元组类型推断问题;
  4. ForEachkey 生成器返回稳定唯一值:语言用l.code、食材用i.id、温度用`${idx}-${c}`(数值可能有重复);
  5. 页面最外层Scroll()承载(Columnscrollable属性),scrollBar(BarState.Off)隐藏滚动条保持清爽;
  6. 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多语言文案表 + 三级降级(语言层骨架)
03NumberFormat 货币/汇率(金额维度)
04NumberFormat 数字/百分比(数值维度)
09NumberFormat style:‘unit’(单位维度)← 本文
13货币 + 数字 + 日期 + 单位综合(生产级账单)

给生产环境的三条铁律:① 数据永远存 SI 基准单位;② 展示永远走Intl style:'unit',绝不手拼单位字符串;③ 默认单位跟随 locale,但允许用户手动覆盖并单独持久化——因为"用户偏好"和"系统默认"是两回事。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询