在这里插入图片描述

开篇:国际化是跨端落地的"最后一公里"

当我们把应用从一台手机推向全球市场,或者从国内版本扩展到海外版本时,真正棘手的往往不是业务逻辑,而是那些"理所当然"的本地差异:一句问候语、一种货币符号、一个从右往左阅读的文字方向。

很多团队在国际化上踩过的坑,本质上是把"文案"当成了字符串硬编码。结果是一旦要新增语种,就要满工程搜 $r 之外的裸字符串、改代码、重新打包。鸿蒙(HarmonyOS NEXT / API 12+)给出的解法非常清晰:用资源限定符(qualifier)做语种分层,用 ResourceManager 做运行时解析,用 i18n 工具链做格式化

这篇文章不堆砌概念,而是带你从工程配置一路写到可运行的完整页面,把国际化的主干链路打通。


一、资源限定符:用目录分层取代硬编码

国际化的第一步,是把所有"会随语言变化的内容"从代码里请出去,交给资源系统管理。鸿蒙的资源目录以 resources/ 为根,通过限定符子目录来表达不同的语言、地区、横竖屏甚至是设备形态。

最关键的限定符就是 language(语言)与 region(地区)。它们写成目录名,系统会根据当前语言环境自动匹配最精确的那个。

典型的工程结构如下:

entry/src/main/resources/
├── base/                      # 默认资源(兜底)
│   └── element/
│       └── string.json        # 默认英文或中性文案
├── zh/                        # 中文(语言限定符)
│   └── element/
│       └── string.json
├── zh_CN/                     # 中文-中国大陆(更精确)
│   └── element/
│       └── string.json
├── en/                        # 英文
│   └── element/
│       └── string.json
└── ar/                        # 阿拉伯语(RTL 语言)
    └── element/
        └── string.json

注意目录名 zhzh_CNenar 就是限定符,不需要在文件里再声明语言。系统匹配时遵循"越精确优先级越高"的原则:zh_CN 会覆盖 zhzh 会覆盖 base

下面给出三个语种分离的 string.json 示例。默认 base 放中性兜底文案(通常英文),zh_CN 放简体中文,en 放英文,ar 放阿拉伯语。

resources/base/element/string.json(兜底):

{
  "string": [
    { "name": "app_name", "value": "Global Demo" },
    { "name": "greeting", "value": "Hello, %s!" },
    { "name": "unread_count", "value": "You have %d unread messages" },
    { "name": "price_label", "value": "Price" },
    { "name": "switch_lang", "value": "Language" }
  ]
}

resources/zh_CN/element/string.json(简体中文):

{
  "string": [
    { "name": "app_name", "value": "全球化示例" },
    { "name": "greeting", "value": "你好,%s!" },
    { "name": "unread_count", "value": "你有 %d 条未读消息" },
    { "name": "price_label", "value": "价格" },
    { "name": "switch_lang", "value": "语言" }
  ]
}

resources/en/element/string.json(英文):

{
  "string": [
    { "name": "app_name", "value": "Global Demo" },
    { "name": "greeting", "value": "Hello, %s!" },
    { "name": "unread_count", "value": "You have %d unread messages" },
    { "name": "price_label", "value": "Price" },
    { "name": "switch_lang", "value": "Language" }
  ]
}

resources/ar/element/string.json(阿拉伯语,RTL):

{
  "string": [
    { "name": "app_name", "value": "عرض عالمي" },
    { "name": "greeting", "value": "مرحبا، %s!" },
    { "name": "unread_count", "value": "لديك %d رسائل غير مقروءة" },
    { "name": "price_label", "value": "السعر" },
    { "name": "switch_lang", "value": "اللغة" }
  ]
}

小结:限定符目录是静态国际化(跟随系统语言)的基石。只要文案全部收进 string.json,系统就能在不同语言下自动取对资源,代码里只用 $r('app.string.greeting') 引用即可。


二、读取系统语言:i18n.System 与 ResourceManager 的协作

静态跟随系统已经能解决大部分场景,但要做"应用内语言切换",我们必须先学会在运行时读语言和取资源。i18nresourceManager 都来自 @kit.LocalizationKit,二者配合是国际化运行时的核心。

