关于 i18n-rtl-l10n
面向支持多语言或从右向左语言的 Flutter 应用的本地化技能。内容涵盖 ARB 翻译流程、按区域设置显示数据,以及可正确镜像的布局设计。
主要功能
- 01保持 ARB 键与占位符一致
- 02使用 ICU 复数和选择消息
- 03采用方向性布局几何
- 04本地化显示数字与日期
- 05处理 RTL 和双向文本
- 06检查区域设置支持与字体覆盖
适用场景
- 01以一致的 ARB 键添加翻译
- 02构建适用于阿拉伯语或波斯语的界面
- 03格式化本地数字和日历日期
- 04处理混合方向界面的技术文本
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ zakariaf/ flutter-skills --skill i18n-rtl-l10n安装后在 Claude Code 中输入/i18n-rtl-l10n确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
examples/directional_widget.dartnumeral_normalizer.dartunsupported_locale_delegates.dartreferences/arb-and-icu.mdnumerals-and-calendars.mdrtl-and-bidi.mdunsupported-locales.mdscripts/check_arb_parity.shcheck_i18n_bans.sh详细介绍
i18n、RTL 与本地化
一项约定:存储规范化数据,在渲染时本地化。 所有转换和格式化都集中在一个 l10n 层;功能组件接收值对象和已本地化的字符串,绝不包含原始格式化逻辑。RTL 是一种布局规范,而不是翻译任务——使用逻辑几何进行编写,使界面能够按设计自动镜像。适用于支持多种语言区域或任何 RTL 语言区域(阿拉伯语、波斯语、希伯来语、索拉尼库尔德语、乌尔都语)的应用。
请根据当前任务阅读相应参考资料:
references/arb-and-icu.md— ARB 添加与翻译工作流、l10n.yaml、ICU 复数与选择、占位符类型。references/rtl-and-bidi.md— 方向几何的允许/禁止表、图标镜像、双向文本隔离、固定为 LTR 的 CustomPainter、RTL 图表。references/numerals-and-calendars.md— 数字系统、分隔符陷阱、格式化/解析、日历投影。references/unsupported-locales.md— 为flutter_localizations不提供数据的语言区域发布应用:探测方法、随应用打包的 delegate 三件套、delegate 顺序、字体覆盖范围。
提交 PR 前,运行 scripts/check_arb_parity.sh 和 scripts/check_i18n_bans.sh。
不可妥协的规则
- 所有面向用户的字符串都必须通过 gen-l10n。 先将键添加到模板
app_en.arb中,再通过AppLocalizations.of(context)读取。将l10n.yaml中的nullable-getter: false设为false,确保 getter 非空,且缺失或拼写错误的键会导致编译错误,而不是静默生成空组件。绝不要在Text、tooltip:、label:、hint:或semanticsLabel:中硬编码字面文本。绝不要在Localizations作用域之外调用AppLocalizations.of(例如在runApp之前的main()中)。 - 所有区域设置中的键和占位符必须一致。 模板中有某个键但某个区域设置缺少该键时,发布时会静默使用模板语言;重命名占位符会导致该翻译在运行时出错。确保每个键和每个
{placeholder}名称都完全一致——check_arb_parity.sh会对此进行检查。 - 使用 ICU 构建句子和复数,绝不要拼接。 在 ARB 中使用
{count, plural, ...}和{x, select, ...}。不同语言的语序和复数类别各不相同(阿拉伯语有六种 CLDR 形式:zero/one/two/few/many/other)。字符串拼接会产生不符合语法的输出,也无法适应 RTL 重排。 - 方向由区域设置决定,绝不要硬编码。 在
MaterialApp上配置localizationsDelegates和supportedLocales,并通过状态驱动当前区域设置。在根节点使用Directionality(TextDirection.rtl)会掩盖物理方向边侧问题,并破坏 LTR 局部区域。RTL 会通过GlobalWidgetsLocalizations生效,无需编写任何逐组件代码——但仅适用于flutter_localizations实际包含的区域设置(规则 10)。其他区域设置会静默回退到 LTR。应逐个区域设置断言Directionality.of,而不是想当然。 - 从首次提交起就只使用方向性几何属性。 使用
EdgeInsetsDirectional、AlignmentDirectional、PositionedDirectional、TextAlign.start/end、MainAxisAlignment.start/end、BorderRadiusDirectional、Icons.adaptive.*。绝不要使用EdgeInsets.only(left/right)、Alignment.centerLeft/Right、Positioned(left/right:)、TextAlign.left/right或Icons.arrow_back。grep 检查会拒绝此类 PR。 - 规范存储:UTC 纪元时间 + ASCII 数字。 持久化
dateTime.toUtc().millisecondsSinceEpoch和西方/拉丁数字。日历和数字系统只是纯粹的显示转换。按纪元时间/DateTime排期,绝不要使用 Jalali/Hijri 算术——闰年和月份长度的特殊情况应与日历无关。 - 解析或存储前,将所有数字输入规范化为 ASCII——包括数字和分隔符。使用
double.parse(normalizeToAscii(text));绝不要对原始输入调用int.parse(text)(波斯语/阿拉伯语软键盘会输入۱۲۳/١٢٣和1٫5)。 - 使用一个 FSI/PDI 辅助函数,仅在视图层隔离强 LTR 技术文本段(账号/订单/参考编号、电话、电子邮件、URL、产品代码)。绝不要让隔离字符(
U+2066–U+2069)进入存储、搜索或导出流程——在边界处剥离。 - 打包覆盖所需文字系统的字体,并提供回退;运行时不要获取字体。 设置主题的
fontFamily和fontFamilyFallback,确保阿拉伯语/波斯语字形不会显示为方框。离线/无遥测应用中避免使用google_fonts——它会在运行时获取字体。根据字体自身的cmap验证覆盖范围,而不是看字样示例:文字系统覆盖范围不等于语言覆盖范围——一种能显示波斯语的字体可能缺少 Sorani 使用的七个额外字母,而使用阿拉伯语设置的字样示例也绝不会展示这些字母。 - 对于
flutter_localizations未内置的区域设置,必须提供三个自带的 delegate,并将它们排在最前。 gen-l10n 会本地化你的字符串;Flutter 自身的界面文本和环境中的TextDirection来自另一套目录。借用同一文字系统中相近语言的实现,提供MaterialLocalizations、CupertinoLocalizations和WidgetsLocalizations,且每个 delegate 仅声明对应的语言代码;将它们放在Global*delegate 之前——AppLocalizations.localizationsDelegates已包含这些 delegate。原因:缺少 Material delegate 会在首个Tooltip处触发断言;而 Widgets 回退 delegate 会匹配所有区域设置,并将TextDirection.ltr硬编码,因此只修复 Material 部分会让 RTL 应用从右往左的文字读反,且不再崩溃。 请参阅references/unsupported-locales.md。
ARB 工作流(添加或翻译键)
- 将键及其
@key元数据(类型化的placeholders、供翻译人员参考的description)添加到 模板app_en.arb。 - 将同一个键同步到所有其他语言环境文件,并提供真实的译文;占位符名称和 ICU 结构必须完全一致(分支正文可以不同,但分支形状必须相同)。
- 运行
scripts/check_arb_parity.sh——任何缺失键、多余键或占位符不匹配都会导致检查失败。 - 重新生成(
flutter gen-l10n)并运行flutter analyze——缺失的键现在会导致编译错误。
// app_en.arb (template) — declare the key + @metadata here first.
{
"@@locale": "en",
"itemsRemaining": "{count, plural, =0{No items left} =1{1 item left} other{{count} items left}}",
"@itemsRemaining": {
"description": "Count shown on the order summary card.",
"placeholders": { "count": { "type": "int" } }
}
}
// app_ar.arb — same key; Arabic's SIX CLDR categories are a translation contract.
{
"@@locale": "ar",
"itemsRemaining": "{count, plural, zero{لا عناصر متبقية} one{عنصر واحد متبقٍ} two{عنصران متبقيان} few{{count} عناصر متبقية} many{{count} عنصرًا متبقيًا} other{{count} عنصر متبقٍ}}"
}
方向性几何——通过构造实现镜像
// Reads correctly under LTR and RTL with zero conditionals.
Padding(
padding: const EdgeInsetsDirectional.only(start: 16, end: 8), // never left/right
child: Row(
children: [
Expanded(child: Text(task.title, textAlign: TextAlign.start)), // never .left
IconButton(
icon: Icon(Icons.adaptive.arrow_forward), // auto-mirrors in RTL
onPressed: onNext,
),
],
),
)
对表示方向的字形(返回/下一步、尖括号、进度箭头)使用 Icons.adaptive.* 进行镜像。
切勿镜像含义固定的字形(时钟、对勾、媒体播放/暂停、徽标)。完整的允许/禁止表和手动翻转方法,请参阅
references/rtl-and-bidi.md。
数字——在边界处格式化,统一使用一个格式化器
使用每种语言环境各自的 NumberFormat:intl 会根据语言环境的符号数据输出本地数字,而非
根据 -u-nu- 扩展(回退时会丢弃 unicode -u 扩展)。对于 intl 没有数字符号的语言环境(例如 ckb),
会静默回退为拉丁数字;因此应将其固定到共享同一数字系统且已覆盖的基础语言环境
(ckb→fa),并在测试中断言输出的数字区块。界面元素和任何画布绘制器都使用同一个格式化器。
// One formatter per locale. Persian and Arabic digits are DISTINCT Unicode blocks
// (۴۵۶ U+06Fx vs ٤٥٦ U+066x) — not interchangeable. Format with a locale intl ships
// symbols for; a `-u-nu-` extension is dropped, not honored.
NumberFormat numberFormatFor(Locale locale) => switch (locale.languageCode) {
'fa' => NumberFormat.decimalPattern('fa'), // ۰۱۲۳۴۵۶۷۸۹ (arabext)
// ckb has no intl number symbols — borrow fa (same arabext block + separators).
'ckb' => NumberFormat.decimalPattern('fa'),
'ar' => NumberFormat.decimalPattern('ar'), // ٠١٢٣٤٥٦٧٨٩ (arab)
_ => NumberFormat.decimalPattern('en'), // Western (en, de, fr)
};
解析前先规范化——转换两种数字范围以及波斯语/阿拉伯语分隔符:
String normalizeToAscii(String input) {
final sb = StringBuffer();
for (final r in input.runes) {
if (r >= 0x0660 && r <= 0x0669) { // Eastern-Arabic ٠-٩
sb.writeCharCode(0x30 + (r - 0x0660));
} else if (r >= 0x06F0 && r <= 0x06F9) { // Persian ۰-۹
sb.writeCharCode(0x30 + (r - 0x06F0));
} else if (r == 0x066B) { // ٫ decimal separator -> '.'
sb.write('.');
} else if (r == 0x066C) { // ٬ grouping separator -> drop
continue;
} else {
sb.writeCharCode(r);
}
}
return sb.toString();
}
// double.parse(normalizeToAscii(text)) — never int.parse on raw input.
1٫5 表示 1.5,而不是 15:只规范化数字、不规范化分隔符,会悄无声息地破坏输入的金额。
完整的数字/分隔符/历法表:references/numerals-and-calendars.md。
双向文本隔离——混合文字脚本片段
const _lri = ''; // LEFT-TO-RIGHT ISOLATE
const _rli = ''; // RIGHT-TO-LEFT ISOLATE
const _fsi = ''; // FIRST STRONG ISOLATE (unknown direction)
const _pdi = ''; // POP DIRECTIONAL ISOLATE
String isolate(String run) => '$_fsi$run$_pdi'; // unknown direction
String isolateLtr(String run) => '$_lri$run$_pdi'; // known LTR (codes, numbers)
String isolateRtl(String run) => '$_rli$run$_pdi'; // known RTL
// Prefer known-direction over FSI: first-strong mis-guesses on leading punctuation.
// The isolated value is an ARB placeholder, never a hard-spliced substring:
Text(l10n.orderRef(isolateLtr(order.reference))); // "{ref} — order" reads correctly in RTL
仅在视图层进行隔离。对于独立字段,应改为在 widget 上设置方向:
Text(code, textDirection: TextDirection.ltr, textAlign: TextAlign.end)。切勿使用旧式
LRE/RLE/LRO/RLO 嵌入控制符——应使用隔离控制符(UAX #9)。
历法与日期
仅在显示时,将存储的 UTC 时刻转换为用户所选的历法。切勿持久化特定历法的字段;切勿手动编写
Jalali/Gregorian 转换(请使用 intl、shamsi_date、hijri)。一周的第一天和周末因语言环境而异——在标准日期上
计算分组,并按语言环境显示标签。请参阅 examples/numeral_normalizer.dart 和 references/numerals-and-calendars.md。
使用语言环境无关坐标空间的 CustomPainter
如果绘制器使用固定坐标网格,只将该子树固定为
Directionality(textDirection: TextDirection.ltr, …),以免环境中的 RTL 翻转几何布局;
周围的界面元素仍通过逻辑内边距进行镜像。使用 localPosition,通过绘制器自身的变换映射指针位置——
切勿在 RTL 下反转 dx。向绘制器传入相同的 numberFormatFor(locale),
确保画布上的数字与界面元素一致。图表界面元素(坐标轴、图例、时间轴)会镜像,但
绘制的数据本身绝不翻转。详见 custom-canvas-and-gestures。
反模式
Text('Order details')——硬编码文本,没有 gen-l10n 处理路径。请读取l10n.orderDetails。 (精确匹配硬编码 UI 字符串的 grep 容易误报,因此此项会在代码审查中发现,而不是由check_i18n_bans.sh检查——该检查门禁覆盖几何尺寸、图标、数字拼接、旧式双向文本和字体获取。)- 先编写翻译文件,或让缺失的键降级为 null——模板是
en,而nullable-getter: false会让缺失键成为编译错误。 count == 1 ? 'item' : 'items'——二选一的三元表达式无法涵盖阿拉伯语的few/many;请使用 ICUplural。Text('$count items')或'Total ' + n.toString()——拼接片段并使用原始 ASCII 数字; 在 RTL 中会导致词序和数字区段错误。请使用一条 ICU 消息和numberFormatFor。- 波斯语显示
٤٥٦(阿拉伯-印度数字),或阿拉伯语显示۴۵۶(扩展阿拉伯-印度数字)——它们属于不同的 Unicode 区段;fa/ckb 使用fa格式化器,ar 使用ar格式化器。 EdgeInsets.only(left: 16)/Alignment.centerLeft/Positioned(left:)——使用物理方向的边会绕过镜像处理(grep 会检查出)。- 用
Directionality(TextDirection.rtl)包裹应用根节点来“开启 RTL”——RTL 来自语言区域;硬编码根节点会掩盖物理方向边的问题,并破坏 LTR 区域。 [...AppLocalizations.localizationsDelegates, GlobalMaterialLocalizations.delegate, …]——该 展开内容已经包含三个Global*委托,因此再次追加会造成重复,并将它们置于你引入的任何委托之前。Localizations._loadAll会采用第一个声明支持该语言区域的同类型委托;你的委托永远不会被调用。- 对不受支持的语言区域,只引入
MaterialLocalizations委托——崩溃虽然消失,应用却会以错误方向渲染。也要引入WidgetsLocalizations委托,或者两个都不引入。 - 看到字体铸造商页面上写着“支持阿拉伯语”,就认为它适用于 Sorani、乌尔都语或普什图语构建——请根据捆绑字体的
cmap检查该语言自己的字母。 - 让隔离字符(
U+2066–U+2069)进入存储、搜索或导出——请在边界处剥离。 - 使用
Ahem渲染 RTL/数字金样——这样不会测试波斯数字和阿拉伯文字塑形。请加载实际捆绑的字体(参见widget-golden-and-a11y-testing)。 - 对原始数字字段使用
int.parse(userText)——请先将其规范化为 ASCII。
完成定义
- 在模板
app_en.arb中添加键,并附带@description和带类型的占位符;其他每种语言环境都包含相同的键;check_arb_parity.sh检查通过。 - 设置
nullable-getter: false;字符串只能通过AppLocalizations读取;Text/tooltip/label/hint/semanticsLabel中不得有字面量。 - 带计数的字符串使用 ICU
plural(提供阿拉伯语时,覆盖全部六种阿拉伯语类别);不得使用三元表达式,也不得拼接$count。 - 数字和日期通过
numberFormatFor(locale)/intlDateFormat获取,并作为占位符注入——绝不手动格式化为 ASCII,也绝不使用错误的数字区块;绘制器使用相同的格式化器。 - 所有数字输入在解析/存储前都经过
normalizeToAscii;并测试往返转换。 - 所有几何布局都具有方向性(
start/end、Icons.adaptive);grep 不得再找到任何物理侧 API;方向由语言环境决定,不得硬编码根Directionality。 - 每段混合文字都通过唯一的 FSI/PDI 辅助函数处理(已知方向时优先使用 FSI);不得直接拼接,也不得使用旧版
LRE/RLE;隔离符不得进入存储。 - 日期/数字以规范形式存储(UTC 纪元 + ASCII);仅在渲染时转换日历/数字;提醒按纪元时间安排。
- 字体覆盖所有已提供语言环境的文字脚本,并带有回退字体;不得在运行时获取字体;使用真实字体运行 RTL + 数字黄金测试。根据每个捆绑字体的
cmap断言覆盖范围——检查语言本身的字母,而不只是文字脚本。 - 每个已提供的语言环境都包含在
flutter_localizations中,或在Global*委托之前附带全部三个委托;对每种语言环境都断言Directionality.of和一个MaterialLocalizations字符串;平台清单(CFBundleLocalizations)列出每个标签。
相关技能
- 有关
Semantics角色/标签以及从MediaQuery读取无障碍状态,请参阅accessibility-as-code。 - 有关 RTL/数字黄金测试流程(使用真实字体,而非 Ahem)和
textScaler溢出矩阵,请参阅widget-golden-and-a11y-testing。 - 有关固定为 LTR 的绘制器、共享变换和指针映射,请参阅
custom-canvas-and-gestures。 - 有关此处渲染的规范整数最小单位存储,请参阅
value-objects-money-and-units。 - 有关根据规范时刻安排提醒,而非进行日历运算,请参阅
local-notifications-scheduler。 - 有关这些字符串所在的方向性布局原语,请参阅
widget-composition。 - 有关通过此 ARB 工作流接入新功能字符串,请参阅
scaffold-feature-module。 - 有关声明各文字脚本的回退级联位置,以及如何验证捆绑字形覆盖范围,请参阅
design-system-structure(references/typography-and-fonts.md)。 - 有关在首帧之前恢复持久化语言环境,请参阅
app-startup-and-bootstrap——这是操作系统无法选择的语言环境能够被使用的唯一方式。
参考资料
- Flutter 国际化:https://docs.flutter.dev/ui/accessibility-and-internationalization/internationalization
- gen-l10n /
l10n.yaml选项:https://docs.flutter.dev/ui/accessibility-and-internationalization/internationalization#configuring-the-l10nyaml-file - ARB 格式:https://github.com/google/app-resource-bundle/wiki/ApplicationResourceBundleSpecification
- ICU
MessageFormat(复数/选择):https://unicode-org.github.io/icu/userguide/format_parse/messages/ - CLDR 复数规则:https://cldr.unicode.org/index/cldr-spec/plural-rules
intl包(NumberFormat、DateFormat、Bidi):https://pub.dev/packages/intl- Unicode 双向算法(UAX #9)、隔离控制符:https://www.unicode.org/reports/tr9/
Directionality、EdgeInsetsDirectional:https://api.flutter.dev/flutter/widgets/Directionality-class.htmlLocalizationsDelegate(自行引入):https://api.flutter.dev/flutter/widgets/LocalizationsDelegate-class.html- 支持的区域设置列表 + 添加不受支持的区域设置:https://docs.flutter.dev/ui/accessibility-and-internationalization/internationalization#advanced-locale-definition
- OpenType
cmap表(字形覆盖范围):https://learn.microsoft.com/en-us/typography/opentype/spec/cmap