在这里插入图片描述
在这里插入图片描述

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

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

  • 中文用户看到「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盎司

三个技术点:

  1. 复数形态自动处理kilograms/grammes 的复数、中文/日文无复数形态,全部由 CLDR 规则决定,代码里一个 if 都不用写;
  2. 空格规则随 locale:英文 8.8 oz 用窄空格,法文 8,8 oz 数字的小数点都变了(8.8 vs 8,8),日文窄格式 8.8oz 无空格——排版细节全部正确;
  3. 非法 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 兼容要点

  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. ForEach key 生成器返回稳定唯一值:语言用 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 多语言文案表 + 三级降级(语言层骨架)
03 NumberFormat 货币/汇率(金额维度)
04 NumberFormat 数字/百分比(数值维度)
09 NumberFormat style:‘unit’(单位维度)← 本文
13 货币 + 数字 + 日期 + 单位综合(生产级账单)

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

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