i18n.System 提供了系统级语言信息的读取能力,最常用的就是 getSystemLocale(),它返回形如 "zh-CN""en-US" 的 locale 字符串。

resourceManager 则是资源的运行时入口。通过 getContext().resourceManager 可以拿到当前语言环境下的资源管理器,再用 getStringByNameSync / getStringByName 解析具体字符串,并支持占位符参数。

下面的工具方法演示了"读系统语言 + 解析带参数的字符串":

import { i18n } from '@kit.LocalizationKit';
import { resourceManager } from '@kit.LocalizationKit';
import { common } from '@kit.AbilityKit';

/**
 * 读取系统当前 locale,例如 "zh-CN"
 */
export function getSystemLocale(): string {
  return i18n.System.getSystemLocale();
}

/**
 * 从默认(跟随系统)资源管理器解析字符串,支持 %s / %d 占位符
 * @param ctx UIAbility 或 UIContext 持有的 context
 * @param name string.json 中的 name
 * @param args  占位符参数列表
 */
export function resolveFromSystem(
  ctx: common.UIAbilityContext,
  name: string,
  args?: Array<string | number>
): string {
  const resMgr = ctx.resourceManager;
  if (args && args.length > 0) {
    return resMgr.getStringByNameSync(name, args);
  }
  return resMgr.getStringByNameSync(name);
}

值得强调的是,getStringByNameSync 的第二个参数是 Array<string | number>,它会按顺序替换 string.json 里的 %s%d。例如 unread_count"你有 %d 条未读消息",传入 [3] 即可得到"你有 3 条未读消息"。

小结i18n.System 负责"看"系统语言,resourceManager 负责"取"对应资源。二者一静一动,构成了国际化的运行时底座。


三、应用内动态语言切换:不依赖系统设置的实现

真正的难点来了:用户希望在 App 内部切换语言,而不去改系统设置。鸿蒙提供了两条路径,我们都会讲清楚,并给出可运行的完整方案。

3.1 官方机制:setAppPreferredLanguage

API 11+ 起,i18n.System 提供了 setAppPreferredLanguage(language),它可以为当前应用单独设置偏好语言,且不需要系统级权限。设置后,应用内 $r 引用的资源会按新语言重新解析,但通常需要在 Ability 重建或配置变更后生效。

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

/**
 * 设置应用偏好语言,返回是否设置成功
 * @param language 形如 "zh"、"en"、"ar"
 */
export function setAppLanguage(language: string): boolean {
  const ok = i18n.System.setAppPreferredLanguage(language);
  return ok;
}

这条路径简洁,但有两个现实约束:一是部分旧框架版本在切换后需要主动重建 UI 上下文才能让 $r 立即刷新;二是它依赖系统配置变更链路。为了确定性更强、切换即时可见,生产项目更常用下面这种"主动解析 + 状态驱动"的写法。

3.2 确定性方案:按语言获取专用 ResourceManager

resourceManager.getResourceManager(language, region) 允许我们直接拿一个指定语言的资源管理器。配合 @State 把当前语言存起来,切换时重新解析字符串并刷新 UI,整个过程完全可控,不依赖系统配置变更时机。

下面先写一个 I18nManager,它缓存各语言对应的 ResourceManager,并对外暴露 getString

import { resourceManager } from '@kit.LocalizationKit';

/**
 * 国际化管理器:按语言缓存 ResourceManager,提供确定性的字符串解析
 */
export class I18nManager {
  private static instance: I18nManager | null = null;
  private cache: Map<string, resourceManager.ResourceManager> = new Map();
  private currentLanguage: string = 'zh';

  static getInstance(): I18nManager {
    if (!I18nManager.instance) {
      I18nManager.instance = new I18nManager();
    }
    return I18nManager.instance;
  }

  /**
   * 切换当前语言,并预加载对应的资源管理器
   */
  async changeLanguage(language: string): Promise<void> {
    this.currentLanguage = language;
    await this.getResMgr(language);
  }

  /**
   * 获取指定语言的 ResourceManager(带缓存)
   */
  private async getResMgr(language: string): Promise<resourceManager.ResourceManager> {
    if (this.cache.has(language)) {
      return this.cache.get(language)!;
    }
    // 传入语言限定符,系统会匹配 zh_CN / zh 等最精确目录
    const resMgr = await resourceManager.getResourceManager(language, '');
    this.cache.set(language, resMgr);
    return resMgr;
  }

