Skip to content

14 · 国际化

目标:知道取文案的唯一正确写法,以及新增一条文案的完整步骤。

⚠️ 重要:本项目 不走 flutter gen-l10n。项目 docs/ 下有 4 篇 i18n 文档 (language.md / l10n-flow.md / i18n-analysis.md / l10n-architecture.md), 内容重复且部分过时。以本文为准


开篇:前端概念对照

前端(vue-i18n / react-i18next)本项目
t('key')l10n.t('key')
useI18n()RuntimeI18n.ofNonNull(context)
en.json / zh.json 词条文件assets/i18n/<tag>.json
复数 t('key', { count })l10n.plural('key', count)
插值 t('key', { name })l10n.t('key', {'name': name})
$i18n.locale = 'en'ref.read(localeNotifierProvider.notifier).setLocale(...)
语言包打包进 bundle本地 JSON + 远程词条覆盖
动态加载语言包I18nSyncService.sync(locale)

一、先记住这个:唯一的正确写法

dart
import 'package:haierenergy/core/i18n/runtime_i18n.dart';

@override
Widget build(BuildContext context) {
  final l10n = RuntimeI18n.ofNonNull(context);   // ← 标准写法
  ...
  Text(l10n.t('home.title'))
}

为什么不是 AppLocalizations.of(context)

已过时(旧文档 docs/README.md 里这么写,但代码已改):

dart
AppLocalizations.of(context)!.someKey      // ❌ 不要用

本项目走运行时词条方案,不是 flutter gen-l10n 的静态代码生成方案。原因:

  1. 支持运行时从后端拉取词条(运营可改文案,不发版)
  2. 支持 9 种语言动态切换
  3. 无需代码生成,改 JSON 即生效

其他可用形式(不推荐)

dart
// 可空版本:未注册 delegate 时返回 null
RuntimeI18n? maybe = RuntimeI18n.of(context);

// context extension(定义在 runtime_i18n.dart:544-547)
context.l10n.t('key')
context.t('key')

⚠️ context.l10n / context.tlib/零使用(仅测试里出现)。 团队统一用 RuntimeI18n.ofNonNull(context),保持一致。


二、架构:运行时 i18n 引擎

完整链路

assets/i18n/<tag>.json(本地基线,9 种语言)
        ↓ rootBundle 读取
RuntimeI18nDelegate.load()
        ↓ 叠加
I18nCache(远程词条缓存,磁盘持久化)

RuntimeI18nEngine(O(1) Map 查表 + en-US/zh-CN 回退链)

RuntimeI18n.ofNonNull(context).t('key')

文件职责

lib/core/i18n/ 共 8 个文件:

文件职责
runtime_i18n.dart访问器ofNonNull(context) / t() / plural()
runtime_i18n_delegate.dartLocalizationsDelegate<RuntimeI18n> 实现
runtime_i18n_engine.dart查表/插值/复数引擎(零 Flutter 依赖,可单测)
i18n_cache.dart远程合并字典的磁盘持久化
i18n_remote_source.dartDio 拉取后端 getAllMessageSource
i18n_sync_service.dart同步编排(拉取 → 落盘 → 版本号递增)
app_languages.dart支持的语言列表、resolveEffectiveLocale
i18n_utils.dartlocaleToTag(Locale) 工具

挂载位置

lib/main.dart:283-291(三个 App 树都挂了):

dart
localizationsDelegates: [
  RuntimeI18nDelegate(
      cache: const I18nCache(), version: ref.watch(i18nVersionProvider)),
  GlobalMaterialLocalizations.delegate,
  GlobalWidgetsLocalizations.delegate,
  GlobalCupertinoLocalizations.delegate
],
supportedLocales: supportedLocales,
localeResolutionCallback: resolveLocale,
locale: ref.watch(localeNotifierProvider),

三、API 详解

RuntimeI18n

lib/core/i18n/runtime_i18n.dart:11-41

