一、业务需求(为什么这么设计)
产品要做一款面向全球的食谱工具,核心需求是:同一份食材清单,在不同国家用户面前自动变成他们习惯的单位体系。
- 中文用户看到「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)数据驱动:
| 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 基准单位,展示层换算。
// 换算:公制 → 英制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 兼容要点
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,但允许用户手动覆盖并单独持久化——因为"用户偏好"和"系统默认"是两回事。