一、业务需求为什么这么设计产品要做一款面向全球的食谱工具核心需求是同一份食材清单在不同国家用户面前自动变成他们习惯的单位体系。中文用户看到「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_USStorageLink 写回 AppStoragePersistentStorage 落盘 → isImperial() 变 trueen_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().systemimperial;}privatecurCfg():LangCfg{returnLANGS.find((l:LangCfg)l.codethis.currentLocale)??LANGS[0];}设计决策用system显式标注而非靠US 结尾推断英制——真实产品中英国部分地区混用、日本用公制但用坪tsubo计量面积靠推断必然出错。显式字段 兜底?? LANGS[0]找不到 locale 时按默认语言处理是防御式编码的典型写法。3.2 文案表5 语言functionbuildStrings(pairs:Array[string,string]):Mapstring,string{returnnewMap(pairsasArray[string,string]);}constSTRINGS:Mapstring,Mapstring,string((){constmnewMapstring,Mapstring,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{constvSTRINGS.get(code)?.get(key);if(v!undefined)returnv;returnSTRINGS.get(DEFAULT_LOCALE)?.get(key)??key;// 目标 → 默认 → key}注意文案表里不包含任何单位词“克”磅都不在 STRINGS 里——单位词由fmtUnit()从 CLDR 数据生成文案表只管界面通用词标题/按钮/标签。这是本应用与纯文案翻译应用的本质区别单位名称不是翻译出来的是格式化出来的。四、格式化层Intl.NumberFormat style:unit核心 API4.1 核心用法functionfmtUnit(code:string,value:number,unit:string,display:string):string{try{constfmtnewintl.NumberFormat(code,{style:unit,unit:unit,unitDisplay:displayasshort,maximumFractionDigits:1});returnfmt.format(value);}catch(err){return${value.toFixed(1)}${unit};// 兜底格式化失败时回退纯数字 代码}}一个函数输出全部 5 种语言 三档显示 任意单位单位名称、复数形态、空格/连字符规则全部由 CLDRUnicode Common Locale Data Repository数据驱动locale100 kglong8.8 ozshorten_US100 kilograms8.8 ozzh_CN100千克8.8盎司ja_JP100キログラム8.8オンスfr_FR100 kilogrammes8.8 ozzh_TW100公斤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设计意图long0.24 liters正式/朗读场景单位词完整short0.24 LUI 标签默认平衡信息与空间narrow0.24L图表刻度/极窄空间去掉所有空格本应用把三档做成运行时切换displayIdx用户可实时对比——这也是验证 CLDR 数据完整性最直观的手段。五、换算层公制基准 两个特例5.1 数据统一用公制基准INGREDIENTS表里所有数值都是公制基准值克/毫升英制显示时才换算——这是国际化存储的铁律存储用 SI 基准单位展示层换算。// 换算公制 → 英制functiontoImperial(metric:number,unit:string):number{if(unitgram){returnmetric/28.35;// g → oz}returnmetric/29.5735;// ml → fl oz}如果反过来存储英制、展示换算公制每次新增地区都要改数据而存公制后即使未来加印度英制或缅甸本地单位数据零改动。5.2 温度是唯一非线性特例长度、重量、容量都是目标值 基准值 × 系数的线性关系温度不行functioncToF(c:number):number{returnc*9/532;}175°C → 347°F而非175 × 1.8 315——差 32 度的偏移量必须单独处理。换算层为温度单开函数而不是硬塞进统一的rate表保证了线性换算表可以保持纯乘法的简单性。同理开尔文与摄氏K C 273.15也是偏移型将来扩展同样单开函数。六、状态联动与重渲染StorageLink(STORAGE_LOCALE)currentLocale:stringDEFAULT_LOCALE;StatedisplayIdx:number1;// 0 long / 1 short / 2 narrowStatetempIdx:number1;// 175°C 默认三个状态各自独立、互不覆盖语言切换只改currentLocale显示粒度只改displayIdx温度档只改tempIdx。任一变化 → 相关格式化函数重算 → ArkUI 增量渲染。没有换算结果这个状态——结果永远是即时计算出来的这正是声明式 UI 的优势展示值不落状态、不存缓存天然避免数据与展示不同步的经典 bug。持久化细节aboutToAppear():void{if(!AppStorage.getstring(STORAGE_LOCALE)){AppStorage.setOrCreate(STORAGE_LOCALE,DEFAULT_LOCALE);}PersistentStorage.persistProp(STORAGE_LOCALE,DEFAULT_LOCALE);}先setOrCreate兜底、再persistProp落盘与系列其他应用一致displayIdx/tempIdx不持久化回到默认档位无伤大雅且避免 Preferences 写入过于频繁。七、数据流复盘一次完整交互启动 → aboutToAppearAppStorage 初始化 persistProp → build按 zh_CN 公制渲染食材250克、240毫升、单位 short 档、温度 175°C ⟷ 347°F 用户点击 English → currentLocale en_USStorageLink → AppStorage → 落盘 → curCfg() 返回 imperial 配置isImperial() true → nameOf()无盐黄油 → unsalted butter → amountOf()250/28.358.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 shortdisplayNames()返回string[]传给需要字面量联合类型的选项时需要断言arkts-no-any-unknown规范下最常见的写法catch (err)不带类型注解arkts-no-types-in-catch格式化异常统一走兜底分支对象字面量全部显式接口LangCfg/Ingredientnew 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多语言文案表 三级降级语言层骨架03NumberFormat 货币/汇率金额维度04NumberFormat 数字/百分比数值维度09NumberFormat style:‘unit’单位维度← 本文13货币 数字 日期 单位综合生产级账单给生产环境的三条铁律① 数据永远存 SI 基准单位② 展示永远走Intl style:unit绝不手拼单位字符串③ 默认单位跟随 locale但允许用户手动覆盖并单独持久化——因为用户偏好和系统默认是两回事。