dart
class RuntimeI18n {
  RuntimeI18n._(this._engine, this.locale);   // 私有构造,外部不可 new

  final Locale locale;                         // 当前语言

  /// 作用域内无 RuntimeI18n 时返回 null
  static RuntimeI18n? of(BuildContext context);

  /// of 的非空版本(null 时 assert + 抛)
  static RuntimeI18n ofNonNull(BuildContext context);

  /// 取文案,miss 时返回 key 本身
  String t(String key, [Map<String, dynamic>? args]);

  /// 复数,走 .one / .other 后缀
  String plural(String key, int count, [Map<String, dynamic>? args]);
}

插值

dart
// 词条
{ "greet": "你好,{name}" }

// 使用
l10n.t('greet', {'name': '张三'})    // → "你好,张三"

复数

dart
// 词条
{ "deviceCount.one": "{count} 台设备", "deviceCount.other": "{count} 台设备" }

// 使用
l10n.plural('deviceCount', 5)    // → "5 台设备"

查表失败时

返回 key 本身。所以界面上如果看到 home.title 这种原文,说明:

  1. key 拼错了,或
  2. 该语言的 JSON 里没配这条,或
  3. 回退链(en-US / zh-CN)里也没有

四、支持的语言

assets/i18n/9 个 JSON

de-DE.json   德语
en-US.json   英语(回退链之一)
fr-FR.json   法语
it-IT.json   意大利语
nl-NL.json   荷兰语
th-TH.json   泰语
uk-UA.json   乌克兰语
zh-CN.json   简体中文(回退链之一)
zh-HK.json   繁体中文(香港)

语言定义与解析在 lib/core/i18n/app_languages.dart


五、远程词条同步

为什么需要

运营可在后台改文案,不发版即可生效

流程

lib/core/i18n/i18n_sync_service.dart:51-64

dart
Future<void> _doSync(Locale locale) async {
  try {
    final tag = localeToTag(locale);
    AppLogger.I.i('同步开始: locale=$locale tag=$tag');
    final data = await remoteSource.fetch(tag);
    if (data.isEmpty) return;

    await cache.write(tag, data);
    AppLogger.I.i('同步成功: tag=$tag keys=${data.length} -> 已落盘,递增版本号');
    onVersionBump();                      // 触发 UI 重建
  } catch (e) {
    AppLogger.I.e('同步失败: $locale: $e');   // 静默降级
  }
}

三个设计要点

① 失败静默降级

同步失败不影响使用——回退到本地 JSON。

② 并发去重

dart
Future<void>? _inFlight;

Future<void> sync(Locale locale) async {
  if (_inFlight != null) return _inFlight;
  _inFlight = _doSync(locale).whenComplete(() => _inFlight = null);
  return _inFlight;
}

防止同一 locale 并发写缓存。

③ 版本号触发重建

onVersionBump() 递增 i18nVersionProviderMaterialApp 子树 watch 这个值,触发 delegate reload + UI 重建。

触发时机

lib/main.dart:251-255(Phase 2 完成后,fire-and-forget):

dart
unawaited(
  ref.read(i18nSyncServiceProvider)
      .sync(resolveEffectiveLocale(ref.read(localeNotifierProvider))),
);

fire-and-forget:不阻塞启动,失败静默。


六、语言切换

dart
// 切换语言
ref.read(localeNotifierProvider.notifier).setLocale(const Locale('en'));

// 读取当前语言
final locale = ref.watch(localeNotifierProvider);

LocaleNotifierlib/config/providers/locale_provider.dart:90-91)是 keepAlive持久化到 SharedPreferences,重启后恢复。

切换后会发生:

  1. localeNotifierProvider 变化 → MaterialApp.locale 变化
  2. RuntimeI18nDelegate 重新 load 新语言
  3. 依赖 sysDictProvider 的字典也会重建(Accept-Language 变了)
  4. 触发远程词条同步

七、新增一条文案:标准步骤

