1. 国际化与主题为什么总是拖到后期才爆炸
先说个我观察到的现象:绝大多数Flutter项目,在立项初期根本没人提“国际化”和“主题系统”这回事。产品原型用的是硬编码中文,设计稿里的颜色值直接以十六进制常量出现在代码里,ThemeData只用到了系统默认值。等到市场反馈出来,说要出海、要支持多语言了,或者大客户说要深色模式了,团队才熬夜翻代码——一个页面一个页面地改字符串、一层一层地包Theme。那种痛苦我经历过,而且不止一次。
所以“避免后期大改”这道题,本质不是“国际化怎么写”“主题怎么写”,而是在前期愿不愿意为未来的不确定性付费。很少有人真的因为“以后可能用得上”而主动加抽象层,因为需求不明确的时候,加抽象层会被喷过度设计。但国际化和主题恰好是那种“你几乎可以确定未来一定会变化”的横切关注点——应用只要活着,就一定会有新语言、新地区、新主题风格。你前期不付设计成本,后期就得付重构成本,这个杠杆率非常不划算。
那为什么会爆炸?我捋了几种典型的后期痛场:
第一,字符串散落在组件里。最原始的做法,Text('保存')、Text('取消')直接写在build方法中。到做国际化的那天,全项目搜索中文——好一点的上百处,复杂的项目几百处。每一处都要替成AppLocalizations.of(context)...
第二,日期、数字、货币格式混在业务代码里。你以为国际化只是翻译文字,实际上阿拉伯语的从右到左布局、俄语的长单词截断、德语的复合词,全部会牵扯排版问题。
第三,颜色、字体、间距直接硬编码。ThemeData里设置了primarySwatch,但个别页面为了“好看”直接用了Color(0xFF345678),后期支持暗黑模式的时候,连哪个颜色是主题色、哪个是状态色、哪个是临时色都分不清。
第四,主题只考虑了颜色,忽略了组件形态和文案长度。夜间模式不只是把白色背景换成深色背景,字体粗细、卡片圆角、阴影浓度、颜色对比度都要联动,如果整套主题体系没有设计好,后面每次加组件都要给二十个页面补样式。
第五,状态管理和主题切换的关系没想清楚。主题切换之后,整个组件树重绘,哪些页面应该跟随、哪些页面需要用独立风格、过渡动画怎么做,没有系统设计的项目到了这一步只能把所有页面都强制重绘,就会出现卡顿或者闪烁。
这五个坑几乎是所有“后期大改”案例的通病。下面的几个章节,就是基于这些坑总结出来的应对方案——不是理论推演,是我自己在几个实际项目里反复验证过的做法。
一个前提说明:这篇文章说的“国际化和主题系统”,针对的是中小规模和中等规模项目。超大项目有专门的i18n基础组件团队,做法会更重,这里不做讨论。
2. 国际化不是翻译字符串,是设计“字符串的身份证”
很多Flutter开发者接触国际化,是从pubspec里加flutter_localizations和intl开始的,然后照着官方文档建l10n.yaml、写ARB文件,跑一下flutter gen-l10n,发现Text里能显示英文了,就觉得国际化做完了。这其实只完成了最表层的工作。真正能在后期不爆炸的国际化设计,比这多两层。
2.1 字符串ID的命名规范:它是你的长期接口
ARB文件里的key其实就是一种接口。你以后翻译成法语、日语、阿拉伯语,key不变,变的只是value。所以key的设计质量,决定了未来一年里你找字符串的成本。
我见过最糟糕的key命名是什么?app_text_1、button_text_save这样的。前者没有语义,后期根本不知道哪条对应哪个位置;后者把位置绑进去了,如果保存按钮从页面A挪到弹窗B,key就失去意义了。
我现在用的命名规范是:领域_动词性描述。比如:
profile_updateSuccess表示个人资料页的“更新成功”dialog_confirmDelete表示对话框的“确认删除”error_networkTimeout表示网络超时的错误文案common_cancel表示通用“取消”
这样做的核心逻辑是:key描述的是语义,不是位置。同一个“保存”按钮出现在五个页面,只要语义一样,就用同一个key。语义不同(比如一个保存草稿、一个保存并发布),必须用不同的key。判断标准很简单——如果翻译人员过来问你这句在什么场景下说的,你能不能答上来。答不上来的key就是设计失败的key。
另外,ARB的id不要用驼峰以外的风格,不要用带横线的,别问为什么,翻译工具解析的时候大小写和特殊字符会让你欲仙欲死。
2.2 语法复用的核心:plural、gender、select
这是国际化里最容易漏但后期最救命的部分。中英文的复数表达差异大,俄语有更多复数形式,阿拉伯语有双数形式。你前期图省事写“${count}条未读消息”,到支持阿拉伯语的时候就直接崩——阿拉伯语里读1条、读2条、读3-10条、读11条以上是完全不同的写法。
Flutter的intl库里包了ICU语法,ARB文件里可以这样写:
{ "notification_unreadCount": "{count, plural, =0{暂无未读消息} one{你有1条未读消息} other{你有{count}条未读消息}}", "notification_placeholder": "{count}", "@notification_unreadCount": { "placeholders": { "count": { "type": "int" } } } }生成代码里,对应的方法是notificationUnreadCount(int count),业务代码直接传数字,多语言规则的转换全部交给l10n层。同样的道理,gender用法类似,select适合做“根据状态选文案”的场景。这三个语法,是你应对任何语言形态的底气,宁可前期多写两行占位符,不要等到新语言来了再逐个补。
2.3 上下文注释必须写在ARB里
这是我踩过坑才重视起来的细节。ARB文件支持@键名下的description和placeholders,但大多数项目里这几个字段是空的。翻译外包给你的翻译机构时,对方拿到的是一堆dry的英文短语——比如acknowledge、dismiss——不写上下文他们只能瞎猜,猜错的结果就是产品在某个海外市场闹笑话。
我的习惯是每条ARB都写description。举个例子:
{ "order_refundConfirmTitle": "确认退款?", "@order_refundConfirmTitle": { "description": "用户点击退款按钮后,弹窗标题显示的确认文案,语气需为疑问句" } }多写这行不会花掉你三十秒,但省掉的沟通成本是巨大的。
建议所有Flutter项目在第一天就配好l10n.yaml,然后强制代码评审里约束硬编码字符串。macos/iOS的评审规则直接设一个fail检查:代码里出现中文、日文、韩文字符的直接打回。宁可初期难受一点,不要两年后花两周时间来敲打全项目。
3. 主题系统设计的边界:哪里该用ThemeData,哪里该用组件级样式
话题转到主题系统。Flutter的主题体系其实是分层的,很多人只知道MaterialApp里的theme参数,最多再用一个darkTheme,实际上这几层关系都不清楚,导致后期换肤的时候没法局部控制。
这四层我用实际项目里的关系来梳理一下:
| 层级 | 承载物 | 作用的范围 | 典型错误 |
|---|---|---|---|
| 全局主题 | MaterialApp的theme/darkTheme | 全局生效 | 全局ThemeData里写死业务色 |
| 组件主题 | ThemeData里TextTheme、CardTheme等 | 某一类组件 | 用.copyWith到处覆盖 |
| 扩展主题 | ThemeExtension | 业务自定义样式 | 硬编码颜色满天飞 |
| 局部样式 | 组件内直接设置style | 单个组件 | 为省事忽略设计规范 |
第1和第2层的用法官方文档写得挺清楚了,我不再赘述。第3层ThemeExtension是目前自定义主题的正道,也是很多人没掌握好的一层,我多讲两句。flutter早期没有ThemeExtension的时候,大家普遍的做法是把自定义颜色放到一个全局类里静态常量,比如AppColors.primary,AppColors.background。这个方法在“只有一种主题”的时期没什么毛病,但一旦要支持暗黑模式,就出大问题——静态常量没有办法跟随Brightness切换。
ThemeExtension的用法,本质上就是给“主题”下定义:你的业务系统里有哪些颜色、字体、间距是跨组件复用的,把它们定义成一个类,然后给light和dark各给一套实例。
// 自定义主题扩展 class AppColors extends ThemeExtension<AppColors> { const AppColors({ required this.brandPrimary, required this.brandSecondary, required this.surfaceSubtle, required this.textHighEmphasis, }); final Color brandPrimary; final Color brandSecondary; final Color surfaceSubtle; final Color textHighEmphasis; @override AppColors copyWith({...}) => ...; @override AppColors lerp(AppColors? other, double t) { if (other == null) return this; return AppColors( brandPrimary: Color.lerp(brandPrimary, other.brandPrimary, t)!, // ... } }然后在MaterialApp里配置:
MaterialApp( theme: ThemeData( extensions: [AppColors.light], ), darkTheme: ThemeData( extensions: [AppColors.dark], ), );用的时候就通过Theme.of(context)拿到对应的颜色,而不是直接引用常量。
final colors = Theme.of(context).extension<AppColors>()!;这整套写下来,一开始比直接写Color(0xFF……)麻烦,但换肤能力强了不止一个档次。更关键的是,它逼着你把“业务色”从“材料色”中剥离出来。以后设计要改品牌色,你只需要修改AppColors.light和AppColors.dark这两个实例,而不必去几十上百个文件里找const Color(0xFF3182CE)。
3.1 组件级主题的克制使用
第4层边界——组件内部直接放style,也是很常见的大改源头。flutter的组件样式写法很自由,Text可以直接放style,Continer可以直接改decoration,开发者为了看得见摸得着,往往会直接写。这就造成了样式散落。
我的约束原则是这样的:
组件级style优先从ThemeData去取,只有“一次性用途”才允许局部覆盖。所谓一次性用途,就是确认这个样式不会在第2个地方出现。比如页面顶部那个专门的大标题,size 28,weight w600,color用textHighEmphasis,它只出现在这里一次——那可以直接在Text里写style。但卡片标题呢,10个页面都会用到,就必须抽到TextTheme的某个命名里。
这个判断标准如果从一开始就抓,后期主题演进的时候只动TextTheme,页面级几乎不用动。
3.2 动态切换主题的正确状态管理姿势
主题的动态切换,是很多人容易写崩的地方。常见做法是在顶层包一个ValueNotifier ,点了切换按钮之后setState,把MaterialApp的themeMode传进去。代码能跑,但有个实际问题:每个使用Theme.of(context)的组件都会以它为依赖重建,如果页面层级深、组件多,切换瞬间可能掉帧。
我现在的做法是在入口组件用ListenableBuilder包在MaterialApp外层,切换时更新ThemeMode,同时过渡动画交给AnimatedTheme来做:
ListenableBuilder( listenable: themeController, builder: (context, _) { return AnimatedTheme( data: themeController.buildTheme(), duration: const Duration(milliseconds: 300), child: MaterialApp( theme: themeController.lightTheme, darkTheme: themeController.darkTheme, themeMode: themeController.mode, home: const HomePage(), ), ); }, );AnimatedTheme会根据ThemeData的差异自动做颜色和样式的渐变动画,观感好很多,也不会对整个组件树做“全部闪烁”的重建。如果视觉规范允许,这个方案比直接setState顺畅得多。
主题分层做到这个程度,之后品牌改色、新增暗色模式扩展、特定页面独立配色,都只动少量文件,不至于从根上推翻重来。
4. 从第一天起就建立国际化与主题的“结构约定”
工具和API都搞清楚了,还差一件事:规矩。因为任何一套系统都是在人的协作里演化的,如果你不立规矩,再好的架构也会被“图省事”的人逐步腐蚀掉。这个章节讲的是我实践下来最有效的几项“约定”,配合代码评审和工具链一起用。
4.1 目录结构的分层约定
我的Flutter项目里,多语言资源和主题相关的资源一定放在lib目录下独立分层,而不是散在features里:
lib/ core/ l10n/ l10n.yaml app_en.arb app_zh.arb app_fr.arb theme/ app_colors.dart // AppColors ThemeExtension app_text_styles.dart // 文本样式体系 app_theme_data.dart // light/dark ThemeData构建函数 theme_mode_controller.dart // 切换控制器 features/ ...这个结构的核心是:l10n和theme是全局基础能力,它们不应该属于任何一个业务模块。如果哪天要拆成多包结构或者接模块化,core里的内容可以直接抽成共用的包。
4.2 要不要用国际化状态管理库
有一种方案是用state管理库做语言的动态切换。传统做法是setState(() {})来刷新MaterialApp,但实际项目里不建议纯setState。推荐结合GetMaterialApp或flutter_localizations结合某个状态管理方案来管理locale。工程量不大,但如果你要支持“应用内切换语言且不重启”,就必须考虑locale状态放在全局store里。
我的经验是要“能切而不过度切”。产品通常要的是:设置页改语言,马上全局生效。这需要一个全局locale状态,和主题的themeMode一样挂在外层。用一个ChangeNotifier就好,不要再引入复杂的redux saga之类,杀鸡用牛刀了。
代码结构如下:
class LocaleController extends ChangeNotifier { Locale _locale = const Locale('zh'); Locale get locale => _locale; Future<void> setLocale(String languageCode) async { _locale = Locale(languageCode); notifyListeners(); } }然后在入口处监听它,并把locale传给MaterialApp的locale属性。没有重启,没有context跨层传递问题,简单够用。
4.3 代码评审里的自动化和手动化双防线
自动化防线的目标是拦住“硬编码字符串”和“硬编码颜色”。“硬编码字符串”虽然不能完全靠lint自动拦截,但只要开拓者花一点功夫在bug catch上,还是可以拦住大部分。通常我用两套手段:
- 项目里配置一个自定义lint规则:检查Text、TextFormField的label等组件属性,如果参数是字符串字面量(StringLiteral),给出warning;
- 代码评审协议里要求:新增的样式与颜色只能通过
Theme.of(context)或Theme.extension获取,不能直接引用Color常量。
手动防线是每轮代码评审里我必看三件事:
- 有没有中文/英文硬编码在Widget里;
- 有没有直接new Text(‘hello’)这种不经过AppLocalizations的写法;
- 有没有一个组件里直接写了超过三个样式属性,而项目里同样组件还在用另外一套。
这三条看起来都是琐事,但它们在前期能挡住百分之九十的后期大改。
4.4 组件样式清单:别让每个页面都“自由发挥”
这个问题比前面三个更隐蔽也更重要。主题系统做得再好,如果没有人定义“到底哪些样式是应该被复用的”,开发人员还是会以“所见即所得”的方式去自由发挥。两个页面都有标题,一个有阴影一个没阴影,一个有13号字一个有14号字——到了UI统一性检查那天,全部要推倒重来。
所以项目启动的第二周我就让开发团队列出“样式清单”,把会用到的文字样式、颜色角色、间距规则、圆角尺寸全部登记在案,做成一个md文档,挂在项目文档库里。此后任何页面开发,先查清单再写代码。如果清单里没有你要的样式,你得举证为什么需要新增——这个过程本身就把“设计随意变更”挡在门外。
这个清单不用很长,核心是十来条规则,例如:
- 标题文字:TextTheme.titleLarge,weight半粗
- 正文:bodyMedium,颜色用textHighEmphasis
- 卡片圆角:12
- 页面间距:水平16,垂直24
一开始就列好了,后面基本不存在“这里得多加一种灰色”的情况。
5. 真到了拆掉重做的境地:摸底、鉴定、渐进迁移三件套
文章到这里一直在讲“从第一天该怎么做”,但现实是,很多人拿到这篇文章的时候,项目已经到了一团乱麻的状态——几千处硬编码字符串,几十种颜色散落,需求方突然告诉你下个月要做三套语言和暗黑模式。不用慌,先干三件事。
5.1 摸底:先盘清楚家底
第一件事,把硬编码内容全部捞出来。写个临时脚本,遍历lib目录下的dart文件,正则匹配两种模式:字符串字面量里的中文字符([\u4e00-\u9fa5]),以及Color(0x开头的数字常量。跑一遍,统计文件数和出现次数,你就知道工作量级了。
这个摸底不是为了“看有多糟”,而是为了定优先级。比如,公共组件库里的硬编码(比如一个页面骨架里的示例文字)优先清理,因为所有页面都在用;一个用户几乎不进的功能页里的硬编码,可以放到最后。也就是老人常说的,抽丝剥茧先从线头开始。
5.2 鉴定:哪些值得留,哪些直接重写
摸底之后,你会发现字符串其实分两类:一类是纯展示文案,比如“暂无数据”“加载中”,这种直接机械替换;另一类是带业务拼装、带参数的,比如“您的订单将在{date}前送达”“正在删除{count}个项目”,这种不能简单复制翻译,需要走一遍ARB的placeholders模板。
颜色也一样。你最终会把颜色归纳为若干个角色,但在那之前得先判断,当前那些硬编码颜色是多“哈希值”数量,如果超过100处相近色,我认为与其逐一生映射,不如趁早统一。曾经有一个项目,光灰色就有七种——看起来是黑色系列但细看都有细微差异,这种直接统一成一种表面色就行,节省未来的维护成本。当然,判断哪个颜色地位高级、哪个临时性强,得靠二八定律:大多数页面的颜色集中在2-3个主角色上,先用主角色的归纳去映射,捉大放小。
5.3 渐进迁移:从公共组件和核心链路开始
迁移策略我建议按三步走:
第一步,搭骨架。建好l10n的架子,生成AppLocalizations并接入,配好ThemeExtension的light/dark,但不用急着改业务。项目能编译能跑,只是在根部预留了扩展点。
第二步,把公共组件迁移过去。按钮、对话框、列表通用状态(空、加载中、error),这些组件使用频率最高、牵涉面最广,改好后收益最大。字符串和颜色以Component为单位收敛,而不是以页面为单位收敛,因为组件改动一次,所有引用页面都受益。
第三步,按页面价值排序任务,能改的就编码,改不了写TODO标记,杜绝边改边补新硬编码。每次提交控制在合理范畴,让代码评审能够看到差异,也方便出问题时回滚。
渐进迁移最大的好处是每一步的回归风险都小——改公共组件不会引起全局色变,改少量页面不会引起全应用布局崩溃。如果你们希望通过一次“硬编码大扫除”一星期完事,我基本可以断定会出线上事故,建议放弃这种幻想。
迁移期间还有一个容易被忽略的细节:旧的字符串和新的ARB key会在代码里并存一段时间。为了防止混乱,可以统一在ARB里留一个legacy_前缀的命名空间,把正在迁移中的旧文案先集中放,迁移完成的再改回正常key。代码里加一个deprecated的注解标记,lint里加一条“legacy_不被允许出现在新代码”的限制,这样就能保证迁移方向的单向性,防止回潮。
这个“三件套”做完,项目会经历一个从混乱到有序的过渡。过程中肯定会有觉得“早知道前期好好设计”的懊恼,但这也是最真实的成长——设计国际化和主题系统,最难的从来不是写代码,而是在所有人都觉得“用不上”的时候,你愿意多花几天想清楚系统该怎么长。
最后说一句我个人的习惯:不管项目多小,哪怕只是个Demo,只要不是绝对的一次性原型,我都会花半天时间把国际化和主题的架子搭好,因为“以后改不了”和“以后有机会改”,中间只差这半天。反正多语言和换肤就算现在没需求,把地方留出来也不错,等技术债长出来再去补的人,都懂那是什么代价。