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

实例:旅行翻译助手|技术:@ohos.i18n / @ohos.intl(Intl/DateTimeFormat/NumberFormat)、资源限定符(qualifiers)、多语言资源管理、@kit.ArkData(preferences)

一、本篇范围

旅行翻译助手是「国际化 + 文本处理」的综合性应用:内置一个多语种常用语词典(问候、点餐、问路、购物四大类),支持中英日韩互译;用户界面本身完全国际化——跟随系统语言显示中文/英文,日期时间、数字、货币按用户区域习惯格式化;常用短语可收藏进 preferences。

本篇拆「国际化服务层」,核心问题:

  1. 多语言资源怎么组织?base/en_US/zh_CN 资源目录 + 资源限定符的匹配机制;
  2. 代码里怎么读多语言?$r() 资源引用与 getContext().resourceManager 动态取资源;
  3. Intl 格式化三件套:DateTimeFormatNumberFormatCollator 的用法与区域差异;
  4. 本地词典数据结构怎么设计?多语种短语的 key-value 模型与查找算法。

二、多语言资源体系:限定符的魔力

2.1 资源目录结构

HarmonyOS 的资源文件按「目录名 = 限定符」自动匹配。一个支持中英双语的翻译应用,资源目录长这样:

entry/src/main/resources/
├── base/                    # 默认资源(必选,兜底语言)
│   ├── element/string.json  # 默认语言字符串(英文)
│   ├── media/icon.png
│   └── profile/main_pages.json
├── zh_CN/                   # 简体中文限定符目录
│   └── element/string.json  # 中文覆盖
├── en_US/                   # 美式英语(显式声明,虽然 base 也是英文)
│   └── element/string.json
└── zh_HK/                   # 繁体(可选)
    └── element/string.json

匹配优先级规则:系统语言是简体中文 → 优先 zh_CN;系统是英语 → 优先 en_US;系统是法语且没有 fr 目录 → 落回 base。这就是「限定符回退」机制——base 目录必须存在,否则法语用户拿到的是空资源。

2.2 string.json 的资源键设计

base/element/string.json(默认英文):

{
  "string": [
    { "name": "app_name", "value": "Travel Translator" },
    { "name": "tab_phrases", "value": "Phrases" },
    { "name": "tab_favorites", "value": "Favorites" },
    { "name": "search_hint", "value": "Search phrases..." },
    { "name": "greeting_title", "value": "Greetings" },
    { "name": "dining_title", "value": "Dining" },
    { "name": "asking_title", "value": "Asking Directions" },
    { "name": "shopping_title", "value": "Shopping" },
    { "name": "add_favorite", "value": "Add to favorites" },
    { "name": "removed_favorite", "value": "Removed from favorites" },
    { "name": "language_label", "value": "Language" },
    { "name": "translate_hint", "value": "Tap a phrase to translate" }
  ]
}

zh_CN/element/string.json(中文覆盖,只写不同的键):

{
  "string": [
    { "name": "app_name", "value": "旅行翻译" },
    { "name": "tab_phrases", "value": "常用语" },
    { "name": "tab_favorites", "value": "收藏" },
    { "name": "search_hint", "value": "搜索短语…" },
    { "name": "greeting_title", "value": "问候" },
    { "name": "dining_title", "value": "点餐" },
    { "name": "asking_title", "value": "问路" },
    { "name": "shopping_title", "value": "购物" },
    { "name": "add_favorite", "value": "已加入收藏" },
    { "name": "removed_favorite", "value": "已取消收藏" },
    { "name": "language_label", "value": "语言" },
    { "name": "translate_hint", "value": "点击短语查看译文" }
  ]
}

覆盖机制:zh_CN 只声明与 base 不同的键,未覆盖的键自动回退 base——避免重复维护。注意资源值里不要硬编码占位逻辑,需要拼接的地方用格式化占位符(见第四节)。

2.3 代码里读取:$r() 与 resourceManager

UI 代码里用 $r('app.string.xxx') 直接引用,系统自动按当前语言解析:

// 静态引用(编译期检查 key 是否存在)
Text($r('app.string.tab_phrases')).fontSize(16)

动态场景(比如拼字符串、根据变量取资源)用 resourceManager

import { common } from '@kit.AbilityKit';

