ICU 复数与占位符:gen-l10n 放过了什么

更新于 · LocalePilot

flutter gen-l10n 会拒绝格式错误的 ICU 消息,却会默默接受丢失了占位符的译文,或者在数量为零时显示“1 item”的复数。本文说明它检查什么、不检查什么,并提供一个补上漏洞的脚本。

下面的所有示例都使用 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英语法语
00 items0 article
11 item1 article
22 items2 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 调用消息的测试来发现。

常见问题

flutter gen-l10n 检查译文,还是只检查模板?

它会检查每个 ARB 文件的 ICU 语法,但不会把译文的占位符与模板比较:丢失的占位符不会产生任何警告。

为什么我的本地化方法多了一个 Object 参数?

某条译文使用了模板中不存在的占位符名称,例如用 {nombre} 代替 {count}。gen-l10n 会在每个语言中把它添加为参数,现有的调用处就无法再编译。

复数中该用 =1 还是 one?

在 ICU 中,=1 精确匹配值 1,但 gen-l10n 把它当作 one 类别传递,而在法语中 one 也覆盖 0。请添加 =0 分支,或在 one 分支中显示 {count}。