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) |
一、先记住这个:唯一的正确写法
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 里这么写,但代码已改):
AppLocalizations.of(context)!.someKey // ❌ 不要用本项目走运行时词条方案,不是 flutter gen-l10n 的静态代码生成方案。原因:
- 支持运行时从后端拉取词条(运营可改文案,不发版)
- 支持 9 种语言动态切换
- 无需代码生成,改 JSON 即生效
其他可用形式(不推荐)
// 可空版本:未注册 delegate 时返回 null
RuntimeI18n? maybe = RuntimeI18n.of(context);
// context extension(定义在 runtime_i18n.dart:544-547)
context.l10n.t('key')
context.t('key')⚠️ context.l10n / context.t 在 lib/ 下零使用(仅测试里出现)。 团队统一用 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.dart | LocalizationsDelegate<RuntimeI18n> 实现 |
runtime_i18n_engine.dart | 查表/插值/复数引擎(零 Flutter 依赖,可单测) |
i18n_cache.dart | 远程合并字典的磁盘持久化 |
i18n_remote_source.dart | Dio 拉取后端 getAllMessageSource |
i18n_sync_service.dart | 同步编排(拉取 → 落盘 → 版本号递增) |
app_languages.dart | 支持的语言列表、resolveEffectiveLocale |
i18n_utils.dart | localeToTag(Locale) 工具 |
挂载位置
lib/main.dart:283-291(三个 App 树都挂了):
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:
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]);
}插值
// 词条
{ "greet": "你好,{name}" }
// 使用
l10n.t('greet', {'name': '张三'}) // → "你好,张三"复数
// 词条
{ "deviceCount.one": "{count} 台设备", "deviceCount.other": "{count} 台设备" }
// 使用
l10n.plural('deviceCount', 5) // → "5 台设备"查表失败时
返回 key 本身。所以界面上如果看到 home.title 这种原文,说明:
- key 拼错了,或
- 该语言的 JSON 里没配这条,或
- 回退链(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:
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。
② 并发去重
Future<void>? _inFlight;
Future<void> sync(Locale locale) async {
if (_inFlight != null) return _inFlight;
_inFlight = _doSync(locale).whenComplete(() => _inFlight = null);
return _inFlight;
}防止同一 locale 并发写缓存。
③ 版本号触发重建
onVersionBump() 递增 i18nVersionProvider,MaterialApp 子树 watch 这个值,触发 delegate reload + UI 重建。
触发时机
lib/main.dart:251-255(Phase 2 完成后,fire-and-forget):
unawaited(
ref.read(i18nSyncServiceProvider)
.sync(resolveEffectiveLocale(ref.read(localeNotifierProvider))),
);fire-and-forget:不阻塞启动,失败静默。
六、语言切换
// 切换语言
ref.read(localeNotifierProvider.notifier).setLocale(const Locale('en'));
// 读取当前语言
final locale = ref.watch(localeNotifierProvider);LocaleNotifier(lib/config/providers/locale_provider.dart:90-91)是 keepAlive,持久化到 SharedPreferences,重启后恢复。
切换后会发生:
localeNotifierProvider变化 →MaterialApp.locale变化RuntimeI18nDelegate重新load新语言- 依赖
sysDictProvider的字典也会重建(Accept-Language变了) - 触发远程词条同步
七、新增一条文案:标准步骤
Step 1:确定 key
本项目用点分命名,按模块分组:
home.title
home.searchHint
device.list.empty
alarm.level.high
tip.noData
bTerminal.nomore
retry
cancel
allow复用优先:先搜一下有没有语义相近的 key(如 cancel、retry、tip.noData)。
Step 2:加到所有语言文件
编辑 assets/i18n/ 下所有 9 个 JSON,加同一个 key:
// zh-CN.json
{
"alarm.title": "电站告警",
"alarm.searchHint": "搜索告警"
}// en-US.json
{
"alarm.title": "Plant Alarm",
"alarm.searchHint": "Search alarms"
}⚠️ 至少保证 zh-CN.json 和 en-US.json 有(这两个是回退链)。其他语言可后续补。
Step 3:验证 JSON 格式
⚠️ JSON 语法错误会导致整个文件加载失败。项目历史上有专门的修复分支(feature/fix-i18n-json-syntax)。
# 用 dart 快速校验
fvm dart run scripts/_validate_json_tmp.dart或直接用一个 JSON 校验工具。
Step 4:使用
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'),没有硬编码中文 - [ ] 跑起来验证文案显示正常
八、复数与插值的词条写法
插值
{ "greet": "你好,{name}" }l10n.t('greet', {'name': '张三'})复数(.one / .other 后缀)
{
"deviceCount.one": "{count} device",
"deviceCount.other": "{count} devices"
}l10n.plural('deviceCount', 1) // "1 device"
l10n.plural('deviceCount', 5) // "5 devices"⚠️ 中文没有复数变化,但也要提供 .one / .other(引擎会按规则查找):
{
"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。
{
"a": "1",
"b": "2", // ❌ 尾逗号,JSON 不允许
}十、测试
i18n 引擎(RuntimeI18nEngine)是零 Flutter 依赖的纯 Dart 类,可独立单测:
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 语法错误会导致整个语言文件失效