export class I18nUtil {
  /**
   * 动态获取本地化字符串。
   */
  static async getString(context: common.UIAbilityContext, key: string): Promise<string> {
    const mgr = context.resourceManager;
    try {
      return await mgr.getStringByName(key);
    } catch {
      return key; // 资源缺失时回退为 key 本身,便于排查
    }
  }

  /**
   * 获取带占位符的格式化字符串。
   * 用法:formatString(ctx, 'welcome_user', ['张三'])
   */
  static async formatString(context: common.UIAbilityContext, key: string,
                            args: Array<string | number>): Promise<string> {
    const mgr = context.resourceManager;
    const raw = await mgr.getStringByName(key);
    // 按 %s %d 顺序替换(资源里写 %1$s 位置参数更稳,此处演示顺序替换)
    return raw.replace(/%\d*\$?[sd]/g, () => String(args.shift() ?? ''));
  }
}

关键区别$r() 是编译期静态解析,适合 UI 里写死的资源;resourceManager.getStringByName 是运行时动态获取,适合「key 由变量决定」或非 UI 层的国际化。两者读的是同一套资源目录。

格式化占位符最佳实践:资源值里用位置参数 %1$s%2$d,因为不同语言的语序不同——中文「欢迎 %1s」,英文"Welcome,s」,英文 "Welcome, %1s」,英文"Welcome,s" 都是 1 号参数,但某些语言(如日语)可能需要调换顺序,位置参数让每种语言自己决定顺序,这是国际化的硬性要求。

三、Intl 三件套:区域感知的格式化

@ohos.intl 模块提供 ECMA-402 标准的国际化 API。旅行场景最常见的三个:日期时间、数字、货币。

3.1 DateTimeFormat:日期时间的本地化

import { intl } from '@kit.ArkTS';

export class DateTimeFmt {
  /**
   * 按区域格式化日期。zh-CN: 2025年1月1日;en-US: Jan 1, 2025
   */
  static formatDate(date: Date, locale: string = 'zh-CN'): string {
    const dtf = new intl.DateTimeFormat(locale, {
      year: 'numeric',
      month: 'long',
      day: 'numeric',
      weekday: 'long'
    });
    return dtf.format(date.getTime());
  }

  /**
   * 24小时制时间。zh-CN: 14:30;en-US: 2:30 PM
   */
  static formatTime(date: Date, locale: string = 'zh-CN'): string {
    const dtf = new intl.DateTimeFormat(locale, {
      hour: '2-digit',
      minute: '2-digit',
      hour12: false
    });
    return dtf.format(date.getTime());
  }

  /**
   * 相对时间(翻译助手显示"刚刚/5分钟前"用)。
   */
  static formatRelative(date: Date, now: number = Date.now()): string {
    const diffMs = now - date.getTime();
    const min = Math.floor(diffMs / 60000);
    if (min < 1) return '刚刚';
    if (min < 60) return `${min}分钟前`;
    const h = Math.floor(min / 60);
    if (h < 24) return `${h}小时前`;
    return `${Math.floor(h / 24)}天前`;
  }
}

DateTimeFormat 的关键是让引擎决定格式:你只声明「要年、月、日、星期几」,引擎按区域输出。中文 “2025年1月1日 星期三”、英文 “Wednesday, January 1, 2025”、日语 “2025年1月1日 水曜日”——语言和格式全部自动适配,这是手写模板永远做不到的。

date.getTime()format 接收的是时间戳(number),传 Date 对象也可以但推荐显式 getTime()

3.2 NumberFormat:数字与货币

export class NumberFmt {
  /**
   * 千分位数字。zh-CN: 1,234.5;en-US: 1,234.5(差异在符号位置)
   */
  static formatNumber(n: number, locale: string = 'zh-CN'): string {
    const nf = new intl.NumberFormat(locale, {
      style: 'decimal',
      maximumFractionDigits: 2
    });
    return nf.format(n);
  }

  /**
   * 货币:旅行预算换算展示用。
   * 注意:这只是「格式化为货币样式」,汇率换算需自行计算。
   */
  static formatCurrency(amount: number, currency: string, locale: string = 'zh-CN'): string {
    const nf = new intl.NumberFormat(locale, {
      style: 'currency',
      currency: currency,      // 'CNY' / 'USD' / 'JPY' / 'KRW'
      minimumFractionDigits: 0,
      maximumFractionDigits: 2
    });
    return nf.format(amount);
  }
}