Step 1:确定 key

本项目用点分命名,按模块分组:

home.title
home.searchHint
device.list.empty
alarm.level.high
tip.noData
bTerminal.nomore
retry
cancel
allow

复用优先:先搜一下有没有语义相近的 key(如 cancelretrytip.noData)。

Step 2:加到所有语言文件

编辑 assets/i18n/所有 9 个 JSON,加同一个 key:

json
// zh-CN.json
{
  "alarm.title": "电站告警",
  "alarm.searchHint": "搜索告警"
}
json
// en-US.json
{
  "alarm.title": "Plant Alarm",
  "alarm.searchHint": "Search alarms"
}

⚠️ 至少保证 zh-CN.jsonen-US.json(这两个是回退链)。其他语言可后续补。

Step 3:验证 JSON 格式

⚠️ JSON 语法错误会导致整个文件加载失败。项目历史上有专门的修复分支(feature/fix-i18n-json-syntax)。

bash
# 用 dart 快速校验
fvm dart run scripts/_validate_json_tmp.dart

或直接用一个 JSON 校验工具。

Step 4:使用

dart
final l10n = RuntimeI18n.ofNonNull(context);
Text(l10n.t('alarm.title'))

Step 5:验证

跑起来看效果。如果显示 alarm.title 原文,说明 key 没配上(回退链也没有)。

检查清单

  • [ ] key 用点分命名、按模块分组
  • [ ] 9 个语言文件都加了(至少 zh-CN + en-US)
  • [ ] JSON 语法正确(无尾逗号)
  • [ ] 代码里用 l10n.t('key')没有硬编码中文
  • [ ] 跑起来验证文案显示正常

八、复数与插值的词条写法

插值

json
{ "greet": "你好,{name}" }
dart
l10n.t('greet', {'name': '张三'})

复数(.one / .other 后缀)

json
{
  "deviceCount.one": "{count} device",
  "deviceCount.other": "{count} devices"
}
dart
l10n.plural('deviceCount', 1)    // "1 device"
l10n.plural('deviceCount', 5)    // "5 devices"

⚠️ 中文没有复数变化,但也要提供 .one / .other(引擎会按规则查找):

json
{
  "deviceCount.one": "{count} 台设备",
  "deviceCount.other": "{count} 台设备"
}

九、常见坑

现象解法
用了 AppLocalizations.of(context)编译错误RuntimeI18n.ofNonNull(context)
硬编码中文无法国际化Text(l10n.t('key'))
JSON 语法错误整个语言文件加载失败,全 app 文案变 key校验 JSON
只加了 zh-CN切英文时显示 key至少补 en-US
key 拼错显示 key 原文检查回退链
忘了插值参数显示 {name} 原文{'name': ...}

⚠️ 最容易踩的:JSON 语法错误

一个尾逗号就会让整个文件解析失败,导致该语言所有文案都变成 key

json
{
  "a": "1",
  "b": "2",     // ❌ 尾逗号,JSON 不允许
}

十、测试

i18n 引擎(RuntimeI18nEngine)是零 Flutter 依赖的纯 Dart 类,可独立单测:

bash
fvm flutter test test/core/i18n/runtime_i18n_engine_test.dart

测试里可以用 RuntimeI18n.forTest(...) 构造实例(runtime_i18n.dart:102-105@visibleForTesting)。


十一、自检清单

  • [ ] 取文案只用 RuntimeI18n.ofNonNull(context).t('key')
  • [ ] 不用 AppLocalizations.of(context)
  • [ ] 知道本项目不走 gen-l10n,是运行时词条方案
  • [ ] 知道支持 9 种语言,回退链是 en-US / zh-CN
  • [ ] 能独立完成「新增一条文案」的 5 个步骤
  • [ ] 知道远程同步失败会静默降级到本地 JSON
  • [ ] 知道 JSON 语法错误会导致整个语言文件失效

下一步

15 · 权限与设备能力