HarmonyOS应用《民族图鉴》开发第97篇:国际化实战——多语言/多区域/多时区全适配

📖 引言
经过上一篇的多端适配,我们的「民族图鉴」已经能在手机、平板、折叠屏等多种设备上流畅运行了。但要让更多人用得上、用得好,还有一项重要工作要做——国际化。
你可能会问:
- 「民族图鉴」是介绍中国民族文化的,主要用户是中国人,需要国际化吗?
- 国际化不就是翻译一下文字吗?有那么难吗?
- 除了语言,还有什么需要国际化的?
- 日期、时间、数字、货币,这些怎么适配不同地区?
- 阿拉伯语、希伯来语这种从右往左写的语言,怎么适配?
- 翻译质量怎么保证?怎么管理多语言资源?
这些问题都很好。很多开发者对国际化的理解还停留在"把文字翻译成英文"的层面。但实际上,国际化是一个系统工程——它涉及语言文字、日期时间、数字货币、布局方向、文化习俗等方方面面。一个细节没做好,就可能让海外用户觉得"这个应用不是为我做的"。
本文将从为什么需要国际化讲起,系统讲解国际化的完整知识体系,并以「民族图鉴」英文版实现为实战案例,带你从零完成多语言适配。读完本文,你将掌握国际化的核心方法论。
🎯 学习目标
完成本文后,你将能够:
- ✅ 理解国际化的重要性与完整范围
- ✅ 掌握字符串资源、图片资源、布局资源的国际化管理
- ✅ 学会翻译管理流程与质量保证方法
- ✅ 掌握日期时间国际化:格式、时区、相对时间
- ✅ 理解数字与货币格式化的要点
- ✅ 掌握 RTL(从右到左)布局适配方法
- ✅ 实战实现「民族图鉴」英文版完整适配
- ✅ 了解国际化的常见问题与解决方案
💡 需求分析
为什么需要国际化?
在动手之前,我们先想清楚:「民族图鉴」是介绍中国56个民族的应用,主要用户应该是中国人吧?为什么还要做国际化?
1. 出海是必然趋势
中国的应用出海,已经不是"选做题",而是"必答题"了:
- 文化输出:民族文化是中华文化的瑰宝,值得让全世界了解
- 用户增长:海外市场有更多的用户,更多的机会
- 品牌价值:一个支持多语言的应用,品牌形象更国际化
- 政策支持:国家鼓励文化出海,讲好中国故事
「民族图鉴」作为一个介绍中国民族文化的应用,天然具有出海的潜力。外国人对中国的56个民族、丰富多彩的民族文化,同样充满好奇。
2. 国内也有多语言需求
不要以为只有出海才需要国际化。就算只做国内市场,也有多语言需求:
- 少数民族用户:藏族、维吾尔族、蒙古族等少数民族,可能更习惯用本民族语言
- 港澳台用户:繁体中文用户
- 在华外国人:在中国生活、工作、学习的外国人
- 学习中文的外国人:对中国文化感兴趣,想学中文的外国人
3. 国际化要趁早
很多人觉得"先做好中文版,以后再加英文"。但经验告诉我们:国际化越晚做,成本越高。
早期做国际化:写代码的时候就考虑,成本增加 10-20%
后期做国际化:代码写完了再改,成本增加 50-100%
还可能改出 bug,影响已有功能
为什么后期改贵?因为:
- 硬编码的字符串散落在各处,要一个个找出来
- 有些写法天生不支持国际化,要重写
- 布局是按中文长度设计的,英文更长就溢出了
- 没有统一的规范,每个人写的都不一样
💡 最佳实践:国际化从项目第一天就开始做。哪怕暂时只支持中文,也要按国际化的方式来写。这样以后加语言,成本就很低了。
国际化的范围
国际化(Internationalization,简称 i18n,因为 i 和 n 之间有 18 个字母)不只是翻译文字。它的范围很广:
国际化(i18n)全范围
│
├─ 语言文字
│ ├── 界面文本翻译
│ ├── 图片中的文字
│ ├── 字体(不同语言用不同字体)
│ ├── 排版规则(换行、空格、标点)
│ └── 书写方向(LTR / RTL)
│
├─ 日期时间
│ ├── 日期格式(YYYY-MM-DD / DD/MM/YYYY / MM/DD/YYYY)
│ ├── 时间格式(24小时制 / 12小时制)
│ ├── 时区处理(UTC / 本地时间)
│ ├── 星期起始日(周日 / 周一)
│ └── 相对时间("刚刚"、"5分钟前")
│
├─ 数字与货币
│ ├── 千分位分隔符(, / . / 空格)
│ ├── 小数点符号(. / ,)
│ ├── 货币符号(¥ / $ / €)
│ ├── 货币符号位置(前缀 / 后缀)
│ └── 数字格式(阿拉伯数字 / 其他数字系统)
│
├─ 布局与设计
│ ├── 文字长度变化(德文、俄文往往更长)
│ ├── RTL 布局(阿拉伯语、希伯来语)
│ ├── 图片和图标(有些文化有禁忌)
│ └── 颜色含义(不同文化颜色含义不同)
│
└─ 文化与合规
├── 文化习俗(节日、礼仪、禁忌)
├── 法律法规(隐私、数据、内容审查)
└── 内容适配(不同地区内容可能有差异)
看到了吧?国际化的范围比你想的广多了。翻译文字只是最基础的一步。
💡 L10n vs i18n:经常有人把这两个搞混。
- i18n(Internationalization):国际化,指让应用具备支持多语言多区域的能力(技术工作)
- L10n(Localization):本地化,指针对某个具体地区做适配(翻译、内容调整等)
简单说:i18n 是打基础,L10n 是具体落地。
「民族图鉴」国际化方案
了解了范围,我们来制定「民族图鉴」的国际化方案。不是一上来就支持几十种语言,而是有规划、有步骤地来。
语言规划
第一阶段(当前):
├── 中文简体(zh-CN):默认语言,最完善
└── 英文(en):面向海外用户和英语学习者
第二阶段(v2.0):
├── 中文繁体(zh-TW / zh-HK):港澳台用户
├── 日文(ja):日本市场
└── 韩文(ko):韩国市场
第三阶段(v3.0):
├── 西班牙文(es)
├── 法文(fr)
├── 德文(de)
└── 更多...
为什么先做英文?因为:
- 英文是国际通用语,覆盖最广
- 很多非英语国家的人也能看懂英文
- 英文翻译资源最丰富,机器翻译质量也最好
- 先把英文做好,再扩展其他语言成本低
区域规划
区域(Locale)和语言不完全是一回事。同一种语言,在不同地区可能有差异:
中文:
├── zh-CN:中国大陆(简体)
├── zh-TW:中国台湾(繁体)
├── zh-HK:中国香港(繁体)
└── zh-SG:新加坡(简体)
英文:
├── en-US:美国英语
├── en-GB:英国英语
├── en-CA:加拿大英语
└── en-AU:澳大利亚英语
「民族图鉴」的策略:
- 第一阶段:语言级别区分(中文 / 英文),不区分地区
- 第二阶段:再细分地区(比如简体繁体分开,美式英式分开)
- 原则:先粗后细,逐步完善
功能范围
不是所有功能都要第一时间国际化。我们按优先级排个序:
P0(必须有):
├── 所有界面文本
├── 按钮、菜单、标签
├── 错误提示、加载状态
└── 设置中的语言切换
P1(应该有):
├── 民族名称的翻译
├── 民族介绍的翻译
└── 日期时间格式化
P2(可以后面加):
├── 图片中的文字
├── TTS 语音播报的多语言
├── 音乐歌词的翻译
├── AI 对话的多语言
└── RTL 布局适配
为什么民族介绍是 P1 而不是 P0?因为民族介绍的翻译量很大(56个民族,每个几千字),翻译成本高、周期长。可以先把界面翻译了,内容翻译逐步来。
💡 国际化是个渐进的过程。不要想着"一次做完美"。先把框架搭好,把最核心的翻译了,再逐步完善。框架搭好了,加语言只是加翻译资源的事。
🛠️ 核心实现
步骤1:资源管理——字符串资源国际化
国际化的基础是资源管理。不要把字符串硬编码在代码里,要放到资源文件里,不同语言有不同的资源文件。
1.1 鸿蒙的多语言资源体系
鸿蒙的资源管理是基于限定词的。不同限定词的目录下放不同语言的资源,系统会自动选择合适的:
resources/
├── base/ # 默认资源(找不到匹配的就用这个)
│ ├── element/
│ │ └── string.json
│ ├── media/
│ └── ...
│
├── en/ # 英文资源
│ └── element/
│ └── string.json
│
├── zh_CN/ # 简体中文资源
│ └── element/
│ └── string.json
│
├── zh_TW/ # 繁体中文资源
│ └── element/
│ └── string.json
│
├── ar/ # 阿拉伯语资源
│ └── element/
│ └── string.json
│
└── ...
💡 资源匹配机制:系统会根据当前语言设置,从最匹配的目录找资源。找不到就 fallback 到上一级,最后到 base。所以 base 里一定要有默认资源。
1.2 string.json 的格式
string.json 的格式很简单,就是一个 JSON 数组,每个元素是一个字符串资源:
{
"string": [
{
"name": "app_name",
"value": "民族图鉴"
},
{
"name": "tab_home",
"value": "首页"
},
{
"name": "tab_encyclopedia",
"value": "百科"
},
{
"name": "search_placeholder",
"value": "搜索民族、节日、美食..."
}
]
}
每个字符串有两个字段:
name:资源名,代码里用这个引用value:资源值,实际显示的文字
1.3 代码中引用字符串资源
在 ArkTS 中,用 $r('app.string.xxx') 来引用字符串资源:
// 方式1:直接在组件中用
Text($r('app.string.app_name'))
.fontSize(20)
// 方式2:在代码逻辑中用
import { StringResource } from '@kit.ArkUI';
// 获取字符串的值
const appName = getContext(this).resourceManager.getStringSync(
$r('app.string.app_name')
);
⚠️ 注意:
$r()是一个资源引用对象,不是字符串本身。要拿到字符串的值,需要用 ResourceManager 的 getString 方法。
1.4 带参数的字符串
有时候字符串里需要插入变量,比如"你好,张三"、“共 56 个民族”。这时候要用格式化字符串:
// string.json
{
"string": [
{
"name": "greeting",
"value": "你好,%s!"
},
{
"name": "ethnic_count",
"value": "共 %d 个民族"
},
{
"name": "pop_rank",
"value": "人口排名:第 %1$d 位(共 %2$d 个)"
}
]
}
格式化符号:
%s:字符串%d:整数%f:浮点数%1$s:第 1 个参数,字符串类型(指定位置)
代码中使用:
// 方式1:在组件中用
Text($r('app.string.greeting', '张三'))
// 显示:你好,张三!
Text($r('app.string.ethnic_count', 56))
// 显示:共 56 个民族
// 方式2:在代码中格式化
const str = getContext(this).resourceManager.getStringSync(
$r('app.string.pop_rank'),
3, // 第 1 个参数:第 3 位
56 // 第 2 个参数:共 56 个
);
// 显示:人口排名:第 3 位(共 56 个)
💡 为什么要用 %1$s 这种形式? 因为不同语言的语序可能不一样。比如中文说"我吃苹果",英文说 “I eat apples”,语序一样。但有些语言的语序差异很大,参数顺序可能不同。用位置参数,翻译的时候可以调整顺序。
1.5 「民族图鉴」的字符串资源规范
为了让资源文件好维护,我们定一些规范:
命名规范:
页面_模块_描述
比如:
- home_search_placeholder:首页搜索框占位符
- detail_favorite_add:详情页添加收藏
- settings_theme_title:设置页主题标题
分组规范:
- 按页面分组,同一个页面的字符串放在一起
- 通用的字符串(确认、取消、加载中…)放最前面
- 按字母顺序或功能顺序排列
注释规范:
- 有歧义的字符串加注释,告诉翻译者上下文
- 比如 “bank” 是"银行"还是"河岸"?要有注释
步骤2:资源管理——图片与布局国际化
不只是文字,图片和布局有时候也需要国际化。
2.1 图片资源国际化
如果图片里有文字,或者图片内容有文化差异,就需要多语言版本:
resources/
├── base/
│ └── media/
│ ├── logo.png
│ ├── banner.png
│ └── ...
│
├── en/
│ └── media/
│ ├── banner.png # 英文版的 banner(图中文字是英文)
│ └── ...
│
└── ar/
└── media/
├── banner.png # 阿拉伯语版的 banner(文字是阿拉伯语,从右往左)
└── ...
什么时候需要多语言图片?
- 图片中包含文字(比如 banner、引导页)
- 图片内容有文化差异(比如手势、符号、颜色)
- 图片上的 UI 元素(比如带文字的按钮图)
什么时候不需要?
- 纯照片、插图(没有文字,文化中性)
- 图标(通用图标,没有文字)
「民族图鉴」的情况:
- 民族服饰照片:不需要翻译,文化中性
- Logo、banner 图:如果有文字,需要翻译
- 图标:大部分不需要,少数可能需要调整
💡 最佳实践:尽量让图片不带文字,用文字叠加在图片上的方式。这样就不用为每种语言做一套图了。
2.2 布局资源国际化(RTL)
有些语言是从右往左写的,比如阿拉伯语、希伯来语、波斯语。这时候整个界面都要镜像翻转:
LTR(从左到右)布局: RTL(从右到左)布局:
┌───────────────────┐ ┌───────────────────┐
│ ← 返回 标题 + │ │ + 标题 返回 → │
├───────────────────┤ ├───────────────────┤
│ 图标 文字 > │ │ < 文字 图标 │
├───────────────────┤ ├───────────────────┤
│ 内容... │ │ ...内容 │
│ │ │ │
└───────────────────┘ └───────────────────┘
好在鸿蒙的 ArkUI 对 RTL 有很好的支持。大部分情况下,你只要用对了属性,系统会自动适配:
会自动镜像的属性:
marginLeft/marginRight→ 自动换成marginStart/marginEndpaddingLeft/paddingRight→ 自动换成paddingStart/paddingEndleft/right(定位)→ 自动换成start/endTextAlign.Start/TextAlign.End→ 自动调整
建议的写法:
// ❌ 不好:用 left/right,RTL 时不会自动翻转
Text('内容')
.padding({ left: 16, right: 16 })
.textAlign(TextAlign.Left)
// ✅ 好:用 start/end,RTL 时自动翻转
Text('内容')
.padding({ left: 16, right: 16 }) // 其实 left/right 在 ArkUI 中也会自动适配
.textAlign(TextAlign.Start) // 用 Start 更语义化
RTL 适配的关键点:
-
用 Start/End 代替 Left/Right
- 语义更清晰:start 是"阅读开始的方向",end 是"结束的方向"
- LTR 时 start=left, end=right
- RTL 时 start=right, end=left
-
水平布局方向自动翻转
- Row 布局的方向会自动翻转
- 第一个子组件在 LTR 时在左边,RTL 时在右边
-
图片和图标需要注意
- 有方向的箭头、指示图标,需要 RTL 版
- 可以用
rotateY(180deg)翻转,或者准备两套图
// RTL 时自动翻转图标
Image(arrowIcon)
.rotate({
angle: isRTL() ? 180 : 0,
centerX: '50%',
centerY: '50%'
})
- 布局方向检测
// 检测当前是否是 RTL 布局
import { I18nUtil } from '@kit.ArkUI';
function isRTL(): boolean {
const locale = I18nUtil.getSystemLocale();
// 阿拉伯语、希伯来语、波斯语等是 RTL
const rtlLangs = ['ar', 'he', 'fa', 'ur'];
return rtlLangs.includes(locale.language);
}
💡 RTL 适配建议:如果你的应用暂时不需要支持 RTL 语言,可以先不考虑。但写代码的时候养成用 Start/End 的习惯,以后要支持 RTL 就容易多了。
步骤3:「民族图鉴」国际化服务实现
前面我们已经有了一个基础的 I18nService,现在来完善它,让它支持完整的国际化功能。
3.1 语言枚举与存储
首先,扩展语言枚举,支持更多语言选项:
// models/EnumModels.ets
export enum AppLanguage {
ZH_CN = 'zh_CN', // 简体中文
ZH_TW = 'zh_TW', // 繁体中文
EN = 'en', // 英文
JA = 'ja', // 日文
KO = 'ko' // 韩文
}
// 语言显示名称映射
export const LanguageDisplayNames: Record<AppLanguage, { native: string; english: string }> = {
[AppLanguage.ZH_CN]: { native: '简体中文', english: 'Chinese (Simplified)' },
[AppLanguage.ZH_TW]: { native: '繁體中文', english: 'Chinese (Traditional)' },
[AppLanguage.EN]: { native: 'English', english: 'English' },
[AppLanguage.JA]: { native: '日本語', english: 'Japanese' },
[AppLanguage.KO]: { native: '한국어', english: 'Korean' }
};
3.2 I18nService 完善
然后完善 I18nService,增加更多功能:
// services/I18nService.ets
import { AppLanguage, LanguageDisplayNames } from '../models/EnumModels';
import { StorageService } from './StorageService';
import { StorageConstants } from '../common/constants/StorageConstants';
export class I18nService {
private static instance: I18nService;
private currentLanguage: AppLanguage = AppLanguage.ZH_CN;
private listeners: ((lang: AppLanguage) => void)[] = [];
private constructor() {}
public static getInstance(): I18nService {
if (!I18nService.instance) {
I18nService.instance = new I18nService();
}
return I18nService.instance;
}
// 初始化:从存储加载设置,没有就用系统语言
public async init(): Promise<void> {
const savedLang = await StorageService.getInstance().getString(
StorageConstants.KEY_APP_LANGUAGE,
'' // 默认空,表示跟随系统
);
if (savedLang) {
// 用户手动设置过语言
const lang = this.validateLanguage(savedLang);
this.currentLanguage = lang;
} else {
// 跟随系统语言
this.currentLanguage = this.getSystemLanguage();
}
AppStorage.setOrCreate<AppLanguage>('currentAppLanguage', this.currentLanguage);
this.notifyListeners();
}
// 校验语言是否合法,防止脏数据
private validateLanguage(lang: string): AppLanguage {
const validLangs: AppLanguage[] = Object.values(AppLanguage);
if (validLangs.includes(lang as AppLanguage)) {
return lang as AppLanguage;
}
// 不合法就返回默认
return AppLanguage.ZH_CN;
}
// 获取系统语言
private getSystemLanguage(): AppLanguage {
try {
// 获取系统首选语言列表
// 实际项目中用 I18nUtil.getSystemLanguages()
// 这里简化处理
return AppLanguage.ZH_CN;
} catch (e) {
return AppLanguage.ZH_CN;
}
}
// 设置语言
public async setLanguage(lang: AppLanguage): Promise<void> {
if (this.currentLanguage === lang) return;
this.currentLanguage = lang;
AppStorage.setOrCreate<AppLanguage>('currentAppLanguage', lang);
// 持久化存储
await StorageService.getInstance().saveString(
StorageConstants.KEY_APP_LANGUAGE,
lang
);
// 通知监听者
this.notifyListeners();
}
// 获取当前语言
public getLanguage(): AppLanguage {
return this.currentLanguage;
}
// 是否是中文
public isChinese(): boolean {
return this.currentLanguage === AppLanguage.ZH_CN
|| this.currentLanguage === AppLanguage.ZH_TW;
}
// 是否是 RTL 语言
public isRTL(): boolean {
const rtlLangs: string[] = ['ar', 'he', 'fa', 'ur'];
return rtlLangs.includes(this.currentLanguage);
}
// 获取语言显示名称(母语显示)
public getLanguageDisplayName(lang: AppLanguage): string {
return LanguageDisplayNames[lang]?.native || lang;
}
// 获取所有支持的语言列表
public getSupportedLanguages(): AppLanguage[] {
// 第一阶段先支持简中和英文
return [AppLanguage.ZH_CN, AppLanguage.EN];
}
// ===== 双语内容辅助方法 =====
// 这些方法用于从"平行字段"的数据结构中取对应语言的内容
// 比如民族数据中有 nameZh / nameEn 两个字段
// 根据语言取对应文本
public getText<T extends { zh: string; en: string }>(data: T): string {
return this.isChinese() ? data.zh : data.en;
}
// 获取民族名称(双语数据结构)
public getEthnicName(zhName: string, enName: string): string {
return this.isChinese() ? zhName : enName;
}
// 获取民族介绍(双语数据结构)
public getEthnicDescription(zhDesc: string, enDesc: string): string {
return this.isChinese() ? zhDesc : enDesc;
}
// ===== 监听 =====
public addListener(listener: (lang: AppLanguage) => void): void {
this.listeners.push(listener);
}
public removeListener(listener: (lang: AppLanguage) => void): void {
const index = this.listeners.indexOf(listener);
if (index > -1) {
this.listeners.splice(index, 1);
}
}
private notifyListeners(): void {
this.listeners.forEach(listener => listener(this.currentLanguage));
}
}
3.3 平行字段设计
「民族图鉴」的核心数据是民族信息,这些内容需要支持多语言。我们采用平行字段的设计:
// models/EthnicModels.ets
export interface EthnicGroup {
id: string;
pinyin: string; // 拼音,搜索用,不需要翻译
population: number; // 人口数,数字,不需要翻译
popRank: number; // 人口排名,数字,不需要翻译
mainRegion: string; // 主要分布地区(可能需要翻译)
// ===== 平行字段:每种语言一个字段 =====
name: string; // 名称(多语言:nameZh / nameEn?)
// ... 更多字段
}
等等,这里有个设计决策:是每种语言一个字段,还是用对象结构?
// 方案1:平行字段
interface EthnicGroup {
nameZh: string;
nameEn: string;
descZh: string;
descEn: string;
// ... 每个需要翻译的字段都有 zh/en 两个版本
}
// 方案2:嵌套对象
interface EthnicGroup {
name: {
zh: string;
en: string;
};
desc: {
zh: string;
en: string;
};
// ... 每个需要翻译的字段都是一个多语言对象
}
两种方案各有优劣:
| 方案 | 优点 | 缺点 |
|---|---|---|
| 平行字段 | 结构扁平,访问简单 | 字段多,有点乱 |
| 嵌套对象 | 结构清晰,每个字段的多语言是一组 | 访问要多一层 |
「民族图鉴」选择的是平行字段方案,因为:
- 访问简单,直接
ethnic.nameZh就行 - 列表展示时经常只要一种语言,拿起来就用
- 增加语言的时候只要加字段,不用改结构
💡 数据设计的原则:国际化不只是界面的事,数据层也要支持。从一开始设计数据模型的时候,就要考虑多语言的问题。不要等中文数据都写好了,再加英文——那时候改数据结构成本很高。
步骤4:日期时间国际化
日期时间是国际化的重灾区。不同地区的人,日期格式、时间格式、星期起始日,都不一样。
4.1 日期格式差异
中国大陆:2026-06-29 或 2026年6月29日 (年-月-日)
美国:06/29/2026 或 June 29, 2026 (月/日/年)
英国:29/06/2026 或 29 June 2026 (日/月/年)
日本:2026/06/29 或 令和8年6月29日 (年/月/日)
看到了吧,光是日期格式就有好几种,搞反了月和日就全错了。
4.2 鸿蒙的日期格式化
鸿蒙提供了 @kit.IntlKit 来做国际化的日期时间格式化:
import { dateTimeFormat } from '@kit.IntlKit';
// 创建格式化器
const formatter = new dateTimeFormat.DateTimeFormat(
'zh-CN', // locale
{
year: 'numeric',
month: 'long',
day: 'numeric',
weekday: 'long'
}
);
// 格式化日期
const date = new Date(2026, 5, 29); // 注意:月份从 0 开始
const formatted = formatter.format(date);
// 中文:2026年6月29日 星期一
// 英文(en-US):Monday, June 29, 2026
// 英文(en-GB):Monday, 29 June 2026
常用的选项:
year: ‘numeric’(2026)/ ‘2-digit’(26)month: ‘numeric’(6)/ ‘2-digit’(06)/ ‘long’(六月/June)/ ‘short’(6月/Jun)day: ‘numeric’(29)/ ‘2-digit’(29)weekday: ‘long’(星期一/Monday)/ ‘short’(周一/Mon)hour: ‘numeric’ / ‘2-digit’minute: ‘numeric’ / ‘2-digit’second: ‘numeric’ / ‘2-digit’
4.3 「民族图鉴」的日期格式化工具
我们封装一个工具函数,方便使用:
// common/utils/DateUtils.ets
import { dateTimeFormat } from '@kit.IntlKit';
import { I18nService } from '../services/I18nService';
import { AppLanguage } from '../models/EnumModels';
export class DateUtils {
// 获取当前语言的 locale 字符串
private static getLocale(): string {
const lang = I18nService.getInstance().getLanguage();
switch (lang) {
case AppLanguage.ZH_CN: return 'zh-CN';
case AppLanguage.ZH_TW: return 'zh-TW';
case AppLanguage.EN: return 'en-US';
case AppLanguage.JA: return 'ja-JP';
case AppLanguage.KO: return 'ko-KR';
default: return 'zh-CN';
}
}
// 格式化日期(年月日)
static formatDate(date: Date): string {
const formatter = new dateTimeFormat.DateTimeFormat(
this.getLocale(),
{
year: 'numeric',
month: 'long',
day: 'numeric'
}
);
return formatter.format(date);
}
// 格式化日期时间(年月日 时分)
static formatDateTime(date: Date): string {
const formatter = new dateTimeFormat.DateTimeFormat(
this.getLocale(),
{
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
}
);
return formatter.format(date);
}
// 格式化时间(时分)
static formatTime(date: Date): string {
const formatter = new dateTimeFormat.DateTimeFormat(
this.getLocale(),
{
hour: '2-digit',
minute: '2-digit'
}
);
return formatter.format(date);
}
}
4.4 相对时间格式化
“刚刚”、“5分钟前”、“昨天”、“3天前”——这些相对时间也需要国际化。
鸿蒙也提供了相对时间格式化:
import { RelativeTimeFormat } from '@kit.IntlKit';
const rtf = new RelativeTimeFormat('zh-CN', { numeric: 'auto' });
rtf.format(-5, 'minute'); // 5分钟前
rtf.format(0, 'minute'); // 刚刚 (因为 numeric: 'auto')
rtf.format(-1, 'day'); // 昨天
rtf.format(-3, 'day'); // 3天前
rtf.format(2, 'week'); // 2周后
// 英文版本
const rtfEn = new RelativeTimeFormat('en-US', { numeric: 'auto' });
rtfEn.format(-5, 'minute'); // 5 minutes ago
rtfEn.format(0, 'minute'); // now
rtfEn.format(-1, 'day'); // yesterday
💡 相对时间的坑:相对时间看起来简单,其实有很多细节。比如"昨天"、“上周”、“上个月”,这些不同语言的表达方式差异很大。不要自己拼字符串,一定要用系统的格式化 API。
4.5 时区处理
如果你的应用有跨时区的需求(比如用户在不同时区,显示时间要对应当地时间),那就要注意时区问题。
基本原则:
- 存储和传输用 UTC:数据库存 UTC 时间,网络传输也用 UTC
- 显示用本地时间:展示给用户的时候,转成本地时间
// 后端返回的时间是 ISO 格式(UTC)
const utcTimeStr = '2026-06-29T12:00:00Z';
// 转成 Date 对象,自动根据系统时区显示
const date = new Date(utcTimeStr);
// 格式化显示(自动转成本地时间)
const localTimeStr = DateUtils.formatDateTime(date);
// 北京时区(UTC+8)显示:2026年6月29日 20:00
// 纽约时区(UTC-4)显示:June 29, 2026, 08:00
「民族图鉴」的时间场景不多,主要是收藏时间、浏览历史记录。用本地时间显示就可以了,不需要太多特殊处理。
步骤5:数字与货币格式化
数字和货币的格式也有地区差异。
5.1 数字格式差异
中文/英文:1,234,567.89 (千分位用逗号,小数点用点)
德文:1.234.567,89 (千分位用点,小数点用逗号)
法文:1 234 567,89 (千分位用空格,小数点用逗号)
印度:12,34,567.89 (不是每三位一分,前三位后每两位一分)
看到了吧,千分位分隔符、小数点符号,不同地区都不一样。
5.2 数字格式化
用系统的 NumberFormat:
import { NumberFormat } from '@kit.IntlKit';
// 格式化数字
const formatter = new NumberFormat('zh-CN');
formatter.format(1234567.89);
// 中文:1,234,567.89
const formatterDE = new NumberFormat('de-DE');
formatterDE.format(1234567.89);
// 德文:1.234.567,89
5.3 货币格式化
货币更复杂,因为不同国家有不同的货币,符号位置也不一样:
人民币:¥1,234.56 或 1,234.56元
美元:$1,234.56
欧元:1.234,56 € (欧洲很多国家符号在后面)
英镑:£1,234.56
日元:¥1,234 (没有小数位)
货币格式化:
import { NumberFormat } from '@kit.IntlKit';
// 人民币
const cnyFormatter = new NumberFormat('zh-CN', {
style: 'currency',
currency: 'CNY'
});
cnyFormatter.format(1234.56); // ¥1,234.56
// 美元
const usdFormatter = new NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
});
usdFormatter.format(1234.56); // $1,234.56
// 欧元
const eurFormatter = new NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR'
});
eurFormatter.format(1234.56); // 1.234,56 €
💡 「民族图鉴」的情况:我们的应用暂时没有支付功能,所以不需要货币格式化。但数字格式化还是需要的——比如人口数"12亿"、“1,200万”,不同语言的表达不一样。不过第一阶段我们先简单处理,以后再精细化。
步骤6:翻译管理流程
翻译不只是"找个人把中文翻成英文",它是一个完整的流程。
6.1 翻译工作流
一个规范的翻译流程应该是这样的:
提取 → 翻译 → 校验 → 合入 → 测试
│ │ │ │ │
│ │ │ │ └── 在 App 里实际看效果
│ │ │ │
│ │ │ └── 代码合入,打包
│ │ │
│ │ └── 检查翻译质量
│ │ · 术语统一
│ │ · 上下文正确
│ │ · 没有遗漏
│ │
│ └── 翻译人员翻译
│ · 人工翻译
│ · 或机器翻译 + 人工校对
│
└── 从代码中提取所有需要翻译的字符串
生成翻译资源文件
6.2 翻译质量保证
翻译质量很重要。翻译得不好,用户会觉得这个应用很业余。
常见的翻译问题:
-
术语不统一
- 比如"民族"有时候翻成 ethnic group,有时候翻成 nationality,有时候翻成 minority
- 用户看了会困惑
-
上下文缺失
- 翻译者不知道这个词用在什么地方
- 比如 “bank” 可能是"银行"也可能是"河岸"
- 短词特别容易出问题
-
机器翻译痕迹重
- 读起来很生硬,不像地道的表达
- 语法错误、用词不当
-
文化差异
- 有些说法在另一种文化里不合适
- 有些比喻、梗翻译过去人家不懂
怎么保证质量?
-
建立术语表
- 把核心术语列出来,规定统一译法
- 比如:
- 民族 → ethnic group
- 民族图鉴 → Ethnic Chronicles
- 收藏 → Favorite
- 百科 → Encyclopedia
-
提供上下文
- 给翻译者提供截图或说明
- 告诉他们这个字符串用在什么地方
- 是按钮?是标题?是正文?
-
机器翻译 + 人工校对
- 先用机器翻译出初稿(便宜、快)
- 再找母语者校对质量(保证地道)
- 性价比最高
-
在 App 里实际预览
- 翻译完了,在 App 里跑一遍
- 看看文字有没有溢出、对齐有没有问题
- 有些问题只有在实际界面里才能发现
💡 翻译质量的重要性:翻译质量直接影响用户对产品的感知。一个翻译很烂的应用,用户会默认它其他方面也很烂。宁愿少支持几种语言,也要把已有的语言做好。
6.3 「民族图鉴」的翻译策略
「民族图鉴」的翻译分两类,策略不同:
界面文本(P0):
- 量不大(几百条)
- 更新频繁(每次迭代可能加新功能)
- 策略:机器翻译 + 人工校对,开发人员自己就能搞定
内容文本(P1):
- 量大(56个民族,每个几千字,几十万字总量)
- 更新不频繁(民族介绍相对稳定)
- 策略:找专业翻译做,或者分阶段做(先翻译几个主要民族的)
步骤7:实战——「民族图鉴」英文版完整实现
理论讲了这么多,终于到实战了。我们来一步步把「民族图鉴」改成支持中英文切换。
7.1 准备英文 string.json
首先,我们需要一个英文版的字符串资源。项目里已经有了,我们来完善它:
// resources/en/element/string.json
{
"string": [
{
"name": "app_name",
"value": "Ethnic Chronicles"
},
{
"name": "module_desc",
"value": "Explore the diverse cultures of 56 ethnic groups in China"
},
{
"name": "tab_home",
"value": "Home"
},
{
"name": "tab_encyclopedia",
"value": "Encyclopedia"
},
{
"name": "tab_map",
"value": "Map"
},
{
"name": "tab_quiz",
"value": "Quiz"
},
{
"name": "tab_profile",
"value": "Profile"
},
{
"name": "search_placeholder",
"value": "Search ethnic groups, festivals, cuisines..."
},
{
"name": "loading",
"value": "Loading..."
},
{
"name": "no_data",
"value": "No data available"
},
{
"name": "retry",
"value": "Retry"
},
{
"name": "cancel",
"value": "Cancel"
},
{
"name": "confirm",
"value": "Confirm"
},
{
"name": "save",
"value": "Save"
},
{
"name": "share",
"value": "Share"
},
{
"name": "back",
"value": "Back"
},
{
"name": "settings_title",
"value": "Settings"
},
{
"name": "settings_theme",
"value": "Theme"
},
{
"name": "settings_language",
"value": "Language"
},
{
"name": "settings_font_size",
"value": "Font Size"
},
{
"name": "theme_light",
"value": "Light"
},
{
"name": "theme_dark",
"value": "Dark"
},
{
"name": "theme_system",
"value": "System Default"
},
{
"name": "lang_zh_cn",
"value": "简体中文"
},
{
"name": "lang_en",
"value": "English"
},
{
"name": "collection_title",
"value": "Favorites"
},
{
"name": "favorite_empty",
"value": "No favorites yet"
},
{
"name": "history_empty",
"value": "No browsing history"
},
{
"name": "detail_basic_info",
"value": "Basic Information"
},
{
"name": "detail_population",
"value": "Population"
},
{
"name": "detail_religion",
"value": "Religion"
},
{
"name": "detail_language_family",
"value": "Language Family"
},
{
"name": "detail_distribution",
"value": "Distribution"
},
{
"name": "detail_introduction",
"value": "Introduction"
},
{
"name": "detail_add_fav",
"value": "Add to Favorites"
},
{
"name": "detail_remove_fav",
"value": "Remove from Favorites"
},
{
"name": "detail_btn_read",
"value": "Read Aloud"
},
{
"name": "detail_btn_stop",
"value": "Stop"
},
{
"name": "list_page_title",
"value": "56 Ethnic Groups"
},
{
"name": "home_featured",
"value": "Featured"
},
{
"name": "home_all",
"value": "View All"
},
{
"name": "home_quick_access",
"value": "Quick Access"
},
{
"name": "map_title",
"value": "Distribution Map"
},
{
"name": "feedback_title",
"value": "Feedback"
},
{
"name": "feedback_placeholder",
"value": "Please share your feedback..."
},
{
"name": "feedback_submit",
"value": "Submit"
},
{
"name": "feedback_success",
"value": "Thank you for your feedback!"
},
{
"name": "trivia_daily",
"value": "Daily Trivia"
},
{
"name": "splash_title",
"value": "Ethnic Chronicles"
},
{
"name": "splash_subtitle",
"value": "Discover the rich cultures of 56 ethnic groups"
}
]
}
💡 翻译小贴士:界面上的按钮文字、菜单文字,要尽量简洁。英文往往比中文长,要预留足够的空间。
7.2 检查硬编码字符串
接下来,要检查代码里有没有硬编码的字符串(直接写在代码里的中文字符串)。
// ❌ 不好:硬编码
Text('收藏')
.fontSize(16)
Button('开始朗读')
.onClick(() => { ... })
// ✅ 好:用资源
Text($r('app.string.detail_add_fav'))
.fontSize(16)
Button($r('app.string.detail_btn_read'))
.onClick(() => { ... })
这一步很枯燥,但很重要。可以用工具来扫描,比如:
- 全局搜索正则表达式:
'[\u4e00-\u9fa5]+'找中文字符串 - 或者用 Lint 工具来检查
「民族图鉴」项目目前已经大部分用了资源引用,但可能还有遗漏。这是一个需要持续注意的点。
7.3 民族数据的英文版
民族数据是「民族图鉴」的核心内容。我们的数据模型已经支持双语了(平行字段),但 Mock 数据里可能只有中文。
我们来给几个主要民族加上英文数据:
// mock/EthnicMockData.ets (简化版)
export class EthnicMockData {
private static ethnics: EthnicGroup[] = [
{
id: '01',
name: '汉族',
nameEn: 'Han Chinese',
pinyin: 'hàn zú',
population: 1286311334,
popRank: 1,
mainRegion: '全国各地',
mainRegionEn: 'Nationwide',
languageFamily: '汉藏语系-汉语族',
languageFamilyEn: 'Sino-Tibetan - Sinitic',
religion: '多种信仰',
religionEn: 'Various beliefs',
description: '汉族是中国的主体民族...',
descriptionEn: 'The Han Chinese are the largest ethnic group in China...',
coverImage: '/common/rawfile/coverImage/01_han.jpg',
// ... 更多字段
},
{
id: '02',
name: '壮族',
nameEn: 'Zhuang',
// ...
},
// ... 其他民族
];
}
💡 内容翻译的策略:56 个民族,每个几千字,全翻译完工作量很大。可以分阶段:
- 第一阶段:先翻译 10 个主要民族(汉族、壮族、回族、满族…)
- 第二阶段:再翻译 20 个
- 第三阶段:全部翻译完
没翻译的怎么办?可以显示英文名称 + 中文介绍,或者提示"暂无英文版"。
7.4 语言切换功能
最后,在设置页加一个语言切换的功能:
// pages/SettingsPage.ets (简化版)
import { I18nService } from '../services/I18nService';
import { AppLanguage, LanguageDisplayNames } from '../models/EnumModels';
@Entry
@Component
struct SettingsPage {
@State currentLanguage: AppLanguage = AppLanguage.ZH_CN;
@State showLanguageDialog: boolean = false;
private i18nService: I18nService = I18nService.getInstance();
aboutToAppear(): void {
this.currentLanguage = this.i18nService.getLanguage();
// 监听语言变化
this.i18nService.addListener((lang: AppLanguage) => {
this.currentLanguage = lang;
});
}
aboutToDisappear(): void {
// 移除监听
}
build() {
Column() {
// 标题
Text($r('app.string.settings_title'))
.fontSize(20)
.fontWeight(FontWeight.Bold)
.padding(20)
// 设置项列表
List() {
// 语言设置
ListItem() {
this.buildSettingItem(
$r('app.string.settings_language'),
LanguageDisplayNames[this.currentLanguage].native,
() => {
this.showLanguageDialog = true;
}
)
}
// 主题设置
ListItem() {
// ...
}
// ... 其他设置项
}
.layoutWeight(1)
}
.width('100%')
.height('100%')
.backgroundColor('#F5F5F5')
}
@Builder
buildSettingItem(title: ResourceStr, value: string, onClick: () => void): void {
Row() {
Text(title)
.fontSize(16)
.fontColor('#333333')
.layoutWeight(1)
Text(value)
.fontSize(14)
.fontColor('#999999')
Image($r('app.media.arrow_right'))
.width(16)
.height(16)
.fillColor('#CCCCCC')
}
.width('100%')
.height(56)
.padding({ left: 20, right: 20 })
.backgroundColor('#FFFFFF')
.onClick(onClick)
}
// 语言选择弹窗
buildLanguageDialog(): void {
// 显示语言列表,点击切换
// ...
}
private async onLanguageSelected(lang: AppLanguage): Promise<void> {
await this.i18nService.setLanguage(lang);
this.showLanguageDialog = false;
// 切换语言后,可能需要刷新页面或重启应用
// 鸿蒙的资源系统会自动更新,但有些手动获取的字符串需要刷新
}
}
7.5 语言切换后的刷新问题
有个问题:切换语言后,界面上的文字会自动更新吗?
分两种情况:
-
用
$r('app.string.xxx')直接引用的:会自动更新。因为$r是响应式的,语言变了会自动重新加载资源。 -
在代码里手动获取的字符串:不会自动更新。因为已经取出来存到变量里了。
// ✅ 会自动更新
Text($r('app.string.settings_title'))
// ❌ 不会自动更新
@State title: string = '';
aboutToAppear(): void {
this.title = getContext(this).resourceManager.getStringSync(
$r('app.string.settings_title')
);
}
// 解决方法:监听语言变化,手动更新
// 或者尽量用 $r() 直接引用
最佳实践:
- 尽量直接在组件里用
$r()引用资源 - 少在代码逻辑里手动取字符串
- 如果必须手动取,记得监听语言变化,重新获取
💡 冷知识:为什么叫 i18n?因为 Internationalization 这个单词太长了,i 和 n 之间有 18 个字母,所以简称 i18n。类似的还有 k8s(Kubernetes)、a11y(Accessibility)等等。
⚠️ 常见问题与解决方案
问题1:文字溢出,显示不下
现象:中文显示正常,换成英文后文字太长,超出容器,被截断或换行。
为什么会这样?
- 英文普遍比中文长(尤其是德文、俄文更长)
- 同样的意思,中文可能 2-3 个字,英文要 10 几个字母
- 比如"设置"是 2 个字,"Settings"是 8 个字母
解决方法:
-
预留足够空间
- 按钮、标签不要给固定宽度,用自适应
- 预估一下最长的语言需要多少空间
-
允许换行
- 不要限制行数(除非设计要求)
- 用
.maxLines()配合.textOverflow()
-
用省略号
- 实在放不下,末尾显示省略号
.textOverflow({ overflow: TextOverflow.Ellipsis })
-
小字体
- 长文本可以适当减小字号(但不要太小,看不清)
-
调整布局
- 横排变竖排
- 两栏变一栏
💡 经验法则:设计界面的时候,给文字预留 30-50% 的额外空间。这样换成英文或其他语言,才不会溢出。
问题2:翻译不准确,意思不对
现象:翻译读起来很奇怪,或者意思完全不对。
常见原因:
| 原因 | 说明 | 解决方法 |
|---|---|---|
| 上下文缺失 | 翻译者不知道用在什么地方 | 给翻译者提供截图和上下文说明 |
| 术语不统一 | 同一个词有好几种译法 | 建立术语表,统一核心词汇 |
| 机器翻译直接用 | 没经过人工校对 | 机器翻译 + 人工校对 |
| 文化差异 | 有些说法直译过去很奇怪 | 找母语者做本地化,而不是直译 |
「民族图鉴」的经验:
- 专门建一个术语表文档,核心词汇统一译法
- 翻译完了,找英语好的人通读一遍
- 有疑问的地方标注出来,不要瞎猜
问题3:日期时间显示错误
现象:日期显示成了"月/日/年",但用户习惯"年/月/日"。
解决方法:
- 用系统的
DateTimeFormat,不要自己拼字符串 - 传对 locale 参数
- 测试的时候换不同的语言地区,看看格式对不对
常见坑:
- 月份从 0 开始(0=1月,11=12月),传错了就差一个月
- 时区不对,差了几个小时
- 相对时间的单位用错(应该用 day 却用了 date)
问题4:RTL 布局错乱
现象:切换到阿拉伯语,布局全乱了,有些元素还是从左到右。
常见原因:
- 用了固定的 left/right,没有用 start/end
- 自定义绘制的内容(Canvas)没有翻转
- 有方向的图标(箭头、指示)没翻转
解决方法:
- 全面检查代码,把 left/right 换成 start/end
- 有方向的图标,准备 RTL 版本或自动翻转
- 自定义绘制的内容,根据布局方向翻转坐标系
- 找一台 RTL 语言的设备或模拟器,实际测试
💡 RTL 测试建议:如果暂时不支持 RTL 语言,可以先不用测。但写代码的时候养成好习惯,以后省得改。
问题5:语言切换后有些页面没刷新
现象:切换语言后,当前页面更新了,但返回上一页,上一页还是旧语言。
原因:
- 上一页的字符串是在
aboutToAppear里取的,存在 @State 变量里 - 切回页面时,页面没有重新创建,所以没更新
解决方法:
- 尽量用
$r()直接引用(自动更新) - 如果必须手动取,在
onPageShow或aboutToAppear里重新取 - 或者用 AppStorage + @StorageLink 来响应语言变化
// 用 @StorageLink 监听语言变化
@StorageLink('currentAppLanguage') currentLanguage: AppLanguage = AppLanguage.ZH_CN;
// 用 computed 属性,每次读取都重新计算
get title(): string {
return getContext(this).resourceManager.getStringSync($r('app.string.title'));
}
// 但这样不是响应式的,语言变了不会自动更新
📝 本章小结
核心知识点
本文系统讲解了国际化的完整知识体系,并以「民族图鉴」英文版实现为实战案例,带你从零完成多语言适配。
1. 为什么需要国际化
- 出海是趋势,文化输出、用户增长、品牌价值
- 国内也有多语言需求(少数民族、港澳台、在华外国人)
- 国际化要趁早,越晚做成本越高
2. 国际化的范围
- 语言文字:界面文本、图片文字、字体、排版、书写方向
- 日期时间:格式、时区、相对时间、星期起始日
- 数字货币:千分位、小数点、货币符号与位置
- 布局设计:文字长度、RTL 布局、文化禁忌
- 文化合规:习俗、法律、内容差异
3. 资源管理
- 字符串资源:string.json,按语言分目录
- 带参数的字符串:%s、%d、%1$s(位置参数)
- 图片资源:有文字的图片需要多语言版本
- 布局资源:RTL 适配,用 start/end 代替 left/right
4. 日期时间国际化
- 不同地区日期格式差异大(年/月/日、月/日/年、日/月/年)
- 用系统的 DateTimeFormat,不要自己拼字符串
- 存储传输用 UTC,显示用本地时间
- 相对时间用 RelativeTimeFormat
5. 数字与货币格式化
- 千分位分隔符、小数点符号,地区差异大
- 货币符号位置、货币格式,各不相同
- 用 NumberFormat,不要自己格式化
6. 翻译管理
- 流程:提取 → 翻译 → 校验 → 合入 → 测试
- 质量保证:术语统一、上下文、人工校对、实际预览
- 策略:界面文本快迭代,内容文本分阶段
7. 「民族图鉴」实战
- 准备英文 string.json
- 检查并替换硬编码字符串
- 民族数据双语化(平行字段)
- 语言切换功能实现
- 注意语言切换后的刷新问题
最佳实践总结
✅ 从项目第一天就考虑国际化
不要等中文版做完了再加英文
↓
一开始就按国际化的方式写
↓
以后加语言成本很低
✅ 字符串全部资源化,不要硬编码
// ❌ 不好
Text('收藏')
// ✅ 好
Text($r('app.string.favorite'))
✅ 用系统的格式化 API,不要自己拼
// ❌ 不好
Text(`${year}年${month}月${day}日`)
// ✅ 好
Text(DateUtils.formatDate(date))
✅ 预留文字空间,防止溢出
英文比中文长 30-50%
德文、俄文可能更长
↓
设计的时候就留够空间
不要给固定宽度
✅ 术语统一,建立术语表
核心词汇先确定统一译法
↓
所有翻译都遵循术语表
↓
用户看到的术语是一致的
✅ 机器翻译 + 人工校对,性价比最高
机器翻译出初稿(快、便宜)
↓
母语者校对质量(地道、准确)
↓
既快又好,成本可控
下一步预告
在下一篇文章中,我们将:
- 🔒 了解为什么需要安全加固,以及应用安全的四个层次
- 🧩 学习代码混淆、资源混淆、加壳加固
- 🔐 掌握数据安全:加密存储、加密算法、密钥管理
- 🌐 了解网络安全:HTTPS、证书校验、防抓包
- 📋 学习隐私合规:权限最小化、用户授权、PIPL 合规
- 🧪 了解安全检测:静态扫描、渗透测试、漏洞修复
- 🚩 分析安全加固的常见问题与解决方案
🔗 相关链接
- 项目源码: GitCode 仓库
- 国际化开发指南: 官方文档
- 资源文件访问: 官方文档
- 多语言支持: 官方文档
- 日期时间格式化: 官方文档
💡 结语:国际化不只是"把文字翻译成英文",它是一种"尊重用户"的态度——不管用户说什么语言、在哪个地区、用什么书写方式,都能获得舒适、自然的使用体验。做好国际化,你的应用才能真正走向世界。下一篇,我们来聊一个同样重要的话题——安全加固。
更多推荐




所有评论(0)