下面的所有示例都使用 Flutter 3.47.5 和 flutter gen-l10n 测试过,模板为 app_en.arb,译文为 app_fr.arb。
赶时间?直接在浏览器中为每种语言生成复数代码块:免费,无需注册,不上传任何内容。
gen-l10n 确实会拦下的问题
gen-l10n 会检查每个文件的 ICU 语法,不限于模板。没有 other 分支的复数会让生成停止,并给出文件名和错误位置:
[app_fr.arb:itemsOne] ICU Syntax Error: Plural expressions must have an "other" case.
{count, plural, one{{count} article}}
^
花括号未闭合也一样,这类问题会立即暴露。下面几种则不会。
丢失占位符不会报错
英文模板声明了一个参数:
"welcomeUser": "Welcome back, {name}!",
"@welcomeUser": { "placeholders": { "name": { "type": "String" } } }
法语译文把它弄丢了:"welcomeUser": "Bon retour parmi nous !"。生成成功,分析器没有任何提示,生成的方法直接忽略了它的参数:
@override
String welcomeUser(String name) {
return 'Bon retour parmi nous !';
}
程序不会崩溃,法语用户只是看不到自己的名字。有时这是有意的(不带名字句子更通顺),但多数时候是错误,而标准工具无法区分这两种情况。你必须把每条译文的占位符与模板比较;下文提供了一个脚本。
被改名的占位符会改变每个语言的 API
这种情况更奇怪。译文使用了模板中没有的名称,比如用 {nombre} 代替 {count}:
"reminder": "Il reste {nombre} articles dans {listName}."
gen-l10n 依然什么都不报告。它会把 nombre 作为额外参数添加到抽象类上,因此影响每个语言:
String reminder(String listName, int count, Object nombre);
错误要到编译现有调用处时才出现:Too few positional arguments: 3 required, 2 given. 它指向的是你的 Dart 代码,而不是导致问题的 ARB 文件。如果某个本地化方法突然多了一个 Object 参数,就在译文中搜索这个名称。
在法语中,one 也覆盖零
one、few、many 等类别遵循各语言的 CLDR 规则。在英语中,one 只表示 1;在法语中,它表示 0 和 1(中文则只有 other 一个类别)。对于这条消息:
"itemsOne": "{count, plural, one{{count} article} other{{count} articles}}"
| count | 英语 | 法语 |
|---|---|---|
| 0 | 0 items | 0 article |
| 1 | 1 item | 1 article |
| 2 | 2 items | 2 articles |
“0 article”在法语中是正确的写法。问题在于,gen-l10n 会把 =0 和 =1 转换为 Intl.pluralLogic 的 zero 和 one 参数。因此 =1 分支会变成 one 类别,而在法语中这个类别也适用于零:
"exactOne": "{count, plural, =1{1 article restant} other{{count} articles restants}}"
当 count = 0 时,英语显示“0 items left”,法语却显示“1 article restant”(还剩 1 件)。文字错了,却没有任何提示。解决方法:添加 =0 分支,或者写成 one{{count} article restant},让显示的数字始终正确。凡是 one 覆盖的不止 1 的语言,都有同样的问题。
撇号与 use-escaping
默认情况下,直撇号只是一个普通字符:"Don't forget the milk" 可以正常工作。如果你在 l10n.yaml 中开启 use-escaping: true(用于书写字面花括号),它就会变成 ICU 转义字符,单独的撇号会让生成停止:
[app_en.arb:apostrophe] ICU Lexing Error: Unmatched single quotes.
Don't forget the milk
^
这时你必须把它写两次(Don''t),或者使用不受影响的弯撇号 ’。在已有项目中切换这个选项,意味着要把每种语言都过一遍;英语的缩写和法语的省音会让你忙上一阵。
值得加入 CI 的检查
不会报错的是丢失占位符。这个脚本读取模板中声明的占位符,并检查每条译文是否仍在使用它们:
import 'dart:convert';
import 'dart:io';
void main() {
final template = _read('lib/l10n/app_en.arb');
var errors = 0;
for (final file in Directory('lib/l10n').listSync().whereType<File>()) {
if (!file.path.endsWith('.arb') || file.path.endsWith('app_en.arb')) continue;
final translation = _read(file.path);
for (final key in template.keys.where((k) => !k.startsWith('@'))) {
final text = translation[key];
if (text is! String) continue; // missing key: see untranslated-messages-file
final meta = template['@$key'] as Map<String, dynamic>?;
final declared = (meta?['placeholders'] as Map<String, dynamic>?)?.keys ?? const <String>[];
for (final name in declared) {
if (!RegExp(r'\{\s*' + name + r'\s*[,}]').hasMatch(text)) {
stderr.writeln('${file.path} · $key: {$name} missing');
errors++;
}
}
}
}
if (errors > 0) exit(1);
}
Map<String, dynamic> _read(String path) => jsonDecode(File(path).readAsStringSync()) as Map<String, dynamic>;
对本页的示例,它会输出 lib/l10n/app_fr.arb · welcomeUser: {name} missing 并以错误状态退出。它的局限:只查看 @key.placeholders 中声明的占位符;对于复数,它只检查变量是否出现在消息的某处,而不是每个分支中。上面提到的两个复数陷阱,最好用一个分别以 0、1 和 2 调用消息的测试来发现。