多语言这块,我在几个 Flutter 项目里来回折腾过三轮:第一轮用官方 intl + ARB,第二轮手写 Map 套 InheritedWidget,第三轮被 GetX 的.tr语法吸引,把整套文案搬进了Translations。这篇聊的是第三轮,也就是GetX 版的多语言方案。它从 GetX 2.x 就带着,语法短、接入快、改一个语言码就能全局刷新,看着很省事。可真落到几十个页面、上千条文案的项目里,麻烦一点不比官方方案少:key 找不到时静默返回原文、复数没辙、切换后固定文案不刷新、语言包越来越大之后启动明显变慢,这些都是我一条条趟出来的。
这篇主要讲清楚三件事:GetX 的多语言底层到底怎么运作、怎么从零搭一套能扛住迭代的目录结构和配置、以及那些只有真跑起来才会暴露的问题该怎么排查。适合已经有 Flutter 基础、正在给应用做国际化、或者已经用上 GetX 想统一文案管理的同学;如果你还没决定用哪套方案,前面两节也能帮你判断 GetX 这条路是不是适合你手上的项目。
1. 为什么我第三轮才认真用 GetX 做多语言
1.1 两套方案摆在同一张需求表前的对照
我先说清楚取舍逻辑,不然容易变成“哪个语法短就用哪个”的情绪判断。当时的需求表大概有这么几条:支持中英双语、后期要加繁体、语言可即时切换不重启、用户选择要持久化、文案要能按模块拆给不同人改、不能因为漏翻一条就白屏。
官方 intl + ARB 的路子,优势在于生态完整:复数、性别、日期货币格式化都有现成能力,flutter gen-l10n还能在编译期把 key 变成强类型的 getter,写错 key 直接编译不过。代价是每次改文案都要跑一次生成,ARB 的 JSON 结构对非技术同学不算友好,而且强类型 getter 在需要动态拼 key 的场景(比如按接口返回的枚举取文案)会很别扭。
GetX 这条路正好反过来:文案就是普通Map<String, String>,改完存盘就能跑,动态拼 key 毫无压力,切换语言只需要一行Get.updateLocale。但 key 是字符串,写错不会报错,只会安静地把 key 本身显示在界面上;复数、日期格式化它一概不管,得自己补。
我最后选 GetX,是因为项目本身已经把路由、状态、依赖注入全交给 GetX 了,多语言跟着走能让心智负担少一层——同一套Get.put/Obx/.tr,新人上手只需要理解一个框架。这是很现实的工程考量:方案的一致性往往比单点能力的强弱更值钱。
1.2 GetX 多语言真正的甜点区和雷区
甜点区有三个,用起来确实舒服。
第一个是切换即时生效。Get.updateLocale(Locale('en', 'US'))一调用,整个GetMaterialApp会重建,所有还在走.tr求值的地方立刻变成新语言,不用重启、不用手动通知。
第二个是文案组织自由。你可以把zh_CN拆成十几个文件再spread合并,也可以按页面命名空间加前缀,甚至可以做成“基础包 + 业务包”的叠加结构,GetX 不关心你怎么组织,它只要最后拿到一个Map<String, Map<String, String>>。
第三个是和状态管理的天然贴合。语言本身可以做成一个GetxController里的Rx<Locale>,切换语言的同时把埋点、缓存、接口语言头一起改掉,一条链路走完。
雷区同样明显。最坑的是没有编译期校验,'home_titel'.tr这种拼写错误在测试阶段很难被发现,因为默认行为是原样返回 key,界面上一行home_titel看着像没翻译,实际是 key 写错了。其次是复数支持缺失,GetX 的tr只有trParams,没有 ICU 那套plural语法,1 item和2 items得自己分支。第三是刷新边界不清晰,很多人以为切了语言所有文字都会变,结果发现initState里取好的字符串、const构造的 widget、缓存在变量里的文案全都不动。
注意:GetX 的
.tr是“取值的瞬间”做翻译,不是“绑定的瞬间”。凡是把结果存进变量的地方,都不会跟着语言变。
1.3 什么样规模的项目适合这条路
我的判断标准比较粗:文案总量在 3000 条以内、语言数量不超过 5 种、没有复杂的复数与性别规则,GetX 这条路完全够用,接入成本低到当天就能跑通。
反过来,如果产品要覆盖阿拉伯语、俄语这类有复杂复数规则(一个、两个、少数、多数、其他)的语言,或者对日期、货币、数字格式有严格要求,那我就建议把格式化交给 intl,把文案交给 GetX,两者分工,别指望 GetX 全包。真要上十几个语种、几十号人协作翻译,那还是回官方 ARB 那套更省心,因为工具链和审校流程更成熟。
还有一种情况要提醒:如果团队里 Flutter 版本跨度大、有人还在用老版本 GetX,Translations的行为在不同小版本间有过细微差异(比如 locale 匹配的宽松程度),跨版本协作时最好把 GetX 版本在pubspec.yaml里锁死,别用^放任升级。
2. 把 GetX 多语言的核心机制掰开看
2.1 Translations 抽象类的三个必答项
GetX 的多语言入口就一个抽象类:
abstract class Translations { Map<String, Map<String, String>> get keys; }继承它,实现keys,就完事了。这个 getter 必须回答三个问题:支持哪些语言(外层 Map 的 key)、每种语言有哪些文案(内层 Map 的 key)、文案的值是什么(内层 Map 的 value)。
语言码的写法是语言_地区,比如zh_CN、en_US、zh_TW。这里有个很多人踩过的细节:语言码大小写敏感。你写zh_cn,然后传Locale('zh', 'CN'),GetX 拼出来的 key 是zh_CN,匹配不上,就会掉到 fallback 里去。我第一次接的时候就是这么翻车的,界面一半中文一半英文,查了半小时才发现是大小写问题。
还有一个隐含约束:keys这个 getter 会在应用启动和每次语言切换时被读取,所以别在里面做耗时操作,比如读文件、发网络请求。有人图省事把文案放 JSON 里运行时加载,结果keys变成异步,就只能返回空 Map,第一次渲染全是 key。正确做法是同步返回内存里的常量 Map,需要远程下发就用启动前的预加载把结果合并进去。
2.2 keys 的 Map 套 Map 结构与语言码匹配规则
外层 Map 的 key 就是语言标签,GetX 在解析时会把Locale对象拼成languageCode + '_' + countryCode去查。如果countryCode是空的,它只用languageCode查。所以你可以同时提供en和en_US,前者做兜底,后者做地区特化。
我一般在项目里这样组织:主语言给完整地区码,同时补一个纯语言的兜底。比如:
class AppTranslations extends Translations { @override Map<String, Map<String, String>> get keys => { 'zh_CN': zhCN, 'zh': zhCN, // 兜底:任何中文地区 'en_US': enUS, 'en': enUS, // 兜底:任何英文地区 }; }这么做的好处是,用户手机语言是en_GB、en_AU时不会因为找不到en_GB而掉到 fallback。代价是内存里多存一份引用——注意是引用不是拷贝,Dart 的 Map 赋值是引用传递,所以不会真的多占一份内存。
内层 Map 就是纯粹的键值对了,值一律是String。这里别想着塞嵌套结构,GetX 不支持'a': {'b': 'c'}这种,值必须是字符串。想表达层级就用点号或者下划线拼 key,比如settings_privacy_title。
2.3 .tr 从调用到刷新的完整链路
.tr是String上的一个扩展 getter,内部会去拿当前的Get.locale,拼成语言标签,在keys里查这张表,再查内层 key,找不到就返回 key 本身。
刷新机制的关键在于GetMaterialApp监听 locale 变化。Get.updateLocale改的是 GetX 内部的一个全局 locale 值,GetMaterialApp感知到之后重建整棵树。重建时,所有还在执行.tr的地方重新求值,于是文案就变了。
这条链路解释了为什么会出现“部分文字不刷新”:
- 写在
build方法里的Text('key'.tr),每次重建都重新求值,会刷新。 - 写在
initState里赋值给成员变量、build里直接用变量的,只在初始化时求值一次,不会刷新。 const Text('key'.tr)编译期就定死了,压根不是运行时求值,永远不刷新——这个其实编译不过,因为.tr不是常量表达式,但const的父 widget 缓存了子 widget 的情况很常见。- 放在
GetxController的普通成员变量里的文案,同理不刷新;除非放在Obx里且是RxString。
我后来在团队里定了一条规矩:翻译只允许出现在build方法或者直接写在 widget 参数里,禁止提前赋值给变量。听起来严,但省掉了大量“为什么这行字没变”的排查时间。
2.4 locale、fallbackLocale 与设备语言的优先级
这三个东西的优先级经常被搞混。按 GetX 的实际行为,大致是这样:
| 配置项 | 作用 | 优先级 |
|---|---|---|
locale | 显式指定的当前语言 | 最高,非 null 时直接用它 |
| 设备语言 | 系统设置里的语言列表 | 次之,locale为 null 时生效 |
fallbackLocale | 兜底语言 | 最低,前两者都匹配不到时使用 |
也就是说,如果你在GetMaterialApp里写了locale: Locale('zh', 'CN'),那用户手机是英文也照样显示中文,切换语言全靠你自己调Get.updateLocale。这适合“应用内自带语言设置”的产品。
如果locale留空,GetX 会读设备语言,匹配不到就走fallbackLocale。这个模式适合“跟随系统”的产品。我一般会做成混合:首次启动跟随系统,用户手动选过之后就记住选择,之后启动直接用记住的。
Locale? resolveStartLocale(String? saved) { if (saved == null || saved.isEmpty) return null; // 交给系统 final parts = saved.split('_'); if (parts.length == 2) return Locale(parts[0], parts[1]); return Locale(parts[0]); }注意:
fallbackLocale一定要配一个你 100% 有完整文案的语言。我见过有人把 fallback 设成一个只翻了三十条的语种,结果用户看到的是满屏英文 key 夹杂几句翻译,比全英文还难看。
3. 从零搭一套能扛住迭代的多语言骨架
3.1 目录分层与文件拆分策略
单文件堆文案,三百条还能忍,三千条就是灾难:合并冲突天天有,找一条文案要滚半天,多人协作时几乎无法并行。我的做法是按“公共 + 模块”两层拆:
lib/ i18n/ app_translations.dart # 汇总入口 locale_manager.dart # 语言切换与持久化 tr.dart # 自定义扩展与调试开关 translations/ common_zh.dart common_en.dart home_zh.dart home_en.dart settings_zh.dart settings_en.dart按语种拆还是按模块拆,我选后者,理由是翻译同学是按页面看文案的,一个页面相关的文案放在一起,上下文清晰,翻译质量明显更高。按语种拆的话,翻译的人要在两个文件间来回跳,很容易出现“这条在中文里有、英文里漏了”的情况。
合并的时候用展开运算符:
// lib/i18n/translations/common_zh.dart const Map<String, String> commonZh = { 'app_name': '示例应用', 'common_confirm': '确定', 'common_cancel': '取消', 'common_loading': '加载中', }; // lib/i18n/app_translations.dart import 'translations/common_zh.dart'; import 'translations/common_en.dart'; const Map<String, String> zhCN = { ...commonZh, ...homeZh, ...settingsZh, };这里有一个小技巧:后展开的会覆盖先展开的。所以把公共包放最前面,模块包放后面,模块里要是需要覆盖公共文案(比如首页对“确定”有特殊说法),直接写同名 key 就行,不用改公共包。
3.2 语言包基类与模块化 Translations
汇总类本身很薄,就是拼装:
import 'package:get/get.dart'; class AppTranslations extends Translations { @override Map<String, Map<String, String>> get keys => { 'zh_CN': zhCN, 'zh': zhCN, 'en_US': enUS, 'en': enUS, }; }如果项目大到需要按业务线懒加载语言包,可以做一个GetxService持有可变 Map,启动时先塞公共包,进入某个业务模块前再塞该模块的包。但我不太推荐这么做,因为keys是同步 getter,动态塞包会导致“进入页面之前文案是 key,进入之后才正常”,体验很怪。文案总量在万条以内,全量加载的内存开销其实可以忽略——一个 key 加一个中文值,平均不到 100 字节,一万条也就 1MB 上下。
真正需要担心的是编译产物体积。Dart 会把constMap 编进快照,语种越多体积越大,但通常几百 KB 级别,跟图片资源比不算什么。
3.3 GetMaterialApp 的关键配置(含 Material 本地化)
这一步最容易漏东西。很多人只加了translations,忘了localizationsDelegates,结果日期选择器、下拉刷新提示、返回按钮的语义标签还是英文。完整配置长这样:
import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:get/get.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); await GetStorage.init(); runApp(const MyApp()); } class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { final saved = GetStorage().read<String>('app_locale'); return GetMaterialApp( translations: AppTranslations(), locale: resolveStartLocale(saved), fallbackLocale: const Locale('zh', 'CN'), supportedLocales: const [ Locale('zh', 'CN'), Locale('en', 'US'), ], localizationsDelegates: const [ GlobalMaterialLocalizations.delegate, GlobalWidgetsLocalizations.delegate, GlobalCupertinoLocalizations.delegate, ], home: const HomePage(), ); } }几个细节值得单独说。supportedLocales要和keys里的语言码对得上,虽然 GetX 查表不完全依赖这个列表,但 Material 组件库靠它决定用哪套内置文案。WidgetsFlutterBinding.ensureInitialized()不能省,因为GetStorage.init()是异步的,在runApp之前要确保绑定就绪。
还有translations参数建议直接传实例,不要用Get.put包一层。有人为了“统一管理”把AppTranslations也Get.put进去,然后translations: Get.find<AppTranslations>(),这会导致启动时机错乱——GetMaterialApp构建时Get.find可能还没就绪。
3.4 切换语言、持久化与启动时回读
切换语言的逻辑我收在一个独立文件里,不放在页面里,因为埋点、接口 language header、缓存清理往往要一起做:
// lib/i18n/locale_manager.dart import 'package:get/get.dart'; import 'package:get_storage/get_storage.dart'; class LocaleManager { static const _boxKey = 'app_locale'; static Future<void> switchTo(String code) async { final parts = code.split('_'); final locale = parts.length == 2 ? Locale(parts[0], parts[1]) : Locale(parts[0]); if (Get.locale == locale) return; Get.updateLocale(locale); await GetStorage().write(_boxKey, code); // 这里顺带把接口语言头、埋点、缓存标记一起更新 } }if (Get.locale == locale) return;这行别省。Get.updateLocale会触发整棵树重建,重复调用同一个 locale 会造成没必要的重建,在页面层级深的时候能明显感觉到卡一下。
持久化用GetStorage、SharedPreferences、Hive都行,我选GetStorage纯粹是为了和 GetX 生态对齐。要注意的是读取时机:必须在GetMaterialApp构建之前读到值,否则第一帧会闪一下默认语言,然后才切过来。这也是为什么main里要先await GetStorage.init()。
4. 参数替换、复数与 GetX 不帮你做的部分
4.1 trParams 占位符的正确用法与三个细节
带变量的文案,写法是模板里放@变量名,调用时传trParams:
// 文案 'login_welcome': '欢迎回来,@name', 'cart_summary': '共 @count 件商品,合计 @amount 元', // 使用 Text('login_welcome'.trParams({'name': userName})) Text('cart_summary'.trParams({'count': '$count', 'amount': total}))三个细节必须记住。
第一,trParams的值类型是Map<String, String>,数字要先toString()。传 int 进去编译就报错,这个还算友好。
第二,占位符如果没被替换,会原样留在界面上。比如模板里写了@name,调用时忘了传,用户就看到“欢迎回来,@name”。这个问题在测试阶段极容易被忽略,因为开发环境往往只测主流程。
第三,占位符名不要用中文、不要带空格、不要和文案里的正常符号混淆。我见过有人在模板里写共 @count 件,然后参数名就叫count(带空格),调试了半天。
为了对付“漏传参数”,我在调试构建里加了一层检查:
extension TrX on String { String t([Map<String, String>? params]) { final raw = tr; assert(() { if (raw == this) { debugPrint('[i18n] 缺失 key: $this'); } final placeholders = RegExp(r'@(\w+)') .allMatches(raw) .map((m) => m.group(1)!) .toSet(); final provided = params?.keys.toSet() ?? <String>{}; final missing = placeholders.difference(provided); if (missing.isNotEmpty) { debugPrint('[i18n] key=$this 缺少参数: $missing'); } return true; }()); return params == null ? raw : trParams(params); } }assert块在 release 构建里会被裁掉,不影响线上性能,但开发阶段所有拼写错误、漏参都会在控制台刷出来。这一招帮我们省掉了大量“上线后用户截图说这里有英文”的尴尬。
4.2 复数、量词和性别的轻量封装
GetX 没有 ICU 复数,只能自己按后缀约定:
// 文案 'cart_items_one': '共 @count 件商品', 'cart_items_other': '共 @count 件商品', // 中文不分单复数,写一样即可 'en: cart_items_one': '1 item', 'en: cart_items_other': '@count items', String plural(String baseKey, int count, {Map<String, String>? extra}) { final suffix = count == 1 ? '_one' : '_other'; return '$baseKey$suffix'.trParams({ ...?extra, 'count': '$count', }); }这套约定只覆盖“单数 / 复数”两态。如果是俄语、阿拉伯语那种多态复数,靠后缀就撑不住了,得引入规则表。我的建议是:真要覆盖这类语言,就别硬扛,直接把这一层换成 intl 的Intl.plural,GetX 负责取文案骨架,复数规则交给 intl,两边各干各的。
中文其实没有单复数变化,但英文有,所以即使用户目前只要求中英,也建议一开始就把_one/_other的约定建好,后期加语种不用返工。性别在中文里更淡,但英文的 he/she 会暴露问题,涉及用户称呼的地方建议用中性表达绕开,比如把“他/她的订单”改成“该用户的订单”,比维护一套性别规则省事得多。
4.3 日期、货币、数字交给 intl,别硬塞进翻译表
我见过最离谱的做法是把日期格式也写进翻译表:'date_format': 'yyyy年MM月dd日'。这在同语种内没问题,但一旦要处理“1st”“2nd”“3rd”这种序数词,或者阿拉伯语的数字方向,就彻底失控了。
正确的分工是:GetX 管“说什么”,intl 管“怎么说”。日期、货币、数字、相对时间,全部走intl的DateFormat、NumberFormat,用当前 locale 初始化:
import 'package:intl/intl.dart'; String formatDate(DateTime dt, Locale locale) { final tag = locale.countryCode == null ? locale.languageCode : '${locale.languageCode}_${locale.countryCode}'; return DateFormat.yMMMd(tag).format(dt); }intl需要初始化日期符号数据,一般flutter_localizations已经带上了,不用额外调initializeDateFormatting。但如果你在纯 Dart 环境(比如写单元测试)里用,就得手动初始化,否则会抛 “Locale data has not been initialized”。
货币同理,NumberFormat.currency(locale: tag, symbol: '¥'),符号和位置由 locale 决定,比手写“¥123”靠谱得多。这类格式化结果不要缓存到翻译表里,因为它是随 locale 动态变化的。
4.4 key 命名规范与缺失 key 的自动化检查
key 命名我定了一套很土的规则,但好用:
- 全部小写,单词之间下划线,不用驼峰,避免和 Dart 变量风格混淆。
- 结构是
模块_场景_含义,比如settings_privacy_title、cart_empty_tip。 - 通用文案统一
common_前缀,便于一眼识别复用。 - 带参数的 key 在名字里体现,比如
login_welcome,让人知道它需要@name。
光靠规范不够,得有自动化兜底。我的做法是在test/下放一个纯 Dart 测试,检查各语种 key 集合是否一致,顺带检查占位符是否对齐:
import 'package:flutter_test/flutter_test.dart'; import 'package:your_app/i18n/app_translations.dart'; void main() { final keys = AppTranslations().keys; test('各语种 key 集合一致', () { final base = keys['zh_CN']!.keys.toSet(); for (final entry in keys.entries) { if (entry.key == 'zh_CN') continue; final diff = base.difference(entry.value.keys.toSet()); expect(diff, isEmpty, reason: '${entry.key} 缺少: $diff'); } }); test('同一 key 的占位符一致', () { final pattern = RegExp(r'@(\w+)'); final zh = keys['zh_CN']!; for (final other in keys.entries) { if (other.key == 'zh_CN') continue; for (final k in zh.keys) { final a = pattern.allMatches(zh[k]!).map((m) => m.group(1)).toSet(); final b = pattern .allMatches(other.value[k] ?? '') .map((m) => m.group(1)) .toSet(); expect(a, equals(b), reason: 'key=$k 在 ${other.key} 中占位符不一致'); } } }); }这两个测试跑一遍不到一秒,但拦下的问题非常多。我们有一次加了个新页面,中文翻完了忘了提交英文,CI 直接红,比等测试同学发现早了两天。
5. 踩坑实录与排查速查表
5.1 点了切换按钮但页面没变
这是最高频的问题,我按排查顺序列一下我实际用过的路径。
先看GetMaterialApp有没有被误写成MaterialApp。这是最隐蔽的一种,因为项目初期可能只有一两个页面,用MaterialApp也没报错,translations参数甚至还能传进去(因为参数被忽略),直到第一次切语言才发现完全没反应。
再看切换的那行代码是不是真的执行了。Get.updateLocale是同步的,但如果它被包在一个if里、条件没满足,或者被try/catch吞掉了异常,就静默失败。我习惯在切换后打一行日志:debugPrint('locale -> ${Get.locale}'),一眼看出有没有生效。
然后看文案是不是被提前求值了。前面说过,final String title = 'home_title'.tr;这种写法在initState里执行一次就固定了。排查方法是全局搜.tr,看有没有出现在initState、didChangeDependencies、或者类的字段初始化里。
最后看是不是const缓存。const Text('...')里不可能有.tr,但常见的是static const List<MenuItem> menus = [...],里面存的是已经算好的字符串。这种要改成方法或者getter。
还有一种极少数情况:页面用了AutomaticKeepAliveClientMixin,切语言后页面被保活,虽然GetMaterialApp重建了,但被保活的子树可能没有触发build。这种我一般在didChangeDependencies里手动setState,或者干脆在切语言时清一遍缓存。
5.2 语言包变大之后的加载与拆分
文案从三百条涨到三千条时,我遇到过两个性能问题。一是启动时间变长,二是热重载变慢。
启动变长的原因往往不是翻译表本身,而是keysgetter 里做了合并运算。如果每次读取都...commonZh, ...homeZh, ...展开一次,虽然 Map 展开不慢,但被调用多次就积少成多。解决办法是把合并结果提到顶层常量:
// 顶层 const,只算一次 const Map<String, String> zhCN = { ...commonZh, ...homeZh, ...settingsZh, }; // keys getter 里直接用 Map<String, Map<String, String>> get keys => {'zh_CN': zhCN};热重载变慢则是因为单个文件太大。Dart 的热重载会重新编译被修改的文件,一个五千行的 Map 文件改一个字,重编译时间能到好几秒。按模块拆文件之后就正常了,因为每次只动一个小文件。
还有一个容易被忽视的点:调试时别把整张翻译表打进日志。有人为了看有没有加载成功,debugPrint(keys.toString()),几千条文案刷满控制台,IDE 直接卡住。
5.3 常见问题速查表
| 现象 | 大概率原因 | 处理方式 |
|---|---|---|
| 界面显示的是 key 本身 | key 拼错,或该语种漏翻 | 开assert检查缺失 key,跑 key 一致性测试 |
| 切换语言后部分文字不变 | 文案被提前赋值或const缓存 | 把翻译挪回build内,禁止存字段 |
| 整个应用都不刷新 | 用了MaterialApp而非GetMaterialApp | 换掉根 widget |
| 中文正常,英文全是 key | 语言码大小写不匹配 | 统一用zh_CN/en_US规范写法 |
| 占位符原样显示 | 漏传trParams参数 | 用assert检查占位符与参数集合 |
| 首次启动闪一下默认语言 | 持久化读取晚于首帧 | main里先await读取,再传locale |
| 日期选择器还是英文 | 漏配localizationsDelegates | 补上 Material / Cupertino 三个 delegate |
| 数字和日期格式不对 | 用翻译表拼格式 | 改用intl的DateFormat/NumberFormat |
| 阿拉伯语界面布局错乱 | 用了EdgeInsets.only(left:) | 全部换成EdgeInsetsDirectional |
关于 RTL 我再补一句。GetX 切到阿拉伯语、希伯来语这类语言时,Directionality会自动变成 RTL,但前提是你的布局用的是方向性内边距和start/end对齐。之前项目里有一堆padding: EdgeInsets.only(left: 12),切到 RTL 之后图标全贴在错误的一侧,改起来是一次性的体力活,越早改越省事。
提示:如果产品短期不打算做 RTL 语种,也建议新代码统一用
EdgeInsetsDirectional和AlignmentDirectional,成本几乎为零,将来省大事。
6. 几句不太像总结的经验
GetX 这套多语言方案,我用下来的真实感受是:它的上限不高,但下限也不低,关键看你愿不愿意补那几块短板。短板就三块——缺失 key 的检测、复数规则、格式化能力。前两块用一个 extension 加一个约定就能补上,第三块交给 intl。补完之后,它的开发效率确实比 ARB 那套高出一截,尤其是文案频繁改动的迭代期。
我个人最推荐的一个习惯是:把.tr当成一个“危险 API”来对待。它不是类型安全的,不会报错,出问题还很安静。所以我在团队里要求所有翻译调用都走自己包的.t()扩展,而不是裸用.tr。多敲两个字符,换来的是开发阶段就能看到缺失告警,这笔账怎么算都划算。
另外提一个后续可以扩展的方向:如果你的文案需要运营在后台改,可以做一个远程文案覆盖层——启动时拉一份增量 JSON,合并到本地翻译表之上,用同一个 key 覆盖。因为 GetX 的keys就是个 Map,覆盖逻辑就是把远程的Map<String, String>展开到本地 Map 后面。要注意的是覆盖之后必须调一次Get.updateLocale(Get.locale!)强制重建,否则界面不会更新。这个方案我们跑过一段时间,配合版本号做灰度,效果还不错,前提是本地一定要有一份完整的兜底文案,网络失败时不能白屏。
最后分享一个排查小技巧:当你不确定某段文字到底走没走翻译,把那个 key 的值临时改成一串显眼的字符,比如'home_title': '###HOME_TITLE###',热重载后一眼就能在界面上定位到它。比打断点快得多,尤其是那种藏在列表项、弹窗、SnackBar里的文案。