鸿蒙国际化示例——多语言新闻阅读器 — 技术实现篇


一、业务需求(为什么这么设计)
产品要做一个面向全球用户的新闻客户端,第一个需求不是"能看新闻",而是"换一种语言,整个 App 都变":
- 顶部导航、分类 Tab、按钮文案随语言变化;
- 新闻标题、摘要、正文随语言变化——但内容团队不可能一次翻完全部语言;
- 发布时间显示"3 小时前"而不是"2026-08-19 14:30",且要符合当地语言习惯;
- 评论数"3 条评论"在英语里必须是"3 comments",单数时是"1 comment";
- 阅读量"12.8 万"在英语里是"128K"。
这些诉求在 demo 里可以各自为政,但在真实产品里必须一套状态、联动刷新。本文记录这个应用的完整技术方案,所有代码与 MultiLangSwitch.ets 一一对应。
二、总体架构
┌─ 语言层:LOCALES 元数据 + STRINGS 文案表 + t() 三级降级
├─ 内容层:NEWS_META(元数据)+ NEWS_ZH / NEWS_EN(正文,按语言分表)
├─ 格式化层:relTime / commentLabel / compactViews / fullTime(intl 四件套)
└─ 状态层:@StorageLink + PersistentStorage(语言持久化)
└─ @State activeTab / selectedId / showLangPanel(页面交互)
分层原则:语言层与内容层分离。UI 文案(“阅读”“评论”“返回”)属于语言层,属于 App 自己;新闻正文属于内容层,属于编辑团队。真实产品中这两套文案的维护节奏完全不同(UI 文案随版本发,内容随 CMS 发),从数据结构上分开是第一步。
数据流(语言切换后发生了什么)
用户点击语言卡片
→ this.currentLocale = 'en_US'
→ @StorageLink 写回 AppStorage(PersistentStorage 落盘)
→ 状态变化触发 build() 全树重渲染
→ 每个 Text 的文案走 this.T() → t(code, key)
→ 每条新闻走 newsContent(id, code) 取对应语言正文
→ 时间/评论/阅读量走 intl 四件套按新语言重算
一次点击,五个环节全部联动。这是"即时生效"的机制根源——不是手动刷新,而是状态驱动。
三、语言层:文案表与三级降级
3.1 文案表结构
const STRINGS: Map<string, Map<string, string>> = new Map([
['zh_CN', new Map([[K.appTitle, '今日新闻'], [K.tabTop, '头条'], /* ... */])],
['en_US', new Map([[K.appTitle, 'NewsPulse'], [K.tabTop, 'Top'], /* ... */])],
// ... 共 8 种
]);
文案 key 用显式接口 + 常量,而不是裸字符串,避免拼写错误散布全文件:
interface Keys { appTitle: string; tabTop: string; /* ... */ }
const K: Keys = { appTitle: 'appTitle', tabTop: 'tabTop', /* ... */ };
取用函数:
function t(code: string, key: string): string {
const v = STRINGS.get(code)?.get(key);
if (v !== undefined) return v;
return STRINGS.get(DEFAULT_LOCALE)?.get(key) ?? key; // 目标 → 默认 → key
}
三级降级保证"永不空白":漏翻的条目显示默认语言,连默认语言都没有则显示 key 本身,便于开发期发现。
3.2 内容层降级(与语言层不同的策略)
新闻正文用独立的两张表(NEWS_ZH / NEWS_EN),取用逻辑:
function newsContent(id: string, code: string): NewsContent {
const zh = NEWS_ZH.get(id);
if (zh === undefined) return { title: '', summary: '', body: '' }; // 兜底
if (code === 'zh_CN') return zh;
const en = NEWS_EN.get(id);
if (en !== undefined) return en; // 其他 7 种语言统一回退英文
return zh; // 英文也没翻 → 中文
}
为什么不用 8 张表?因为内容本地化的真实成本:8 种语言 × 全文翻译不现实。这里展示的是生产环境最常见形态——英文作为"国际语种"承载全部内容,其余语言按需补翻。当未来法语团队翻完正文,只需增加 NEWS_FR 表并调整取用优先级,页面代码零改动。
降级路径要与语言层区分:语言层回退到默认语言(zh_CN),内容层回退到英文(国际语种)。这是刻意的——UI 文案面向用户,默认语言通常是目标市场主流语言;新闻内容面向全球,英文是事实标准。两者不该共用一个回退目标。
四、intl 四件套(本应用的核心技术点)
4.1 相对时间 RelativeTimeFormat
新闻列表不能显示绝对时间(用户感知不到"新鲜"),用相对时间:
const fmt = new intl.RelativeTimeFormat(code, { numeric: 'auto' });
fmt.format(-18, 'minute'); // zh: "18分钟前" en: "18 minutes ago"
fmt.format(-3, 'hour'); // zh: "3小时前" en: "3 hours ago"
fmt.format(-2, 'day'); // zh: "2天前" ja: "2日前"
要点:
value传负数表示过去(-18),正数表示未来;numeric: 'auto'会输出"昨天"这类近似词,numeric: 'always'则强制数值(“1 天前”);- unit 支持
year / quarter / month / week / day / hour / minute / second; - 本应用按分钟数自动选择粒度:<60 分钟用 minute,<24h 用 hour,否则 day。
function relTime(code: string, minutesAgo: number): string {
const fmt = new intl.RelativeTimeFormat(code, { numeric: 'auto' });
if (minutesAgo < 60) return fmt.format(-minutesAgo, 'minute');
if (minutesAgo < 24 * 60) return fmt.format(-Math.floor(minutesAgo / 60), 'hour');
return fmt.format(-Math.floor(minutesAgo / (24 * 60)), 'day');
}
4.2 复数规则 PluralRules
"3 条评论"翻译成英语不是机械替换,而是单复数形态切换:
const pr = new intl.PluralRules(code);
const cat = pr.select(n); // 'zero' | 'one' | 'two' | 'few' | 'many' | 'other'
各语言类别差异巨大:
| 语言 | n=1 | n=3 | 说明 |
|---|---|---|---|
| 英语 | one | other | 两分类 |
| 法语 | one | other | 0/1 均 one |
| 俄语 | one | few | 多分类(one/few/many) |
| 中文 | other | other | 无复数形态 |
应用里英语/法语/德语/西语走 one/other 分支,中文/日文/韩文无复数直接拼数字:
function commentLabel(code: string, n: number): string {
const pr = new intl.PluralRules(code);
const cat = pr.select(n);
const label = t(code, K.comments); // 各语言的 "评论" 文案
if (code === 'en_US' || code === 'fr_FR' || code === 'de_DE' || code === 'es_ES') {
return cat === 'one' ? `1 ${label}` : `${n} ${label}`;
}
return `${n} ${label}`;
}
工程细节:英文文案存的是复数形式 "comments",单数时 label.slice(0, -1) 去掉 s 得到 "comment"——这是一条廉价的"伪复数"方案,能覆盖 90% 英文词(绝大多数名词加 s 变复数)。真正的复数形态由 PluralRules 判定,文案形态由人工维护,两者职责不同。
4.3 压缩数字 NumberFormat
阅读量 128400 不能原样显示,中英文的压缩习惯也不同:
const fmt = new intl.NumberFormat(code, { maximumFractionDigits: 1 });
// 中文:12.8 万(以万为单位) 英文:128.4K(以千为单位)
注意 NumberFormat 本身不提供"万"单位,本应用手动选择基数:中文以 10000 为基数输出"万",其余语言以 1000 为基数输出"K"。真实产品还会结合 minimumFractionDigits 控制精度(如 12.8万 与 12.84万)。
function compactViews(code: string, n: number): string {
const fmt = new intl.NumberFormat(code, { maximumFractionDigits: 1 });
if (n >= 10000 && code.startsWith('zh')) return `${fmt.format(n / 10000)}万`;
if (n >= 1000) return `${fmt.format(n / 1000)}K`;
return fmt.format(n);
}
为什么用 NumberFormat 格式化除法结果而不是 (n/10000).toFixed(1)?因为除法结果可能是 12.7654321,toFixed 固定小数点,而 NumberFormat 会按 locale 处理千分位与小数点符号——德语 12,8 万、阿拉伯语用 ٬ 分隔。数字格式化永远交给格式化器,这是 i18n 第一戒律。
4.4 绝对时间 DateTimeFormat
详情页用完整绝对时间(“2026年8月19日星期三 14:30”):
const fmt = new intl.DateTimeFormat(code, { dateStyle: 'full', timeStyle: 'short' });
fmt.format(new Date(Date.now() - minutesAgo * 60 * 1000));
dateStyle: 'full' 自动输出本地化星期:“Wednesday, August 19, 2026”(en)/「2026年8月19日星期三」(zh)。
五、状态管理与联动刷新
@StorageLink(STORAGE_LOCALE) currentLocale: string = DEFAULT_LOCALE;
@State activeTab: string = 'tabTop';
@State selectedId: string = ''; // 非空 = 详情态
@State showLangPanel: boolean = false;
@StorageLink双向绑定AppStorage,配合PersistentStorage.persistProp实现跨启动持久化;- 语言一变 →
currentLocale变 →build()全树重渲染 → 所有T()/newsContent()/ 格式化函数读到的都是新语言; - 列表与详情是同一组件内的两个分支(
if (this.selectedId === '')),切换语言时详情页内容同样即时更新,无需重新进入; - 详情页用
const content: NewsContent = newsContent(...)在分支内取数据——ArkTS 允许 UI 描述内声明局部常量,比在 build 外缓存状态更干净(不需要为"当前详情内容"单独维护一份状态,避免两处同步)。
持久化细节
aboutToAppear(): void {
if (!AppStorage.get<string>(STORAGE_LOCALE)) {
AppStorage.setOrCreate(STORAGE_LOCALE, DEFAULT_LOCALE);
}
PersistentStorage.persistProp(STORAGE_LOCALE, DEFAULT_LOCALE);
}
先 setOrCreate 再 persistProp:首次启动写入默认值,随后持久化;第二次启动从磁盘恢复。顺序不能反——persistProp 建立 AppStorage↔磁盘映射,之后对 @StorageLink 的修改自动落盘。
六、ArkTS 兼容要点
catch (err)不带类型注解(arkts-no-types-in-catch),所有intl调用包try/catch——本地化数据缺失时格式化器可能抛异常,降级为原生toString();- 所有对象字面量标注显式接口(
LocaleInfo/NewsItem/NewsContent),避免隐式Object类型; ForEach的 key 生成器返回稳定唯一值:语言用item.code,新闻用item.id;newsContent()返回的联合类型用空对象兜底,避免undefined泄漏到 UI;- 页面最外层用
Scroll()承载(Column无scrollable属性),保证内容超出屏幕可滚动; ForEach(['tabTop', ...] as string[], ...)数组字面量需要as string[]断言,否则推断为string[]的联合字面量类型,回调参数类型不匹配。
七、性能与内存
RelativeTimeFormat/PluralRules/NumberFormat/DateTimeFormat每次调用都是新实例。列表 6 条 × 每条 2 个格式化器,单次渲染约 12 次实例化,毫秒级,可接受;若列表上百条,应在模块级缓存格式化器实例(code → fmt的 Map),按 locale 复用;setInterval在旧版用于秒级刷新,本应用新闻时间是静态的,不需要——真实产品里"3 小时前"会随时间老化,用"渲染时计算 + 下拉刷新"即可,避免无意义的定时器空转;PersistentStorage只存字符串 key,别存对象——小数据(语言偏好、开关)用它,大数据(草稿、缓存)用 Preferences/数据库。
更多推荐




所有评论(0)