货币格式化的坑:currency 用 ISO 4217 代码(CNY/USD/JPY),不是符号(¥/$)。引擎按区域决定符号样式——zh-CN 显示 “¥100”、en-US 显示 “$100.00”。日元 JPY 默认 0 位小数,但我们声明了 0–2 位,引擎按区域规矩处理。

3.3 Collator:区域感知排序

多语言短语列表按字母/拼音排序,直接用 < 比较对中日文不准,要用 Collator

export class SortUtil {
  static collator: intl.Collator = new intl.Collator('zh-CN', { sensitivity: 'base' });

  /**
   * 区域感知排序:英文按字母、中文按拼音。
   */
  static compare(a: string, b: string): number {
    return SortUtil.collator.compare(a, b);
  }

  static sortByLocale<T>(list: T[], keyFn: (item: T) => string): T[] {
    return [...list].sort((x, y) => SortUtil.compare(keyFn(x), keyFn(y)));
  }
}

Collator 按区域规则比较字符串——中文按拼音、日文按五十音、英文按字母。sensitivity: 'base' 忽略大小写与重音(“café” 与 “cafe” 视为相同),适合搜索排序场景。

四、本地词典数据模型

翻译助手不依赖在线翻译 API,内置一个四语种短语词典。数据结构设计:

export interface PhraseEntry {
  id: string;           // 稳定唯一 id
  category: Category;   // 分类
  translations: Record<string, string>; // locale -> 文本
  favorite: boolean;    // 是否收藏(本地状态)
}

export enum Category {
  GREETING = 'greeting',
  DINING = 'dining',
  ASKING = 'asking',
  SHOPPING = 'shopping'
}

export class PhraseDictionary {
  /** 内置词典:每条短语 4 种语言 */
  private static readonly DATA: PhraseEntry[] = [
    {
      id: 'p001',
      category: Category.GREETING,
      translations: {
        'zh-CN': '你好',
        'en-US': 'Hello',
        'ja-JP': 'こんにちは',
        'ko-KR': '안녕하세요'
      },
      favorite: false
    },
    {
      id: 'p002',
      category: Category.GREETING,
      translations: {
        'zh-CN': '谢谢',
        'en-US': 'Thank you',
        'ja-JP': 'ありがとうございます',
        'ko-KR': '감사합니다'
      },
      favorite: false
    },
    {
      id: 'p003',
      category: Category.DINING,
      translations: {
        'zh-CN': '这个多少钱?',
        'en-US': 'How much is this?',
        'ja-JP': 'これはいくらですか?',
        'ko-KR': '이것은 얼마예요?'
      },
      favorite: false
    },
    {
      id: 'p004',
      category: Category.DINING,
      translations: {
        'zh-CN': '请给我菜单',
        'en-US': 'Menu, please',
        'ja-JP': 'メニューをお願いします',
        'ko-KR': '메뉴판 주세요'
      },
      favorite: false
    },
    {
      id: 'p005',
      category: Category.ASKING,
      translations: {
        'zh-CN': '车站怎么走?',
        'en-US': 'How do I get to the station?',
        'ja-JP': '駅はどう行きますか?',
        'ko-KR': '역은 어떻게 가요?'
      },
      favorite: false
    },
    {
      id: 'p006',
      category: Category.ASKING,
      translations: {
        'zh-CN': '洗手间在哪里?',
        'en-US': 'Where is the restroom?',
        'ja-JP': 'トイレはどこですか?',
        'ko-KR': '화장실은 어디예요?'
      },
      favorite: false
    },
    {
      id: 'p007',
      category: Category.SHOPPING,
      translations: {
        'zh-CN': '可以便宜一点吗?',
        'en-US': 'Can you make it cheaper?',
        'ja-JP': 'もう少し安くなりませんか?',
        'ko-KR': '좀 깎아주세요'
      },
      favorite: false
    },
    {
      id: 'p008',
      category: Category.SHOPPING,
      translations: {
        'zh-CN': '我要这个',
        'en-US': 'I will take this',
        'ja-JP': 'これをください',
        'ko-KR': '이거 주세요'
      },
      favorite: false
    }
  ];

  static all(): PhraseEntry[] {
    return PhraseDictionary.DATA.map((p) => ({ ...p, translations: { ...p.translations } }));
  }

