多语言真正难在哪里

为 HarmonyOS App 增加英语和日语资源并不难,真正容易出问题的是这些场景:

  • 用户在系统语言不变的情况下,只切换应用语言;
  • 切换后部分 $r 文案更新,部分运行时字符串仍是旧语言;
  • 默认习惯以中文写进磁盘,切到日文后依然显示中文;
  • 新增一个资源 Key,只补了两种语言;
  • 桌面应用名称和应用内语言不一致;
  • 杀进程重启后,语言选择丢失。

因此我把多语言拆成三层:

  1. 资源层:静态文案和应用名称;
  2. 设置层:用户选择的语言偏好;
  3. 数据层:哪些值应该稳定保存,哪些值应该按当前语言展示。

一、资源目录如何组织

当前项目使用三套资源:

entry/src/main/resources/
├─ base/element/string.json
├─ zh_CN/element/string.json
└─ ja_JP/element/string.json

其中:

  • base 保存英文,作为基础资源;
  • zh_CN 保存简体中文;
  • ja_JP 保存日文。

应用级名称则位于 AppScope/resources 下,对应英文 MoodMemoir、中文“心晴手记”和日文“こころ日記”。

每个语言文件必须拥有相同的资源 Key。例如:

{
  "string": [
    { "name": "today", "value": "今日" },
    { "name": "history", "value": "历史" },
    { "name": "insights", "value": "统计" }
  ]
}

页面可以通过资源引用或运行时名称获取:

Text($r('app.string.today'))
private named(name: string): string {
  try {
    return this.context.resourceManager.getStringByNameSync(name);
  } catch (_) {
    return name;
  }
}

第二种方式适合资源名称来自模型定义的情况,例如不同心情类型各自保存 resourceName

二、保存的是语言偏好,不是当前翻译结果

设置模型定义四种选择:

export enum LanguagePreference {
  SYSTEM = 'SYSTEM',
  CHINESE = 'CHINESE',
  ENGLISH = 'ENGLISH',
  JAPANESE = 'JAPANESE'
}

默认值是跟随系统:

languagePreference: LanguagePreference.SYSTEM

随后把设置转换为语言标签:

export function languageTag(
  preference: LanguagePreference
): string {
  switch (preference) {
    case LanguagePreference.CHINESE:
      return 'zh-Hans-CN';
    case LanguagePreference.ENGLISH:
      return 'en';
    case LanguagePreference.JAPANESE:
      return 'ja';
    default:
      return '';
  }
}

这里持久化的是“用户想用哪种语言”,而不是某一刻系统解析出来的语言。这样用户选择 English 后,即使系统仍是中文,应用重启也能恢复 English。

三、启动时先应用语言,再展示依赖资源的内容

项目加载顺序如下:

async aboutToAppear(): Promise<void> {
  await appRepository.initialize(this.context);
  const state: PersistedState = await appRepository.load();

  this.applyLanguagePreference(
    state.settings.languagePreference
  );

  this.settings = state.settings;
  this.moodEntries = state.moodEntries;
  this.habits = state.habits;
  this.completions = state.habitCompletions;
  this.loadTodayDraft();
  this.loaded = true;
}

应用语言偏好的函数为:

private applyLanguagePreference(
  preference: LanguagePreference
): void {
  const tag: string = languageTag(preference);
  i18n.System.setAppPreferredLanguage(
    tag.length > 0
      ? tag
      : i18n.System.getFirstPreferredLanguage()
  );
}

先设置首选语言,再让页面进入完整显示状态,可以减少启动时短暂出现错误语言的概率。

四、为什么切换后选择 restartApp

语言选择改变时,项目先保存设置,然后应用首选语言:

const next: AppSettings = copySettings(this.settings);
next.languagePreference = preference;
this.settings = next;

await this.persist();
this.applyLanguagePreference(preference);

理论上可以尝试让当前页面所有组件原地刷新,但复杂页面可能同时包含:

  • $r() 资源引用;
  • ResourceManager 动态获取的字符串;
  • 已经生成的日期格式;
  • Builder 中缓存的显示内容;
  • 依赖语言的默认习惯名称。

