“把中文换成英文就万事大吉?”——鸿蒙国际化适配到底要做哪些看不见的功夫?
·
我是兰瓶Coding,一枚刚踏入鸿蒙领域的转型小白,原是移动开发中级,如下是我学习笔记《零基础学鸿蒙》,若对你所有帮助,还请不吝啬的给个大大的赞~
前言
实话讲,我第一次给 HarmonyOS 做多语言时,心里那个轻松:“翻译下文案不就完了?” 三天后被德语长句、阿语 RTL、复数规则、货币格式、图片带字、分布式多端一致性轮番教育,才知道——国际化不是锦上添花,而是地基工程。这篇就来把 鸿蒙国际化适配(i18n) 从设计到工程一次掰开揉碎,有原则、有工具、有代码、能落地;看完你就能少掉两周坑。🙂
一、先立规矩:语言≠地区≠脚本
- 语言 Language:
en、zh、ar… - 地区 Region:
US、GB、CN、SA…(日期/货币/单位各不相同) - 脚本 Script:
Hans(简体)、Hant(繁体)、Latn(拉丁)、Cyrl(西里尔)
反问一句:“en 就够用了吧?”
不够。en-US的日期是Apr 05, 2026,en-GB是05 Apr 2026;货币符号位置也会变。别偷懒。
二、资源怎么放:目录、命名、回退策略一次定死
1)目录结构(示例)
/resources
/base/element/string.json # 默认词条(建议英文或中文,二选一要统一)
/base/media/ # 与语言无关的图
/en_US/element/string.json
/zh_Hans_CN/element/string.json
/zh_Hant_TW/element/string.json
/ar_SA/element/string.json
/en_US/media/hero.png # 语言相关图片(如带字图)
2)词条命名(别再 title1/title2 了)
- 规则:
模块_页面_用途[_状态],如account_login_title、order_detail_empty_desc。 - 占位符全部命名参数:
{user}、{count},拒绝{0}{1}。
{
"string": [
{ "name": "app_name", "value": "Roaming Notes" },
{ "name": "welcome", "value": "Welcome, {user}!" },
{ "name": "items_one", "value": "{count} item left" },
{ "name": "items_other", "value": "{count} items left" },
{ "name": "delete_confirm", "value": "Delete “{name}”?" }
]
}
3)优雅回退
- 匹配顺序:
lang_Script_Region → lang_Script → lang → base。 - 永不崩溃:缺词条用 key 展示并埋点;必要时回退英文。
三、ArkTS 实战:应用内切换语言(不依赖系统设置)
很多产品要“应用内语言切换”。做法:自建 i18n 管理器 + 资源读取 + 全局状态刷新。
// /common/i18n/I18n.ts
import resourceManager from '@ohos.resourceManager';
export type Locale = { language: string; region?: string; script?: string };
export class I18n {
private rm?: resourceManager.ResourceManager;
private static _cur: Locale = { language: 'en', region: 'US' };
private cache = new Map<string, string>();
async init(ctx: UIAbilityContext, locale?: Locale) {
this.rm = (ctx as any).resourceManager;
if (locale) I18n._cur = locale;
this.cache.clear();
}
setLocale(locale: Locale) { I18n._cur = locale; this.cache.clear(); }
get locale(): Locale { return I18n._cur; }
async t(key: string, params?: Record<string, string | number>): Promise<string> {
if (this.cache.has(key)) return this.format(this.cache.get(key)!, params);
const v = await this.tryGet(key);
this.cache.set(key, v);
return this.format(v, params);
}
private async tryGet(key: string): Promise<string> {
if (!this.rm) return key;
// 新版 SDK 可直接传 locale;老版可手工选择目录,本处演示逻辑
try { return await this.rm.getStringByName(key, I18n._cur as any); }
catch { try { return await this.rm!.getStringByName(key); } catch { return key; } }
}
private format(tpl: string, params?: Record<string, string | number>): string {
if (!params) return tpl;
return tpl.replace(/\{(\w+)\}/g, (_, k) => String(params[k] ?? ''));
}
}
export const i18n = new I18n();
页面侧糖封装(同步体验):
// /common/i18n/useT.ts
import { i18n } from './I18n';
export function tSync(key: string, params?: Record<string, string|number>): string {
// 轻量同步:命中缓存直接返回;miss 时先返回 key,再异步刷新状态
return (i18n as any).cache?.get(key) ?? key.replace(/_/g, ' ');
}
使用示例:
// /pages/Settings.ets
import { i18n } from '../common/i18n/I18n';
@Entry
@Component
struct SettingsPage {
@State localeText: string = 'en-US';
async aboutToAppear() {
await i18n.init(getContext(this), { language: 'en', region: 'US' });
}
build() {
Column({ space: 12 }) {
Text('Language').fontSize(20).fontWeight(FontWeight.Bold)
Row() {
Button('English').onClick(() => this.switchTo({ language: 'en', region: 'US' }))
Button('简体中文').onClick(() => this.switchTo({ language: 'zh', script: 'Hans', region: 'CN' }))
Button('العربية').onClick(() => this.switchTo({ language: 'ar', region: 'SA' }))
}
Text(this.localeText)
// 示例文案
Text($rawfile('icon_info') ? '' : '') // 占位行,仅示意可混合资源
}.padding(24)
}
private async switchTo(loc) {
i18n.setLocale(loc)
this.localeText = `${loc.language}${loc.script ? '-' + loc.script : ''}${loc.region ? '-' + loc.region : ''}`
// 刷新页面:实际可结合全局状态/路由重建根组件
}
}
四、复数 / 性别 / 条件:别让英语俄语阿语“将就中文语序”
1)轻量复数策略(one / other)
function pluralKey(cnt: number, locale: string) {
if (locale.startsWith('en')) return cnt === 1 ? 'one' : 'other';
if (locale.startsWith('zh')) return 'other';
// 可按 CLDR 规则扩展 ru、ar …
return 'other';
}
async function itemsText(cnt: number) {
const key = `items_${pluralKey(cnt, i18n.locale.language)}`;
return await i18n.t(key, { count: cnt });
}
2)ICU Message(进阶玩法)
如果你接入 ICU 消息解析(或自写简化版),可以写:
"{count, plural, one{# item left} other{# items left}}"
同理还有 select 支持性别:{gender, select, male{He} female{She} other{They}} …
五、日期 / 数字 / 货币 / 单位:请交给 @ohos.intl
手搓字符串是灾难:千分位、货币符号位置、小数点、负号、比例格式都不一样。
import intl from '@ohos.intl';
export const fmt = {
date(ts: number, locale: string) {
return new intl.DateTimeFormat(locale, { year:'numeric', month:'short', day:'2-digit' }).format(new Date(ts));
},
currency(v: number, currency: 'USD'|'EUR'|'CNY', locale: string) {
return new intl.NumberFormat(locale, { style:'currency', currency }).format(v);
},
number(v: number, locale: string, max=2) {
return new intl.NumberFormat(locale, { maximumFractionDigits: max }).format(v);
}
}
// 单位示例:千米↔英里
export function kmOrMi(km: number, locale: string) {
const useMile = ['US','GB','LR','MM'].includes(i18n.locale.region ?? '');
const v = useMile ? km * 0.621371 : km;
const unit = useMile ? 'mi' : 'km';
return `${fmt.number(v, locale)} ${unit}`;
}
六、RTL(从右到左):不仅是“把 UI 镜像一下”
- 布局属性用逻辑方向:
paddingStart/paddingEnd、marginStart/marginEnd,少用left/right。 - 图标要镜像:返回箭头、进度箭头、播放/快进符号注意方向;徽标数字位置也要随方向切换。
- 入场动效:LTR 习惯从左入场,RTL 建议从右入场;动效保持克制(≤300ms)。
// 小工具:根据语言自动切换 start/end 的间距
function inset(start: number, end: number) {
const rtl = i18n.locale.language === 'ar' || i18n.locale.language === 'he';
return rtl ? { start: end, end: start } : { start, end };
}
Row() {
Image($r('app.media.icon_back'))
Text('Title').fontSize(18)
}.padding(inset(16, 8))
七、图片 / 图标 / 颜色:非文字资源也“需要会外语”
- 带字图片:放语言子目录;能用矢量图 + 文本叠加就别把字烤进 PNG。
- 颜色含义:红色在西方常警示,但在节庆语境是喜庆;情境不同,文案/颜色搭配要过目。
- 插图与暗色模式:准备浅/深两套或使用着色滤镜,别让暗色下“白底刺眼”。
八、分布式一致:手机↔平板↔手表↔车机的“同一种语言”
- 语言偏好同步:把
locale存到分布式 KV,多端自动跟随(允许用户手动覆盖)。 - 小屏文案:手表/车机不适合长句,同 key 提供
_compact版本。 - 接力场景:任务从手机接力到平板,保持 locale 一致,避免“跳语言”。
// 伪代码:KV 同步语言
import distributedKVStore from '@ohos.data.distributedKVStore';
kv.put('i18n/locale', JSON.stringify(i18n.locale));
// 其他设备监听 'i18n/locale',调用 i18n.setLocale() 刷新
九、性能与包体:让多语言“吃不胖、跑得快”
- 按需装载:主打市场语言随包,长尾语言用在线增量包(下载+SHA 校验+缓存)。
- 词典缓存:i18n 管理器内存缓存 + 冷启动预热(≤50ms)。
- 资源去重:相同图标别在每个语言目录复制粘贴。
- 容错兜底:缺词条显示 key 并埋点;媒体资源失败回退通用图。
十、测试与上架 Checklist:不翻车靠习惯
1)伪本地化(Pseudo-Localization)
一键把文本拉长、加变体字符,提前暴露布局问题。
// /scripts/pseudo_localize.ts(Node)
import fs from 'fs';
const map: Record<string, string> = { a:'á', e:'ë', i:'ï', o:'ô', u:'ü', A:'Å', E:'Ê', I:'Ï', O:'Ø', U:'Û' };
function pseudo(s: string) { return '[!! ' + s.replace(/[A-Za-z]/g, ch => map[ch] || ch) + ' !!]'; }
const j = JSON.parse(fs.readFileSync('resources/base/element/string.json','utf-8'));
j.string = j.string.map((x: any) => ({ ...x, value: pseudo(x.value) }));
fs.writeFileSync('resources/pseudo/element/string.json', JSON.stringify(j, null, 2));
console.log('pseudo done.');
2)覆盖用例
- 应用内切换语言,页面、弹层、路由标题同步刷新
- 复数/性别/条件规则正确(≥3 种语言抽测)
- 日期、货币、数字、单位格式符合地区习惯
- RTL:导航、列表、返回箭头、入场动效自然
- 离线:无网加载词典与图片是否回退
- 分布式:多端语言一致,断网重连不丢
- 性能:切语言重建 ≤ 300ms;首屏 i18n 预热 ≤ 50ms
十一、可直接拿走的工程脚手架
harmony-i18n-kit/
├─ common/i18n/
│ ├─ I18n.ts # 管理器(缓存、回退、切换)
│ ├─ useT.ts # 同步 t() 包装
│ └─ plural.ts # CLDR 规则扩展点
├─ resources/
│ ├─ base/element/string.json
│ ├─ en_US/element/string.json
│ ├─ zh_Hans_CN/element/string.json
│ ├─ ar_SA/element/string.json
│ └─ pseudo/element/string.json
├─ scripts/
│ ├─ extract_keys.ts # 扫描 $t('key') 导出缺失清单
│ ├─ check_missing.ts # 校验各语言键集合一致
│ └─ pseudo_localize.ts # 伪本地化生成
└─ pages/
└─ Settings.ets
extract_keys.ts 思路(片段):
// 用简单正则从 .ets / .ts 里找 $t('key');严谨可用 AST
const pat = /\$t\(\s*['"`]([a-z0-9_\.]+)['"`]/ig;
十二、常见“秒翻车”清单(我先替你踩了)
- 把日期/货币手拼字符串 → 千分位、货币符号、负号样式全错
- 词条硬编码在组件里 → 翻译永远漏
- 按钮固定宽度 → 德语/芬兰语直接爆布局
- 箭头不镜像 → RTL 中“返回”像“前进”
- 只存语言不存地区 → en-US 用户看到 en-GB 日期
- 多端不同步 → 手机改英语,手表还中文
尾巴压个重点:国际化是“持续工程”,不是“一次翻译”
在鸿蒙里把 i18n 打磨到位,你会发现:令牌化设计、逻辑方向布局、标准化格式化、复数/性别模板、分布式一致与兜底回退,一旦“工程化”,后续加语言就是搬砖而不是重建。下次再有人说“把中文改成英文就好了吧”,你就笑笑:“试试德语和阿语先?” 😉
…
(未完待续)
更多推荐



所有评论(0)