  /**
   * 按分类查询。
   */
  static byCategory(category: Category): PhraseEntry[] {
    return PhraseDictionary.all().filter((p) => p.category === category);
  }

  /**
   * 全文搜索:在目标语言文本里 LIKE 匹配。
   */
  static search(keyword: string, locale: string): PhraseEntry[] {
    const kw = keyword.trim().toLowerCase();
    if (kw.length === 0) return PhraseDictionary.all();
    return PhraseDictionary.all().filter((p) =>
      Object.values(p.translations).some((t) => t.toLowerCase().includes(kw)) ||
      p.id.includes(kw));
  }

  /**
   * 翻译一条短语到目标语言。
   */
  static translate(entry: PhraseEntry, targetLocale: string): string {
    return entry.translations[targetLocale] ?? entry.translations['en-US'] ?? '';
  }
}

4.1 词典设计要点

translations 用 Record<string, string>(locale → 文本):新增语言只加一个 key,不改数据结构。翻译时按目标 locale 取值,缺失回退英文(兜底语言)。

深拷贝防串改all(){ ...p, translations: { ...p.translations } } 做两层浅拷贝,避免 UI 层修改 favorite 污染静态数据(静态 DATA 是 readonly 语义,但对象本身可变,拷贝更稳妥)。

搜索在目标语言全文匹配Object.values(p.translations) 遍历所有语言文本,任一语言命中即算命中——旅行场景「用中文搜英文短语」也支持。

五、收藏持久化:preferences

收藏状态要跨启动保留:

export class FavoriteStore {
  private static readonly KEY: string = 'favorite_ids';

  static async load(context: common.UIAbilityContext): Promise<Set<string>> {
    const store = await preferences.getPreferences(context, 'translator');
    const raw = await store.get(FavoriteStore.KEY, '[]');
    try {
      const arr = typeof raw === 'string' ? JSON.parse(raw) as string[] : [];
      return new Set(arr);
    } catch {
      return new Set();
    }
  }

  static async save(context: common.UIAbilityContext, ids: Set<string>): Promise<void> {
    const store = await preferences.getPreferences(context, 'translator');
    await store.put(FavoriteStore.KEY, JSON.stringify(Array.from(ids)));
    await store.flush();
  }
}

Set 序列化成数组存储——JSON 不支持 Set,这是常见处理。收藏操作「读 Set → 增删 → 回写」,配合页面层刷新。

六、区域检测:跟随系统还是手动切换

import { i18n } from '@kit.ArkTS';

export class LocaleService {
  /**
   * 获取系统当前语言(如 'zh-CN'、'en-US')。
   */
  static getSystemLocale(): string {
    return i18n.System.getSystemLanguage();
  }

  /**
   * 判断是否为中文环境(翻译界面的目标语言默认策略)。
   */
  static isChineseEnv(): boolean {
    return LocaleService.getSystemLocale().startsWith('zh');
  }

  /**
   * 支持的语言列表(词典覆盖范围)。
   */
  static supportedLocales(): string[] {
    return ['zh-CN', 'en-US', 'ja-JP', 'ko-KR'];
  }
}

应用 UI 语言跟随系统(资源限定符自动处理);翻译的目标语言则由用户手动选择(中文用户出国默认目标是本地语言,但可手动改)。两套机制互不冲突:UI 本地化走资源限定符,业务数据本地化走词典模型

七、代码定位表

代码块 关注点 改动入口
base/zh_CN 资源目录 限定符匹配与回退 新增语言时加资源目录
I18nUtil $r 与 resourceManager 动态取资源时用
DateTimeFmt/NumberFmt/SortUtil Intl 三件套 调整格式时改 options
PhraseDictionary 词典数据与搜索翻译 增词时加 DATA 条目
FavoriteStore 收藏持久化 调整存储格式时改这里
LocaleService 系统区域检测 扩展支持语言时改 supportedLocales

八、本篇小结

国际化服务层的三大支柱:资源限定符(目录名自动匹配语言)、Intl API(引擎决定格式细节)、词典模型(Record<locale, text> 天然支持多语种)。三者各管一段:界面文案、数据格式、业务内容,互不越界。

关键记忆点:base 目录必须存在兜底;格式化用位置参数 %1$s;currency 用 ISO 代码;Collator 管排序;UI 语言与数据语言解耦。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