为了避免出现半中半英,项目调用 restartApp()

const want: Want = {
  bundleName: 'com.zhouyajie.moodbloomharmony',
  abilityName: 'EntryAbility'
};

this.context.getApplicationContext().restartApp(want);

因为偏好已经先持久化,重启后的冷启动会直接进入新语言。

这是稳定性优先的选择。对于页面结构较小、状态管理完全可控的应用,也可以研究不重启的动态刷新方案,但需要逐项验证所有资源来源。

五、默认数据不要保存翻译后的名字

多语言最容易被忽略的是“初始化到本地的数据”。

如果第一次启动时把“早睡”直接保存到 Habit.name,那么用户切换到英语后,磁盘里仍是“早睡”。

项目为默认习惯定义稳定名称:

export const STARTER_HABITS: StarterHabit[] = [
  {
    resourceName: 'starter_sleep',
    storedName: 'sleep-early',
    legacyNames: ['早睡', 'Sleep early', '早寝'],
    icon: 'moon_z',
    color: '#5E7CE2'
  }
];

写入时保存 storedName

name: definition.storedName

展示时再查找本地化资源:

private localizedHabitName(habit: Habit): string {
  const starter = this.starterDefinition(habit.name);
  return starter
    ? this.named(starter.resourceName)
    : habit.name;
}

这样可以同时满足:

  • 默认习惯跟随应用语言;
  • 用户自定义名称保持原样;
  • legacyNames 兼容早期版本曾经保存过的翻译文本。

这个模式也适用于分类名、内置标签、模板名称等任何“系统预置但会进入持久化”的内容。

六、日期和星期也要使用当前 Locale

不要把星期写死成“日一二三四五六”。项目使用当前应用语言生成:

private localeTag(): string {
  return i18n.System
    .getAppPreferredLanguage()
    .replace('_', '-');
}
new Date(timestampMillis).toLocaleDateString(
  this.localeTag(),
  {
    year: 'numeric',
    month: 'short',
    day: 'numeric',
    weekday: 'short'
  }
);

星期标题同样通过日期格式化生成,而不是手写三份数组。这样可以自然获得不同语言的文字和排列习惯。

七、用脚本阻止漏翻译进入构建

人工维护三个资源文件,很容易新增 Key 时漏掉一种语言。

项目中的验证脚本会收集三套资源 Key,排序后比较:

for (const locale of ['base', 'zh_CN', 'ja_JP']) {
  const strings = readJson(
    `entry/src/main/resources/${locale}/element/string.json`
  ).string;

  const names = strings.map((item) => item.name);
  const sortedNames = names.slice().sort();
  // 与 base 的 Key 集合比较
}

它还会检查重复 Key 和 AppScope 中的应用名称。这样“资源文件不完整”会在发布前变成可见错误,而不是交给某个语言用户发现。

八、建议覆盖的测试路径

多语言至少应该测试:

  1. 中文系统首次启动,默认跟随系统;
  2. 中文切 English,自动重启后全部变成英文;
  3. 杀进程重开,仍保持 English;
  4. English 切日本語;
  5. 默认习惯随语言变化;
  6. 自定义习惯名称保持原样;
  7. 日期、星期、统计文案使用同一种语言;
  8. 隐私政策和支持页面跳转到对应语言版本;
  9. 字体变长后按钮没有截断;
  10. 三套资源 Key 完全一致。

总结

HarmonyOS 多语言的关键并不是翻译文件本身,而是处理好三种边界:

  • 资源边界:所有静态文案拥有一致 Key;
  • 设置边界:保存用户语言偏好,并在启动时恢复;
  • 数据边界:稳定内部值与本地化显示值分离。

在复杂页面中,保存偏好后重启应用虽然不够“炫”,却能用较低复杂度换来一致结果。再配合自动资源校验和多语言回归路径,三语支持才算真正可维护。

本文案例来自“心晴手记(MoodMemoir / こころ日記)”HarmonyOS 版。

心晴手记下载地址

Logo

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

更多推荐