  /**
   * 解析当前语言下的字符串
   */
  async getString(name: string, args?: Array<string | number>): Promise<string> {
    const resMgr = await this.getResMgr(this.currentLanguage);
    if (args && args.length > 0) {
      return resMgr.getStringByNameSync(name, args);
    }
    return resMgr.getStringByNameSync(name);
  }

  getCurrentLanguage(): string {
    return this.currentLanguage;
  }
}

这里有个细节:resourceManager.getResourceManager('zh', '') 中第二个 region 传空字符串时,系统会按语言 zh 去找最匹配的目录(zh_CN 优先于 zh,再兜底到 base)。这正好复用了第一节能的限定符优先级规则。

3.3 用 ViewModel + @State 驱动 UI 刷新

为了让语言切换即时反映到界面,我们用一个 @ObservedV2 / @State 结合的 ViewModel 持有"已解析好的字符串字典"。注意我们用 AppStorage 之外的轻量方案:直接在页面 @State 持有字符串表。

下面是完整的页面示例(可直接放进 entry/src/main/ets/pages/Index.ets):

import { I18nManager } from '../i18n/I18nManager';
import { i18n } from '@kit.LocalizationKit';

// 语言选项
const LANGUAGES: Array<{ code: string; label: string }> = [
  { code: 'zh', label: '简体中文' },
  { code: 'en', label: 'English' },
  { code: 'ar', label: 'العربية' }
];

@Entry
@Component
struct IndexPage {
  @State currentLang: string = i18n.System.getSystemLocale().split('-')[0] || 'zh';
  @State strings: Record<string, string> = {};
  @State userName: string = 'Harmony';
  @State unread: number = 3;
  @State price: number = 1299.00;

  private mgr: I18nManager = I18nManager.getInstance();

  aboutToAppear(): void {
    this.reloadStrings();
  }

  /**
   * 根据当前语言把需要的字符串全部解析到 @State,触发 UI 刷新
   */
  async reloadStrings(): Promise<void> {
    await this.mgr.changeLanguage(this.currentLang);
    const keys = ['app_name', 'greeting', 'unread_count', 'price_label', 'switch_lang'];
    const map: Record<string, string> = {};
    for (const k of keys) {
      map[k] = await this.mgr.getString(k);
    }
    // 处理带占位符的项(占位符需用参数单独解析,这里在渲染处处理)
    this.strings = map;
  }

  build() {
    Column({ space: 20 }) {
      Text(this.strings['app_name'] ?? 'App')
        .fontSize(26)
        .fontWeight(FontWeight.Bold)

      Text(this.strings['greeting'] ?? '')
        .fontSize(18)

      Text(`${this.strings['unread_count'] ?? ''}`.replace('%d', String(this.unread)))
        .fontSize(16)
        .fontColor('#666666')

      Button(this.strings['switch_lang'] ?? 'Language')
        .width(160)
        .onClick(() => this.openLangPicker())

      // 语言切换按钮组
      Row({ space: 12 }) {
        ForEach(LANGUAGES, (item: { code: string; label: string }) => {
          Button(item.label)
            .width(90)
            .height(36)
            .backgroundColor(this.currentLang === item.code ? '#0A59F7' : '#E5E5E5')
            .fontColor(this.currentLang === item.code ? '#FFFFFF' : '#333333')
            .onClick(async () => {
              this.currentLang = item.code;
              await this.reloadStrings();
            })
        })
      }
    }
    .width('100%')
    .height('100%')
    .padding(24)
    .justifyContent(FlexAlign.Start)
  }

  private openLangPicker(): void {
    // 简化示例:直接复用上面的按钮组,真实项目可弹 Dialog
  }
}

在这里插入图片描述

这段代码把"切换语言 → 重新解析字符串 → 写入 @State → 自动刷新"的闭环讲清楚了。占位符 %d 在渲染时以字符串替换的方式填入,确保不同语种语序差异也能正确显示。

