在这里插入图片描述
在这里插入图片描述

一、业务需求(为什么这么设计)

产品要做一个面向全球用户的新闻客户端,第一个需求不是"能看新闻",而是"换一种语言,整个 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.7654321toFixed 固定小数点,而 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);
}

setOrCreatepersistProp:首次启动写入默认值,随后持久化;第二次启动从磁盘恢复。顺序不能反——persistProp 建立 AppStorage↔磁盘映射,之后对 @StorageLink 的修改自动落盘。

六、ArkTS 兼容要点

  1. catch (err) 不带类型注解(arkts-no-types-in-catch),所有 intl 调用包 try/catch——本地化数据缺失时格式化器可能抛异常,降级为原生 toString()
  2. 所有对象字面量标注显式接口(LocaleInfo / NewsItem / NewsContent),避免隐式 Object 类型;
  3. ForEach 的 key 生成器返回稳定唯一值:语言用 item.code,新闻用 item.id
  4. newsContent() 返回的联合类型用空对象兜底,避免 undefined 泄漏到 UI;
  5. 页面最外层用 Scroll() 承载(Columnscrollable 属性),保证内容超出屏幕可滚动;
  6. ForEach(['tabTop', ...] as string[], ...) 数组字面量需要 as string[] 断言,否则推断为 string[] 的联合字面量类型,回调参数类型不匹配。

七、性能与内存

  • RelativeTimeFormat / PluralRules / NumberFormat / DateTimeFormat 每次调用都是新实例。列表 6 条 × 每条 2 个格式化器,单次渲染约 12 次实例化,毫秒级,可接受;若列表上百条,应在模块级缓存格式化器实例code → fmt 的 Map),按 locale 复用;
  • setInterval 在旧版用于秒级刷新,本应用新闻时间是静态的,不需要——真实产品里"3 小时前"会随时间老化,用"渲染时计算 + 下拉刷新"即可,避免无意义的定时器空转;
  • PersistentStorage 只存字符串 key,别存对象——小数据(语言偏好、开关)用它,大数据(草稿、缓存)用 Preferences/数据库。
Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