鸿蒙 ArkTS 国际化实战全攻略:多语言切换、格式本地化与 RTL 布局一步到位
应用"出海"、多语言用户群、政府项目合规......凡是做过这些需求的开发者都踩过同一个坑:在主流框架里搞国际化(i18n)往往是加几个 JSON 文件的事,但在鸿蒙 HarmonyOS 里,你要同时搞定 资源系统 、运行时语言切换 、日期 / 数字格式化 和 RTL(从右到左)布局 四件事,少一件都是残缺品。
本文基于 HarmonyOS 5.0(API Level 12),带你把这四件事一次性搞清楚。
一、鸿蒙国际化资源系统:不止是翻译文件
鸿蒙的 i18n 资源不靠 JSON,靠的是 限定符目录(Qualifier Directory)。
1.1 目录结构规范
entry/src/main/resources/
├── base/ # 默认资源(必须存在)
│ ├── element/
│ │ └── string.json # 默认字符串
│ └── profile/
├── zh_CN/ # 简体中文
│ └── element/
│ └── string.json
├── en_US/ # 英文(美国)
│ └── element/
│ └── string.json
├── ar/ # 阿拉伯语(RTL)
│ └── element/
│ └── string.json
└── dark/ # 也可以是主题限定符
注意 :目录名必须是 BCP 47 语言标签,如 zh_CN、en_US、ar,不能用 zh(会识别失败)。
1.2 string.json 格式
json
// base/element/string.json
{
"string": [
{ "name": "app_name", "value": "我的应用" },
{ "name": "home_title", "value": "首页" },
{ "name": "welcome_msg", "value": "欢迎,%s!" },
{ "name": "item_count", "value": "共 %d 件商品" }
]
}
json
// en_US/element/string.json
{
"string": [
{ "name": "app_name", "value": "My App" },
{ "name": "home_title", "value": "Home" },
{ "name": "welcome_msg", "value": "Welcome, %s!" },
{ "name": "item_count", "value": "%d items in total" }
]
}
1.3 在 ArkTS 中引用资源
typescript
// 静态引用(编译期确定)
Text($r('app.string.home_title'))
.fontSize(20)
// 带参数占位符(使用 $rawfile 无效,需要用格式化工具)
import { resourceManager } from '@kit.LocalizationKit';
// 获取带参数的字符串
const rm = getContext(this).resourceManager;
const msg = rm.getStringSync($r('app.string.welcome_msg').id, '斌哥');
// 输出:"欢迎,斌哥!" 或 "Welcome, 斌哥!"
二、运行时动态切换语言
这是大多数教程一笔带过、实际开发中最容易翻车的部分。
2.1 系统语言 vs 应用内切换
HarmonyOS 有两种国际化方案:
| 方案 | 适用场景 | 切换生效方式 |
|---|---|---|
| 跟随系统语言 | 大多数 App | 自动,无需代码 |
| 应用内独立切换 | 出海应用、企业应用 | 需要手动更新配置 |
本节重点讲应用内独立切换。
2.2 实现语言切换服务
typescript
// src/main/ets/service/I18nService.ets
import { i18n } from '@kit.LocalizationKit';
import { preferences } from '@kit.ArkData';
const PREF_KEY = 'app_language';
export class I18nService {
private static instance: I18nService;
private store: preferences.Preferences | null = null;
static getInstance(): I18nService {
if (!I18nService.instance) {
I18nService.instance = new I18nService();
}
return I18nService.instance;
}
// 初始化:从持久化存储读取用户语言设置
async init(context: Context): Promise<void> {
this.store = await preferences.getPreferences(context, 'i18n_prefs');
const savedLang = this.store.getSync(PREF_KEY, '') as string;
if (savedLang) {
this.applyLanguage(savedLang);
}
}
// 获取当前应用语言
getCurrentLanguage(): string {
return i18n.System.getSystemLanguage();
}
// 获取支持的语言列表
getSupportedLanguages(): Array<{ code: string; label: string }> {
return [
{ code: 'zh-Hans', label: '简体中文' },
{ code: 'zh-Hant', label: '繁體中文' },
{ code: 'en', label: 'English' },
{ code: 'ar', label: 'العربية' },
{ code: 'ja', label: '日本語' },
];
}
// 切换语言(核心方法)
async switchLanguage(langCode: string): Promise<void> {
// 1. 持久化保存
if (this.store) {
this.store.putSync(PREF_KEY, langCode);
await this.store.flush();
}
// 2. 应用到运行时
this.applyLanguage(langCode);
}
private applyLanguage(langCode: string): void {
// 设置应用级别语言覆盖
i18n.System.setAppPreferredLanguage(langCode);
// ⚠️ 注意:这里只改变了资源查找路径
// UI 需要重建才能生效(见下节)
}
}
2.3 语言切换后的 UI 刷新策略
坑点来了 :setAppPreferredLanguage 改变的是资源查找逻辑,但已渲染的组件不会自动重新渲染!
解决方案有三种,选其一:
typescript
// 方案 A:重启 UIAbility(体验最差,但最彻底)
import { common } from '@kit.AbilityKit';
const context = getContext(this) as common.UIAbilityContext;
context.terminateSelf();
// 配合 autoStartupManager 实现重启,不推荐
// 方案 B:全局状态驱动重渲染(推荐 ✅)
// AppStorage 作为全局语言状态
AppStorage.setOrCreate('currentLang', 'zh-Hans');
// 切换时
AppStorage.set('currentLang', newLang);
// 组件中监听
@StorageProp('currentLang') currentLang: string = 'zh-Hans';
// 方案 C:NavPathStack 重推页面(适合 Navigation 架构)
this.navStack.replacePath({ name: 'HomePage' });
推荐方案 B 的完整实现:
typescript
// 语言设置页面
@Entry
@Component
struct LanguageSettingPage {
@StorageProp('currentLang') @Watch('onLangChanged') currentLang: string = 'zh-Hans';
private i18nService = I18nService.getInstance();
private onLangChanged(): void {
// 触发当前页面的文本刷新
// 通过重新绑定 $r() 实现
}
build() {
Column({ space: 12 }) {
Text($r('app.string.language_setting'))
.fontSize(18)
.fontWeight(FontWeight.Bold)
ForEach(this.i18nService.getSupportedLanguages(), (lang: { code: string; label: string }) => {
Row() {
Text(lang.label).fontSize(16).layoutWeight(1)
Radio({ value: lang.code, group: 'lang' })
.checked(this.currentLang === lang.code)
.onChange(async (isChecked: boolean) => {
if (isChecked) {
await this.i18nService.switchLanguage(lang.code);
AppStorage.set('currentLang', lang.code);
}
})
}
.width('100%')
.padding({ left: 16, right: 16, top: 8, bottom: 8 })
.backgroundColor(Color.White)
.borderRadius(8)
})
}
.width('100%')
.padding(16)
}
}
三、日期、时间与数字的本地化格式化
这块是真正体现"国际化专业度"的地方,CSDN 上 90% 的教程都只停在翻译文本这一步。
3.1 日期格式化
typescript
import { intl } from '@kit.LocalizationKit';
export class DateFormatter {
// 根据当前语言环境格式化日期
static formatDate(date: Date, style: 'short' | 'medium' | 'long' = 'medium'): string {
const locale = i18n.System.getSystemLocale(); // 如 "zh-Hans-CN"
const formatter = new intl.DateTimeFormat(locale, {
dateStyle: style
});
return formatter.format(date);
}
// 格式化日期+时间
static formatDateTime(date: Date): string {
const locale = i18n.System.getSystemLocale();
const formatter = new intl.DateTimeFormat(locale, {
dateStyle: 'medium',
timeStyle: 'short'
});
return formatter.format(date);
}
// 相对时间("3分钟前"、"昨天")
static formatRelative(date: Date): string {
const locale = i18n.System.getSystemLocale();
const rtf = new intl.RelativeTimeFormat(locale, { numeric: 'auto' });
const diffMs = date.getTime() - Date.now();
const diffSec = Math.round(diffMs / 1000);
const diffMin = Math.round(diffSec / 60);
const diffHour = Math.round(diffMin / 60);
const diffDay = Math.round(diffHour / 24);
if (Math.abs(diffSec) < 60) return rtf.format(diffSec, 'second');
if (Math.abs(diffMin) < 60) return rtf.format(diffMin, 'minute');
if (Math.abs(diffHour) < 24) return rtf.format(diffHour, 'hour');
return rtf.format(diffDay, 'day');
}
}
// 使用示例
const now = new Date();
console.log(DateFormatter.formatDate(now, 'long'));
// zh-Hans: "2026年5月6日"
// en-US: "May 6, 2026"
// ar: "٦ مايو ٢٠٢٦"
console.log(DateFormatter.formatRelative(new Date(Date.now() - 180000)));
// zh-Hans: "3分钟前"
// en-US: "3 minutes ago"
3.2 数字与货币格式化
typescript
export class NumberFormatter {
// 数字格式(带千分位分隔符)
static formatNumber(value: number, decimals: number = 0): string {
const locale = i18n.System.getSystemLocale();
return new intl.NumberFormat(locale, {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals
}).format(value);
}
// 货币格式
static formatCurrency(value: number, currency: string = 'CNY'): string {
const locale = i18n.System.getSystemLocale();
return new intl.NumberFormat(locale, {
style: 'currency',
currency: currency,
currencyDisplay: 'symbol'
}).format(value);
}
// 百分比
static formatPercent(value: number): string {
const locale = i18n.System.getSystemLocale();
return new intl.NumberFormat(locale, {
style: 'percent',
maximumFractionDigits: 1
}).format(value);
}
}
// 使用示例
NumberFormatter.formatNumber(1234567.89, 2);
// zh-Hans: "1,234,567.89"
// de-DE: "1.234.567,89"(德语用点分千位、逗号分小数)
NumberFormatter.formatCurrency(299.00, 'CNY');
// zh-Hans: "¥299.00"
// en-US: "CN¥299.00"
NumberFormatter.formatPercent(0.856);
// zh-Hans: "85.6%"
// ar: "٨٥٫٦٪"(阿拉伯数字)
四、RTL(从右到左)布局适配
适配阿拉伯语、希伯来语等 RTL 语言是很多开发者的噩梦。鸿蒙提供了相对优雅的支持方案。
4.1 自动 RTL 镜像
typescript
@Component
struct ProductCard {
build() {
Row() {
// 商品图片
Image($r('app.media.product_img'))
.width(80)
.height(80)
.borderRadius(8)
// 商品信息
Column({ space: 4 }) {
Text($r('app.string.product_name')).fontSize(16)
Text($r('app.string.product_price')).fontSize(14).fontColor('#FF6B35')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.padding({ left: 12 }) // ⚠️ 这里有坑,见下方说明
// 购买按钮
Button($r('app.string.buy_now'))
.width(80)
}
.width('100%')
.padding(16)
// 关键:使用 direction 属性自动处理 RTL
.direction(this.isRTL ? Direction.Rtl : Direction.Auto)
}
// 判断当前是否为 RTL 语言
private get isRTL(): boolean {
const lang = i18n.System.getSystemLanguage();
const rtlLangs = ['ar', 'he', 'fa', 'ur'];
return rtlLangs.some(l => lang.startsWith(l));
}
}
⚠️ 关键坑点 :使用固定方向 padding(paddingLeft/paddingRight)在 RTL 下会出错,应改用逻辑方向:
typescript
// ❌ 错误:固定物理方向
.padding({ left: 12, right: 0 }) // RTL 下应该是 right: 12
// ✅ 正确:使用逻辑方向(start = LTR时左,RTL时右)
.padding({ start: LengthMetrics.vp(12), end: LengthMetrics.vp(0) })
4.2 图标镜像
方向性图标(箭头、返回键等)在 RTL 下需要镜像翻转:
typescript
@Component
struct BackButton {
build() {
Image($r('app.media.ic_arrow_left'))
.width(24)
.height(24)
// 阿拉伯语下水平翻转箭头图标
.scale({ x: this.isRTL ? -1 : 1, y: 1 })
.onClick(() => {
router.back();
})
}
private get isRTL(): boolean {
return i18n.isRTL(i18n.System.getSystemLanguage());
}
}
4.3 封装全局 RTL 工具
typescript
// src/main/ets/utils/RTLUtils.ets
import { i18n } from '@kit.LocalizationKit';
export class RTLUtils {
private static _isRTL: boolean | null = null;
static get isRTL(): boolean {
if (RTLUtils._isRTL === null) {
RTLUtils._isRTL = i18n.isRTL(i18n.System.getSystemLanguage());
}
return RTLUtils._isRTL;
}
// 语言切换后清除缓存
static reset(): void {
RTLUtils._isRTL = null;
}
// 获取文本对齐方向
static get textAlign(): TextAlign {
return RTLUtils.isRTL ? TextAlign.End : TextAlign.Start;
}
// 获取 Flex 主轴方向
static get flexDirection(): FlexDirection {
return RTLUtils.isRTL ? FlexDirection.RowReverse : FlexDirection.Row;
}
// 镜像 Scale(用于方向性图标)
static get mirrorScale(): ScaleOptions {
return { x: RTLUtils.isRTL ? -1 : 1, y: 1 };
}
}
五、综合实战:多语言商品详情页
把上面所有技巧整合到一个真实场景里:
typescript
import { i18n, intl, resourceManager } from '@kit.LocalizationKit';
import { RTLUtils } from '../utils/RTLUtils';
import { DateFormatter, NumberFormatter } from '../utils/Formatters';
interface Product {
name: string;
price: number;
currency: string;
stock: number;
listingDate: Date;
rating: number;
}
@Entry
@Component
struct ProductDetailPage {
@StorageProp('currentLang') currentLang: string = 'zh-Hans';
private product: Product = {
name: 'ArkTS 开发实战课程',
price: 299,
currency: 'CNY',
stock: 1234,
listingDate: new Date('2026-01-15'),
rating: 0.964
};
build() {
Scroll() {
Column({ space: 16 }) {
// 商品图片
Image($r('app.media.product_banner'))
.width('100%')
.aspectRatio(1.78)
.borderRadius(12)
// 价格区域(RTL 自适应)
Row() {
Text(NumberFormatter.formatCurrency(this.product.price, this.product.currency))
.fontSize(28)
.fontColor('#FF6B35')
.fontWeight(FontWeight.Bold)
Text(NumberFormatter.formatPercent(this.product.rating))
.fontSize(14)
.fontColor('#52C41A')
.backgroundColor('#F6FFED')
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
.borderRadius(4)
.margin({ start: LengthMetrics.vp(8) }) // ← 逻辑方向
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.direction(RTLUtils.isRTL ? Direction.Rtl : Direction.Auto)
// 商品元信息
Column({ space: 8 }) {
this.buildInfoRow($r('app.string.label_stock'),
NumberFormatter.formatNumber(this.product.stock) + ' ' + $r('app.string.unit_pcs'))
this.buildInfoRow($r('app.string.label_listed'),
DateFormatter.formatDate(this.product.listingDate, 'long'))
this.buildInfoRow($r('app.string.label_updated'),
DateFormatter.formatRelative(new Date()))
}
.width('100%')
.padding(16)
.backgroundColor(Color.White)
.borderRadius(12)
// 购买按钮
Button($r('app.string.buy_now'))
.width('100%')
.height(50)
.fontSize(18)
.fontColor(Color.White)
.backgroundColor('#FF6B35')
.borderRadius(12)
.onClick(() => {
this.handlePurchase();
})
}
.padding(16)
// 整页 RTL 方向
.direction(RTLUtils.isRTL ? Direction.Rtl : Direction.Auto)
}
.scrollable(ScrollDirection.Vertical)
}
@Builder
buildInfoRow(label: Resource, value: string) {
Row() {
Text(label)
.fontSize(14)
.fontColor('#8C8C8C')
Text(value)
.fontSize(14)
.fontColor('#262626')
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.direction(RTLUtils.isRTL ? Direction.Rtl : Direction.Auto)
}
private handlePurchase(): void {
// 购买逻辑
}
}
六、常见踩坑速查表
| 坑位 | 错误写法 | 正确写法 |
|---|---|---|
| 目录名格式 | zh / en |
zh_CN / en_US |
| 带参数字符串 | $r() 直接传参 |
rm.getStringSync(id, ...args) |
| 切换后不刷新 | 只调 setAppPreferredLanguage |
同时更新 AppStorage 触发重渲染 |
| RTL padding | paddingLeft: 12 |
start: LengthMetrics.vp(12) |
| 方向性图标 | 硬编码左箭头 | .scale({ x: isRTL ? -1 : 1 }) |
| 货币格式 | 手动拼 "¥" + price |
intl.NumberFormat + style:'currency' |
| 日期格式 | date.toLocaleString() |
intl.DateTimeFormat(locale).format() |
| RTL 判断 | 手写语言列表 | i18n.isRTL(lang) 官方 API |
总结
鸿蒙 ArkTS 的国际化能力是完备的,但坑也相当集中:
- 资源目录命名要用 BCP 47 完整标签,不能省略地区代码
- 运行时语言切换需要配合 AppStorage 驱动 UI 重渲染
- 日期/数字/货币格式化 必须用
intl模块,别手写 - RTL 布局 要全程使用逻辑方向属性(
start/end),避免物理方向(left/right) i18n.isRTL(lang)是官方提供的 RTL 判断 API,不要自己维护语言列表
做好这五件事,你的鸿蒙应用基本能打通全球化需求。下一步可以探索 复数规则 (intl.PluralRules)和 排序规则 (intl.Collator),对于新闻类、社区类 App 来说很有价值。
如有疑问欢迎评论区交流,点赞收藏是最大的鼓励 🚀