鸿蒙跨平台开发实战:从 ResourceManager 到动态切换,彻底搞定国际化与多语言

开篇:国际化是跨端落地的"最后一公里"
当我们把应用从一台手机推向全球市场,或者从国内版本扩展到海外版本时,真正棘手的往往不是业务逻辑,而是那些"理所当然"的本地差异:一句问候语、一种货币符号、一个从右往左阅读的文字方向。
很多团队在国际化上踩过的坑,本质上是把"文案"当成了字符串硬编码。结果是一旦要新增语种,就要满工程搜 $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
注意目录名 zh、zh_CN、en、ar 就是限定符,不需要在文件里再声明语言。系统匹配时遵循"越精确优先级越高"的原则:zh_CN 会覆盖 zh,zh 会覆盖 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 的协作
静态跟随系统已经能解决大部分场景,但要做"应用内语言切换",我们必须先学会在运行时读语言和取资源。i18n 与 resourceManager 都来自 @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 适配的"坑"值得单独点出:
第一,不要手动镜像坐标。当 direction 为 Rtl 时,ArkUI 会自动把 Row 的主轴翻转、padding/margin 的左右含义也跟着翻转。如果你在代码里写死 margin: { left: 12 } 想"对齐左边",在 RTL 下反而会错。应优先用 space、layoutWeight、alignItems 这类相对约束,而不是绝对左右值。
第二,图标与图片要谨慎镜像。返回箭头"‹"在 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.DateTimeFormat 与 i18n.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-CN、en-US、ar-EG),而我们的资源限定符目录只用 zh、en、ar。所以在格式化时要补上地区码,而在取资源时只用语言码——两者要分别处理,不要混用。
/**
* 把资源语言码(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 打底。 布局尽量用相对约束(space、layoutWeight、alignItems),避免写死左右边距;箭头类图标用 isRTL 单独替换资源。
第四,格式化统一走 i18n。 日期/货币/数字一律用 DateTimeFormat / NumberFormat,绝不手拼。资源用语言码,格式化用语言-地区码,两者用 toLocale 桥接。
第五,占位符注意语序。 不同语言形容词、量词的位置不同,带参数的句子优先用 %s/%d 占位,并在渲染处按本语言语序填充,而不是在代码里写死"主语+谓语"顺序。
收尾
国际化不是给字符串加个翻译那么简单,它是一条从资源分层(限定符)、运行时解析(ResourceManager)、动态切换(@State 驱动)、方向适配(RTL / Direction.Auto)到本地化格式化(i18n.DateTimeFormat / NumberFormat)的完整链路。鸿蒙 NEXT 的 @kit.LocalizationKit 与 ArkUI 已经把这条链路的基础打得足够扎实,剩下的就是我们在工程里把约定立好、把边界处理好。
把语言"请"出代码、"交"给资源系统,你的应用才能真正做到一次开发、全球落地。
更多推荐




所有评论(0)