小结:官方 setAppPreferredLanguage 适合"跟随应用偏好"的轻量切换;而"按语言取 ResourceManager + @State 驱动"则能提供确定性、即时可见的切换体验。生产项目通常二者结合——先用前者写入应用偏好,再用后者保证 UI 即时刷新。


四、RTL 布局适配:方向即体验

切换到阿拉伯语(ar)时,真正的挑战不是文案,而是文字方向。阿拉伯语、希伯来语等是 RTL(Right-To-Left,从右向左)语言。如果布局不做适配,用户会看到一个"镜像错乱"的界面:返回按钮在右边、列表从左边开始、图标顺序全反。

ArkUI 提供了 Direction 枚举与组件的 direction 属性来控制布局方向:

  • Direction.Ltr:从左到右(中文、英文默认)
  • Direction.Rtl:从右到左(阿拉伯语、希伯来语)
  • Direction.Auto:跟随系统/语言环境自动判断

最稳妥的写法是使用 Direction.Auto,让框架根据当前语言自动选择方向。以下片段展示如何为容器设置方向,并让排版随语言翻转:

import { Direction } from '@kit.ArkUI';

@Component
struct RtlAwareCard {
  @State text: string = '';

  build() {
    // 使用 Auto,框架根据 locale 自动决定 Ltr / Rtl
    Row({ space: 12 }) {
      Image($r('app.media.ic_avatar'))
        .width(40)
        .height(40)
        .borderRadius(20)

      Column({ space: 4 }) {
        Text(this.text)
          .fontSize(16)
        Text('Subtitle')
          .fontSize(12)
          .fontColor('#999999')
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Start)
    }
    .width('100%')
    .padding(16)
    .backgroundColor('#F5F5F5')
    .borderRadius(12)
    .direction(Direction.Auto) // 关键点:方向自适应
  }
}

有两个 RTL 适配的"坑"值得单独点出:

第一,不要手动镜像坐标。当 directionRtl 时,ArkUI 会自动把 Row 的主轴翻转、padding/margin 的左右含义也跟着翻转。如果你在代码里写死 margin: { left: 12 } 想"对齐左边",在 RTL 下反而会错。应优先用 spacelayoutWeightalignItems 这类相对约束,而不是绝对左右值。

第二,图标与图片要谨慎镜像。返回箭头"‹"在 RTL 下应指向右侧,但 Logo、照片不应镜像。ArkUI 默认不会镜像 Image,只有布局方向会变。若你用了语义化的箭头图标,建议根据 i18n 判断语言后手动替换图标资源,而不是依赖自动翻转。

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

/**
 * 根据语言返回返回箭头图标:RTL 用向右箭头
 */
export function backIconFor(lang: string): Resource {
  const isRtl = i18n.System.isRTL(lang);
  return isRtl ? $r('app.media.ic_arrow_right') : $r('app.media.ic_arrow_left');
}

注意 i18n.System.isRTL(language) 可以判断某个语言是否为 RTL 语言,省去我们维护语言列表。

小结:RTL 不是"把界面反一下"那么简单,而是从布局方向、相对约束到图标语义的整体适配。用 Direction.Auto 打底,用 isRTL 处理图标特例,是最省心且正确的组合。


五、格式化:日期、货币、数字随语言而变

同样一组数字 1299.5 和日期 2026-07-17,在不同语言下写法天差地别:英文是 “$1,299.50” 和 “Jul 17, 2026”,中文是 “¥1,299.50” 和 “2026年7月17日”,德文是 “1.299,50 €”。靠字符串拼接必然出错,必须用区域化格式化

鸿蒙的 @kit.LocalizationKit 提供了 i18n.DateTimeFormati18n.NumberFormat,底层基于 ICU,支持按 locale 输出。下面给出三个常用场景:

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

/**
 * 按语言格式化日期
 */
export function formatDate(locale: string, date: Date): string {
  const df = new i18n.DateTimeFormat(locale, {
    dateStyle: 'long',  // full | long | medium | short
    timeStyle: 'short'
  });
  return df.format(date);
}

/**
 * 按语言格式化货币
 */
