Flutter 在 OpenHarmony 上跑起来之后,第一个让我头大的不是渲染性能,也不是插件适配,而是国际化。项目早期只有几十个 Key-Value 字符串,直接写个 Map 随手取用也没觉得有什么问题,等翻译文案涨到几百条,再叠加参数、复数、多语言切换,代码里到处是魔法字符串,改一个 key 要全局搜索半天。这时候我注意到 slang 这个 i18n 代码生成工具,它能在编译期把翻译文案变成类型安全的 Dart 对象,彻底告别手写 key。这篇内容就是我在 Flutter for OpenHarmony 下使用 slang 的完整落地记录,从环境准备、配置生成到鸿蒙工程集成,以及我踩过的坑,希望能帮到要做多端统一国际化的团队。
1. 为什么我放弃手写 Key-Value:slang 的类型安全到底在解决什么
1.1 项目里那些让人崩溃的字符串 Key
早期做 Flutter 国际化,最容易上手的方式就是建一个 Map<String, String> 或者一个静态类,里面写满 static const String homeTitle = 'xxx'。项目小的时候这样确实够用,但一旦翻译条目多起来,问题就很现实:英文、简体中文、繁体中文各一份,还有日语、韩语,每次新增文案要在好几个文件里同步改一遍,漏一个就出现空白页面。
更难受的是 key 本身没有约束。比如原来定义 orderDetail => 'Order Detail',后来产品要把这个字段改成 orderInfo,你需要全局搜一遍所有引用点。如果有拼写错误,框架直接把这个 key 原样输出到 UI 上,用户看到一坨英文标识符,这属于线上事故级别的问题了。
还有参数问题。翻译文案里经常有 Hello, {name} 这种占位符,手写 Map 时没人约束你传几个参数,少传了运行时报错,多传了没人提醒。复数更是重灾区,英文有单复数区分,中文没有,用 Map 自己拼字符串,遇到列表数量变化的文案基本靠感觉写。
1.2 官方 gen-l10n 的边界在哪
Flutter 官方其实提供了 gen-l10n 工具,配合 ARB 文件也能生成 Dart 代码。它的思路是通过 flutter_localizations 那套机制,把 AppLocalizations.of(context) 变成可调用的方法。很多项目用这个方案解决了一部分手工维护的问题,但我自己的体验是它有几个硬伤。
第一,生成的 API 是方法调用,比如 AppLocalizations.of(context)!.helloWorld,虽然 key 有了编译期检查,但方法名、参数做得比较死板,复制变化、复数处理、条件文案这些高级能力写起来非常繁琐。第二,ARB 文件虽然格式统一,但写起来并不轻松,占位符要实现成元数据字典,可读性不高。第三,它对多语言回退、运行时动态改语言的支持不算顺手,想做一个 App 内切换语言,你要自己维护 MaterialApp 的 locale 状态,还得处理 locale 回退链。
不是说 gen-l10n 不能用,而是当项目到了几百个 key、四种语言以上,并且要支持参数和复数时,它的开发体验不够高效。
1.3 类型安全到底在安全什么
slang 的核心卖点是“类型安全”,很多人理解成“编译器帮我检查 key 是否存在”,这只是表面一层。真正有价值的是下面几件事。
一是 IDE 补全和重构。生成的 Dart 类里每个翻译条目都是实际存在的字段,比如 t.home.title,你输入 t.home.,编辑器会列出这个 namespace 下所有可用的翻译项。复制、重命名也完全交给 IDE,换个 key 名称,所有引用点一起变,不会再出现漏改。
二是参数个数和类型检查。如果翻译项声明了 {count} 占位符,生成的翻译方法就一定是 t.cart.itemCount(count),少传一个参数编译直接报错。这个约束看起来简单,实际省了我大量测试时间,因为运行时错误变成编译错误后,回归测试范围小很多。
三是复数类别的显式表达。slang 会根据翻译文件里的复数形式,在 Dart API 中生成对应的条件分支。中文没有复数概念,英文有,你不需要自己在代码里写 count > 1 ? 'items' : 'item',工具帮你处理了 locale 对应的复数规则。
四是平台之间的行为一致性。同一个翻译文件,在 Android 上显示英文、在 OpenHarmony 上显示中文,因为翻译数据源相同,展示行为是一致的。对比一下我早期用 MaterialApp 内置的 locale 加手写 Map 的方式,平台差异导致的问题很少,因为解析逻辑统一放在生成代码里了。
1.4 slang 的关键能力和适用场景
slang 官方支持的翻译文件格式有 JSON、YAML、ARB、CSV,默认我建议用 YAML,层次清晰,注释友好。生成方式也很简单,slg generate 一条命令,支持监听模式 slg watch,文件保存后自动生成。
它最大的好处是生成的 API 完全脱离 BuildContext,大多数翻译方法可以直接在非 Widget 类里调用,不像 gen-l10n 必须在 BuildContext 下面拿对象。这意味着网络层、工具类、服务里的提示文案也能用同一套翻译体系,这个是很多团队忽略的点。
适用场景我总结一下:Flutter 应用要跑在 Android、iOS、OpenHarmony 多端;翻译文件多语言数量超过三种;文案频繁带参数和复数;有 App 内切换语言的需求。小项目只有十几条文案的话,老实说手写 Map 更快,没必要上来就引入代码生成。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Flutter for OpenHarmony 环境准备:先把鸿蒙跑起来再说
2.1 鸿蒙 Flutter 版本怎么选
Flutter 官方并不直接支持 OpenHarmony,真正可用的 OpenHarmony Flutter 是通过 OpenHarmony SIG 维护的 flutter_flutter 分支。这个分支基于社区 Flutter 版本做了大量平台适配,把 Flutter 引擎跑在 OpenHarmony 的 OHOS 体系之上,同时保留原有 Flutter API。
选择版本时不要盲目追新,需要先确认你本机的 OpenHarmony SDK 和 DevEco Studio 版本。一般来说,Flutter for OpenHarmony 的分支版本会滞后于 Flutter 官方主版本,所以如果团队里有多个 Flutter 项目,优先让所有项目统一到同一个 flutter_flutter 版本,避免切换 SDK 时出现一堆依赖不兼容。
我在实际项目中用的是 OpenHarmony 4.x 版本的 SDK,对应的 flutter_flutter 分支是 3.22 系列。验证版本是否匹配的最快方式是拉一个官方示例工程跑一遍,能生成 ohos 目录并装进模拟器,说明基础链路是通的。
2.2 工程结构和 ohos 目录
使用 flutter_flutter 创建 OpenHarmony 工程的命令和标准 Flutter 几乎一样,区别在于创建完工程后会多出一个 ohos 目录,这个目录是 OpenHarmony 原生壳工程,类似 Android 的 android 目录,不过它用 HAP 的工程结构。
ohos 目录里的内容不能用标准 Flutter 的插件模型直接兼容,OpenHarmony 的 Flutter 插件需要以 HAR 包形式集成。所以如果你要从 pub.dev 拉取某个插件,要确认它有没有 OpenHarmony 适配版本,否则编译时会报 MissingPluginException。
这个环境细节对 i18n 有什么影响?影响不大,因为 slang 是纯 Dart 代码生成工具,不依赖任何原生 Channel,不管是 Android 的 APK 还是 OpenHarmony 的 HAP,生成出来的翻译类都是同一套 Dart 代码,这是它适配鸿蒙的优势所在。
2.3 pubspec 依赖和初始化命令
slang 的使用分为两部分:开发期工具和运行期库。pubspec 里要加两个依赖,一个放在 dev_dependencies 用于生成代码和提供命令行工具,另一个放在 dependencies 用于运行库。
dev_dependencies 推荐写 slang 的对应版本,dependencies 写 slang_flutter,它们配套版本号保持一致。配置完成后,在项目根目录执行:
bash复制dart run slang init
这个命令会在项目里生成一个 slang.yaml 配置文件,默认的翻译文件目录会放在项目根目录下。之后执行:
bash复制dart run slang
或者为了方便实时生成:
bash复制dart run slang watch
第一次生成代码后,pubspec 里还需要把生成目录注册到 flutter 的 generate 配置下面,让 Flutter 知道这是工程的一部分。具体来说,flutter: generate: true 这个开关和 slang 没有冲突,但生成代码的文件路径需要保证不影响原生构建。
2.4 为什么纯 Dart 生成方案适合鸿蒙生态
OpenHarmony 的 Flutter 生态目前最大的痛点是插件缺失,很多常用的 Flutter 插件在鸿蒙上没有原生实现。i18n 这类能力如果依赖原生插件,适配成本会很高。
slang 走的路线是纯 Dart 代码生成,生成的翻译对象不依赖任何原生模块,完全靠 Dart 语言本身解析 JSON/YAML 数据。这意味着同一份翻译产物在 Android、iOS、OpenHarmony 上表现完全一致,不需要额外的平台适配层。对于想要一套代码跑多端的团队,这个特性相当友好。
3. slang 配置与代码生成实操:从 pubspec 到 t.xxx 全链路
3.1 翻译文件格式和目录结构
slang 初始化后默认会生成一个类似这样的目录:
code复制assets/i18n/
en.json
zh.json
它支持 JSON 和 YAML,我个人更推荐 YAML 格式,原因有两个:一是 YAML 支持注释,翻译文件里可以写说明,告诉翻译人员这段话的上下文;二是 YAML 的层级结构写起来比 JSON 少很多引号和冒号,几百条文案维护起来更轻松。
翻译文件里的层级可以按模块组织。比如:
yaml复制home:
title: Welcome
description: This is a demo
order:
detail: Order Detail
status:
pending: Pending
shipped: Shipped
对应的生成 API 就是 t.home.title 和 t.order.status.pending,结构清晰了很多。你可以按页面组织,也可以按功能模块组织,团队内统一风格就好。
3.2 slang.yaml 配置逐项拆解
初始化完成后,我会把配置文件调整成下面这样:
yaml复制base_locale: en
fallback_strategy: base_locale
input_directory: assets/i18n
output_directory: lib/i18n
output_file_name: translations.g.dart
input_file_pattern: .yaml
key_case: camel
translate_meta:
skip: true
base_locale 指定基础语言,slang 会以这个语言文件为准推断所有翻译项的字段结构,其他语言如果缺 key 会在生成时给出提示。fallback_strategy 表示当当前 locale 找不到翻译时,回退到 base_locale,这个配置非常关键,特别是 OpenHarmony 设备上语言列表可能与浏览器默认语言不完全对齐的场景。
input_file_pattern 这个参数决定了工具扫描哪些文件。默认情况下它只会扫 en.yaml、zh.yaml 这种带语言代码的文件,如果你把配置文件根目录放了一堆业务 YAML,需要检查这个 pattern 是否正确,否则会出现生成文件里全是空的。
3.3 生成代码后的核心 API
dart run slang 执行完毕后,lib/i18n 目录下会生成 translations.g.dart 和 translations.g_local.dart 一类文件。默认生成的翻译访问对象是 t。
用法很直观:
dart复制import 'package:myapp/i18n/translations.g.dart';
final t = Translations.byLocale('zh');
String text = t.home.title;
这个 t 对象就是类型安全的翻译入口,你可以在任意 Dart 文件里导入使用,不一定要有 BuildContext。这点比 gen-l10n 方便很多,我在 ViewModel、Repository、甚至有状态管理的 Controller 里都能直接调用。
再看创建 MaterialApp 时的用法:
dart复制runApp(const App());
class App extends StatelessWidget {
const App({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
localizationsDelegates: AppLocale.localizationsDelegates,
supportedLocales: AppLocale.supportedLocales,
locale: const Locale('zh'),
home: const HomePage(),
);
}
}
AppLocale.localizationsDelegates 会把 slang 的翻译数据挂到 Flutter 的本地化代理链中,这样页面里 BuildContext 相关组件也能正确解析。它和 flutter_localizations 的 GlobalMaterialLocalizations.delegates 可以同时配置,顺序上一般把 AppLocale 放在前面。
实际调用时还有一个很舒服的写法,通过 BuildContext 的扩展方法:
dart复制Text(context.t.home.title)
只要在 Widget 里导入 translations.g.dart,再配合代码编译,完全不需要手动传 Locale。
3.4 占位符、复数与运行时条件文案
翻译不可能永远是静态字符串,业务里最常出现的就是 Hello, {name} 这类占位符。slang 的做法是在 YAML 里直接写占位符:
yaml复制greeting: Hello, {name}
生成后的 API 就是:
dart复制t.greeting(name: 'John')
这里传入的参数如果少了或者类型不对,编译会直接报错。比手拼字符串靠谱太多。
复数场景用它写也很顺手:
yaml复制cart:
itemCount: >
{count, plural,
=0 {No items}
=1 {One item}
other {{count} items}}
生成的调用是 t.cart.itemCount(count: 5),它会根据当前 locale 的复数规则自动选择对应的文案,英文对应 5 items,中文对应 5 个商品,你不用在业务代码里写 if-else。
还有一类是条件文案,比如根据性别显示不同称呼。slang 支持在翻译项中定义 parameters 和 context,但个人经验是复杂逻辑不要塞进翻译文件,优先保证编码逻辑简单,翻译文件保持纯粹的文案映射。遇见性别、单复数外加参数互相叠加的场景,我一般拆成多个翻译项,在业务代码里用 Dart 的组合逻辑完成,这样后续翻译人员接手也容易理解。
3.5 多模块多包场景下的配置策略
如果你的项目是 monorepo 多模块结构,或者多个 Flutter package 共享一个翻译体系,需要注意生成类的命名冲突。slang 默认生成 LocaleSettings、AppLocale、Translations 这些类,多个包同时生成时容易出现同名类冲突。
解决办法有二。一是每个包修改 output_file_name 和 output_class_name,让类名带包名前缀。二是在根 package 统一产出翻译文件,其他 package 只作为依赖引用根包的生成代码。第二种方案在大中型项目中更合理,因为翻译文案集中管理,后期接入翻译平台也比较方便。
我踩过的坑是基础包和业务包各自生成了一套翻译类,结果在 UI 层导入时 IDE 自动带入了错误的包路径,编译报了一堆类型不匹配。后来统一到根包生成,子包只写 import 'package:app_core/i18n/translations.g.dart',这个问题就没了。
4. 把 slang 生成的翻译体系接入鸿蒙应用:MaterialApp、语言切换与打包
4.1 MaterialApp 中注册 locale 的正确打开方式
接 OpenHarmony 工程时,最容易出问题的是 MaterialApp 的配置。Flutter 的 locale 参数没有指定时,引擎会跟随系统语言,但 OpenHarmony 的系统语言返回代码和 Android/iOS 不完全一样,处理不好会导致 App 启动后显示英文而不是中文。
我的做法是在 App 初始化时显式指定 locale,不让系统语言猜测:
dart复制void main() {
WidgetsFlutterBinding.ensureInitialized();
LocaleSettings.setLocaleRaw('zh');
runApp(const App());
}
在 MaterialApp 里再配合 AppLocale.localizationsDelegates 和 supportedLocales,这样无论系统当前是什么语言,App 内都能稳定得到中文 UI。
需要注意的细节是 supportedLocales 里要包含所有要支持的语言,比如:
dart复制supportedLocales: AppLocale.supportedLocales,
这个列表默认由 slang 根据翻译文件生成,不需要手写。
4.2 页面内使用的三种典型姿势
在页面代码里,我常用的有三种姿势。
第一种,直接通过 BuildContext 获取,适合需要跟随系统语言变化的页面:
dart复制@override
Widget build(BuildContext context) {
return Text(context.t.home.title);
}
第二种,在 Widget 的 build 外使用,比如在 initState 里初始化某些文案,要先获取 t:
dart复制final t = Translations.byLocale('zh');
第三种,非 Widget 类中直接使用,比如 Repository 层返回错误信息。这一点对鸿蒙这类多端 App 尤其有用,因为很多业务逻辑不在 Widget 里,但展示的文案仍然需要按当前语言输出。
4.3 App 内动态切换语言
OpenHarmony 设备上,用户不一定希望跟随系统语言,很多 App 内置语言切换功能。slang 对这块的支持很完善,核心是运行时改变 LocaleSettings:
dart复制LocaleSettings.setLocaleRaw('en');
调用之后,slang 会通知所有监听 LocaleSettings 的 Widget 重建。你需要在 Widget 里监听变化,最简单的做法是用 LocaleSettings.override 提供的 stream,配合 ValueListenableBuilder 或 StreamBuilder 重建页面。
更简洁的方案是用 slang 自带的 FlutterTranslations 组件。在 MaterialApp 外面包一层:
dart复制runApp(
FlutterTranslations(
child: const App(),
),
);
然后在 MaterialApp 里设置 locale: LocaleSettings.currentLocale,切换语言时,整棵 Widget 树会自动刷新,省去了手动管理监听器的过程。
4.4 与系统语言联动和回退逻辑
App 内切换语言后,用户重启 App,通常希望保留上次的选择。slang 本身不提供持久化能力,需要自己把选择的语言写入本地存储。我的做法是在 LocaleSettings.setLocaleRaw 之后,同步把语言码写入 shared_preferences,App 启动时先读本地值,有值就用本地值,没值再跟随系统。
这个逻辑里有个隐藏坑:OpenHarmony 系统语言的 locale 标识,比如系统的某区域语言可能返回 zh_Hans_CN,而你的翻译文件只有 zh,如果不做映射,slang 找不到精确匹配时会按 fallback_strategy 回退到 base_locale,很多用户会因此看到英文。所以在初始化时建议做一个归一化映射:
dart复制LocaleSettings.setLocaleRaw(
normalizeLocale(Platform.localeName)
);
归一化逻辑就是把带地区后缀的语言码映射到基础语言码,zh_Hans_CN 归一到 zh,en_US 归一到 en。
4.5 鸿蒙打包与产物校验
OpenHarmony 打包 Flutter 工程,整体流程是用 DevEco Studio 打开 ohos 目录,构建 HAP 包。这里要确认 slang 生成的代码被打进了产物。
最容易踩的坑是生成目录误写到了 build 下,而这个目录在打包时会被清理或不参与编译。建议把 output_directory 设置到 lib/i18n 这种源码目录,并且不要把它加进 .gitignore,这样 CI 构建时不需要先执行生成命令,代码直接进版本库,减少环境不一致导致的构建报错。
打包完成后,我还会在真机上切换一遍系统语言,快速验证:中文、英文、繁体,App 内语言切换、重启后恢复上次选择,这几个用例覆盖了 90% 的国际化回归场景。
5. 常见问题与避坑实录:鸿蒙 + Flutter 国际化排查速查
5.1 问题速查表
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 生成代码里没有新增 key | 输入目录 path 或 pattern 配置错误 | 检查 slang.yaml 的 input_directory 和 input_file_pattern |
| 热重载后翻译不生效 | slang 没有开 watch 模式,或生成代码没保存 | 执行 dart run slang watch,确保生成文件落盘 |
| 页面显示英文,切换系统语言无效 | MaterialApp 未配置 delegates 或 locale 未设置 |
配置 AppLocale.localizationsDelegates 和 supportedLocales |
| 中文文案显示为方框 | 自定义字体不含中文字形 | 替换支持中文的字体,或去掉字体属性 |
| 参数不足编译报错 | 翻译项声明的占位符与调用参数不一致 | 按 yaml 中 {name} 声明补齐参数 |
| 多模块同名字段互相覆盖 | 各模块生成了自己的 Translations 类 | 统一由根 package 生成,或设置独立类名 |
| OpenHarmony 上 locale 解析错误 | 系统语言码和翻译文件语言码不一致 | 初始化时对系统 locale 做归一化映射 |
| 老项目升级后生成文件大量冲突 | 翻译文件的 key 风格变化 | 在 slang.yaml 中设置 key_case,保持 key 风格稳定 |
5.2 两个最容易忽视的误操作
第一个误操作是把翻译 key 写成了字符串拼访问。比如有人偷懒直接写 t['home.title'],slang 生成的对象虽然支持索引访问,但这样做等于放弃了类型安全和 IDE 补全,相当于绕过了工具最大的价值。我一般不让团队这么写,code review 发现索引访问直接打回。
第二个误操作是翻译文件编码问题。YAML 文件如果不小心保存成了 GBK,生成时会出现乱码或解析失败。建议在编辑器里统一 UTF-8 编码,并在 CI 里加一个文件编码校验步骤,防止有人用 Windows 自带记事本改完文件就提交。
5.3 独家技巧与稳定实践
结合我的经验,有几个技巧值得分享。
第一,CI 里加一步 dart run slang 校验。很多团队把生成文件提交进版本库,但不保证所有人提交前都重新生成过,CI 里跑一次生成命令,然后 git diff 对比,如果有差异就说明生成代码和翻译文件不一致,直接让流水线失败。
第二,slang.yaml 里开启 key 排序。配置 sort: true 可以让 YAML 文件按 key 排序生成,翻译人员找 key 的时候不会因为历史顺序混乱浪费时间去搜索。
第三,和 flutter_localizations 的配置顺序建议。AppLocale.localizationsDelegates 要放在所有其他 delegate 的最前面,否则遇到某些 Material 组件内置的英文文案会被错误覆盖。
第四,翻译文件不宜过大。我见过一个项目把所有文案塞进一个 YAML,几千行,编辑器都卡。建议按业务域拆分成多个文件,比如 home.yaml、order.yaml、settings.yaml,slang 支持多文件输入,最终合并生成到一个 Dart 类里。
写在最后的一点个人体会
做了几年 Flutter 国际化,最后反而是回到最基础的翻译文件结构设计和生成流程管理上。slang 解决了手写 key 的类型安全问题,但它毕竟只是一个工具,真正决定国际化体验的是约定:key 的命名是否清晰、语言包是否统一管理、动态文案是否过度复杂。在 OpenHarmony 上跑 Flutter,i18n 生态虽然不像 Android 那么成熟,但用 slang 之后,平台差异基本被抹平了,一套翻译体系在多端保持一致,是我目前最推荐的做法。如果你也在做 Flutter 多端适配,建议先拿一个小模块试水 slang,感受一下编译期检查带来的安全感,再决定要不要全套迁移。