export function formatCurrency(locale: string, amount: number, currency: string): string {
  const nf = new i18n.NumberFormat(locale, {
    style: 'currency',
    currency: currency,   // 如 CNY、USD、EUR
    currencyDisplay: 'symbol'
  });
  return nf.format(amount);
}

/**
 * 按语言格式化普通数字(千分位、小数位)
 */
export function formatNumber(locale: string, value: number): string {
  const nf = new i18n.NumberFormat(locale, {
    useGrouping: true,
    maximumFractionDigits: 2
  });
  return nf.format(value);
}

调用示例:

const locale = 'zh-CN';
console.log(formatDate(locale, new Date(2026, 6, 17)));
// 输出:2026年7月17日 GMT+8 下午...(timeStyle=short)

console.log(formatCurrency(locale, 1299.5, 'CNY'));
// 输出:¥1,299.50

console.log(formatNumber('de-DE', 1299.5));
// 输出:1.299,5(德文千分位是点、小数位是逗号)

这里有一个容易忽略的点:locale 的格式。国际化里 locale 通常是 语言-地区 的形式(如 zh-CNen-USar-EG),而我们的资源限定符目录只用 zhenar。所以在格式化时要补上地区码,而在取资源时只用语言码——两者要分别处理,不要混用。

/**
 * 把资源语言码(zh)转成格式化 locale(zh-CN)
 * 实际项目可根据业务维护语言->地区的映射
 */
export function toLocale(lang: string): string {
  const map: Record<string, string> = {
    zh: 'zh-CN',
    en: 'en-US',
    ar: 'ar-EG'
  };
  return map[lang] ?? `${lang}-${lang.toUpperCase()}`;
}

小结:日期、货币、数字绝不能用字符串拼接硬编码。用 i18n.DateTimeFormat / i18n.NumberFormat 按 locale 输出,既准确又符合当地习惯。记住资源用"语言码"、格式化用"语言-地区码"的区别。


六、完整工程清单与最佳实践

把前面几节串起来,一个可运行的国际化模块应该包含这些文件与约定:

entry/src/main/ets/
├── i18n/
│   ├── I18nManager.ets        # 按语言缓存 ResourceManager,提供 getString
│   ├── format.ets             # formatDate / formatCurrency / formatNumber
│   └── locale.ets             # getSystemLocale / isRTL / toLocale 等辅助
├── pages/
│   └── Index.ets              # 演示切换语言 + RTL + 格式化的完整页面
└── entryability/
    └── EntryAbility.ets

entry/src/main/resources/
├── base/element/string.json   # 兜底
├── zh_CN/element/string.json  # 简体中文
├── en/element/string.json     # 英文
└── ar/element/string.json     # 阿拉伯语(RTL)

几条落地经验,建议你直接带进项目:

第一,文案零硬编码。 任何面向用户的字符串都进 string.json,代码只引用 name。这让你新增语言时只加目录、不动逻辑。

第二,语言切换双管齐下。setAppPreferredLanguage 写入应用偏好(保证 $r 长期一致),同时用"按语言取 ResourceManager + @State"保证本次切换立即刷新。

第三,RTL 用 Direction.Auto 打底。 布局尽量用相对约束(spacelayoutWeightalignItems),避免写死左右边距;箭头类图标用 isRTL 单独替换资源。

第四,格式化统一走 i18n。 日期/货币/数字一律用 DateTimeFormat / NumberFormat,绝不手拼。资源用语言码,格式化用语言-地区码,两者用 toLocale 桥接。

第五,占位符注意语序。 不同语言形容词、量词的位置不同,带参数的句子优先用 %s/%d 占位,并在渲染处按本语言语序填充,而不是在代码里写死"主语+谓语"顺序。


收尾

国际化不是给字符串加个翻译那么简单,它是一条从资源分层(限定符)、运行时解析(ResourceManager)、动态切换(@State 驱动)、方向适配(RTL / Direction.Auto)到本地化格式化(i18n.DateTimeFormat / NumberFormat)的完整链路。鸿蒙 NEXT 的 @kit.LocalizationKit 与 ArkUI 已经把这条链路的基础打得足够扎实,剩下的就是我们在工程里把约定立好、把边界处理好。

把语言"请"出代码、"交"给资源系统,你的应用才能真正做到一次开发、全球落地。

Logo

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

更多推荐