Flutter+OpenHarmony国际化实战:电子合同多语言切换的坑与解
上个月我把一款跑在OpenHarmony设备上的电子合同签署App从单语言改成了支持中英文切换。改完之后最大的感受是国际化这件事翻译只占三成剩下的七成全藏在合同两个字里。如果你只是把按钮文案翻译一遍那叫多语言皮肤不叫国际化支持。这篇博文不打算讲Flutter的基础知识也不科普OpenHarmony怎么装环境我默认你已经有一台能跑的OpenHarmony设备、有一个Flutter for OpenHarmony的工程。我会从电子合同这个具体业务出发把Flutter的国际化链路flutter_localizations intl ARB模板 代码生成在OpenHarmony上的落地过程、踩过的坑、以及合同业务必须考虑的特殊点完整讲一遍。内容适合正在做或者准备做多语言版本Flutter应用的开发者尤其是金融、法务、政务类应用的同行因为这类App的国际化跟普通工具类App完全是两个量级的问题。1. 为什么说电子合同App的国际化根本不是翻译一遍的事1.1 合同业务对国际化的要求天然比其他App高一档普通App的国际化核心是字符串替换把代码里的中文字面量挪进资源文件再翻译成目标语言基本就算完成。到了上线前顶多再检查一下有没有漏网之鱼。但电子合同App不能这么干因为合同正文不是普通文案它是证据。界面上的发送按钮翻译错了用户最多吐槽一句合同正文里一个有歧义的金额、一个被机器翻译污染的条款直接影响的是这份合同的法律效力。所以我们项目定了第一条铁律合同正文的多语言不是UI翻译是法律翻译必须有人工复核并且每一版翻译都要留版本号。第二个差异点是时效性和一致性。用户在周三签署合同当时界面显示的是中文三个月后他打开归档记录手机系统语言早就切成了英文如果合同内容跟着界面语言走签过的合同就变色了。这种事在普通App里无伤大雅在合同App里就是一个事故级别的bug。因此合同记录必须在签署那一刻就把当时使用的语言固化下来之后任何时候打开归档看到的都应该是签署时的那份原文。这一点我在第4章会详细展开。第三个差异是数字和格式的敏感性。日期、金额、签署时间戳这些数据在国际化时不能简单套用通用的格式化函数。欧洲有些地区写1.500,00美国写1,500.00合同里如果出现这种歧义就是灰色地带。合同正文里的金额必须强调无歧义宁可牺牲一点本地化习惯也要用国际通用的明确格式。这是法务同事在评审时反复强调的。1.2 Flutter OpenHarmony 这套组合的特殊性Flutter for OpenHarmony并不是简单的给Flutter加一个平台它是由OpenHarmony社区维护的Flutter分支Dart语法、Flutter框架API大体与上游保持一致但插件生态比Android/iOS年轻好几个量级。做国际化的时候这个差异格外明显。先说好消息国际化依赖的核心包intl和flutter_localizations基本都是纯Dart实现不依赖具体平台的native能力所以在OpenHarmony上可以正常使用。真正出问题的是那些需要平台通道配合的插件比如获取系统语言、持久化用户选择、监听系统设置变化这些功能在不同设备上的实现并不完全一致。我在项目里遇到的一个典型场景是某台设备上系统语言上报不完整导致中文资源始终无法命中这个问题一度排查了整整一天。另外OpenHarmony各厂商的系统实现也存在差异对语言标签locale tag的处理方式并不统一有些返回zh-Hans-CN有些返回zh_CN有些就直接返回en_US。这些坑我会在第5章专门说。2. 环境准备在OpenHarmony设备上把Flutter工程跑起来2.1 工程初始化的几个关键步骤整个环境搭建过程我不展开成安装手册只列出和国际化相关的关键点。前提是你已经装好了OpenHarmony SDK和Flutter for OpenHarmony的SDK并且配好了PATH环境变量。剩下的步骤是创建工程时指定OpenHarmony平台flutter create --platformsohos project_name如果已经有Flutter工程就在工程根目录执行flutter create --platformsohos .来补上ohos平台目录。用开发工具打开ohos目录配置好签名信息才能往真机安装。在pubspec.yaml里添加国际化相关依赖然后flutter pub get。先跑一个空壳App确认能编译、能安装、能打开再开始写业务代码。这里有一个很实在的建议开发阶段尽量用真机。我们在某款模拟器上遇到过系统语言接口返回不完整的情况系统设置明明切到了简体中文Flutter侧拿到的语言标签却始终是英文导致中文资源一直命中不了。这种问题在真机上很少出现但在模拟器上很常见。如果一开始就依赖模拟器调试很容易被这种环境差异误导以为是自己的代码写错了。2.2 工程第一天就改好的三件配置国际化最忌讳的事情是以后再加。电子合同App页面多、流程长如果先写一堆硬编码的中文再回来改你要么写脚本扫描字符串要么人肉排查都会漏。我见过有项目因为遗漏硬编码字符串在签名确认按钮上出现中英混合用户截了图投诉——在合同类产品里这种低级错误格外扎眼。所以第一天就要把下面三件配置落地。第一件pubspec.yaml。添加flutter_localizations、intl并开启generate: truedependencies: flutter: sdk: flutter flutter_localizations: sdk: flutter intl: any shared_preferences: ^2.2.0 flutter: generate: true第二件l10n.yaml。这是Flutter官方生成多语言代码的配置文件放在工程根目录arb-dir: lib/l10n template-arb-file: app_en.arb output-localization-file: app_localizations.dart output-class: AppLocalizations synthetic-package: false nullable-getter: false我特意把synthetic-package设成false这样生成的代码会直接落在lib目录里方便审查生成结果也方便调试。如果你保持默认的synthetic package生成的类在虚拟包路径里查问题的时候很不直观。第三件先建好初始ARB文件。在lib/l10n目录下创建app_en.arb和app_zh.arb各自至少包含App名称等基础文案。不要求一次配全但文件要先建起来因为生成代码的机制是有模板文件才开始生成。提示如果你们的项目已经写了一半才开始补国际化我建议先花一天把页面里的所有可见字符串扫描出来建立一份清单再统一迁移到ARB里。这一步省不了漏掉的硬编码字符串会在后面不停冒出来而且总是在你最不想看到的地方出现。3. 多语言链路核心ARB模板、代码生成与动态切换3.1 MaterialApp 的本地化接线国际化第一步是把MaterialApp接上本地化配置。代码本身很简单MaterialApp( title: context.l10n.appTitle, locale: _locale, localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: const HomePage(), );这里需要解释一个很多人搞不清楚的点AppLocalizations.localizationsDelegates是生成代码自动展开的它里面除了你自己生成的AppLocalizationsDelegate之外还包含了GlobalMaterialLocalizations.delegate、GlobalWidgetsLocalizations.delegate、GlobalCupertinoLocalizations.delegate这三件套。这三件套是Material组件自带的文案翻译比如返回按钮的tooltip、日期选择器的月份、对话框的默认按钮文字。最常见的坑是自己只添加了AppLocalizations.delegate把这三件套漏了结果自己写的字符串都翻译了日历选择器还是英文。记住千万不要手动精简这个delegate列表否则你会被各种漏翻译的组件折腾到怀疑人生。另外要注意supportedLocales也是生成的代码它只会包含你提供了ARB文件的那些locale。比如你有app_en.arb和app_zh.arb它就会生成[Locale(en), Locale(zh)]。如果后面新增了阿拉伯语记得补上对应的ARB文件否则即使你把系统语言切到阿拉伯语Flutter也不会去用它。3.2 ARB文件编写规范占位符与参数类型ARBApplication Resource Bundle是Flutter官方的本地化资源格式本质是JSON但带了一套元数据规范。最核心的一点是占位符文案里如果包含动态值不要用字符串拼接要写成占位符让翻译人员可以整体调整语序。举个例子签署确认弹窗app_en.arb{ locale: en, appTitle: Contract Sign, signConfirmTitle: Confirm signing, signConfirmBody: You are about to sign the contract \{contractName}\ as {signerRole}., signConfirmBody: { description: Signing confirmation dialog body, placeholders: { contractName: {type: String}, signerRole: {type: String} } } }app_zh.arb{ locale: zh, appTitle: 合同签署, signConfirmTitle: 确认签署, signConfirmBody: 您即将以{signerRole}身份签署合同“{contractName}”。, signConfirmBody: { description: 签署确认弹窗正文, placeholders: { contractName: {type: String}, signerRole: {type: String} } } }注意中文和英文模板里占位符的顺序是不同的中文是以{signerRole}身份签署合同{contractName}英文是sign the contract {contractName} as {signerRole}。这就是占位符的价值——翻译人员不需要为了迁就代码逻辑而硬生生把英文翻成中文语序。使用的时候调用生成的getter方法就行context.l10n.signConfirmBody(contract.name, currentUser.roleLabel);placeholder支持的类型包括String、int、double、DateTime等。还有一类是ICU复数格式比如已签署N份协议signedAgreements: {count, plural, 0{No agreements signed} one{1 agreement signed} other{{count} agreements signed}}这个在生成代码时会被正确处理。不过说实话在合同正文里我们很少用复数句式因为法律文本讲究精确条条框框都是完整句子。复数的使用场景更多是界面上的一些提示文案、通知消息。3.3 动态切换语言与持久化电子合同App有一个业务需求用户可以在App内主动切换显示语言而不是完全跟随系统。原因在第4章会说这里先讲实现。我们项目里用一个全局的ValueNotifierLocale来管理当前语言final ValueNotifierLocale localeNotifier ValueNotifier(_initLocale()); FutureLocale _initLocale() async { final prefs await SharedPreferences.getInstance(); final saved prefs.getString(locale.appLocale); if (saved ! null) { return Locale(saved); } return PlatformDispatcher.instance.locale; } void switchLocale(Locale locale) { localeNotifier.value locale; SharedPreferences.getInstance().then((prefs) { prefs.setString(locale.appLocale, locale.toLanguageTag()); }); }然后在MaterialApp外层用ValueListenableBuilder包一层locale一变整棵树重建ValueListenableBuilderLocale( valueListenable: localeNotifier, builder: (context, locale, _) { return MaterialApp( locale: locale, localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: const HomePage(), ); }, );这里有一个容易被忽略的细节localeResolutionCallback。当系统locale带区域变体时比如zh_TW、zh_HK而你的ARB文件只写了zhFlutter默认的解析规则不一定能正确回落。我们加了这样一个回调localeResolutionCallback: (locale, supportedLocales) { if (locale null) return supportedLocales.first; if (locale.languageCode zh) return const Locale(zh); if (supportedLocales.contains(locale)) return locale; return const Locale(en); },这样做的意图很明确只要是中文变体统一命中简体中文资源其他语言如果没有对应资源就兜底到英文而不是让用户看到一个全部是占位符的界面。4. 合同签署场景下的本地化实战日期、金额与法律文案4.1 签署日期通用格式与法律格式要分开界面上的签署日期交给intl的DateFormat就行了。DateFormat.yMMMMd(zh_CN)输出2024年12月25日DateFormat.yMMMMd(en_US)输出December 25, 2024。这部分没有太多技术含量难点在合同正文里的日期。中文法律文本里有大写日期写法比如二〇二四年十二月二十五日这个intl不提供得自己写转换函数。我们实现了一个查表法的工具函数把年月日数字分别映射到〇一二三四五六七八九再拼上年月日三个字。注意处理十的逻辑十二月不能写成一十二月二十日不能写成二十零日这些琐碎规则都需要测试用例覆盖。实现不复杂但边界值多光〇和零的区分就够写一页。英文法律模板的做法又不一样它不能靠拼接要整句放进ARB模板里。我们在ARB里是这样定义的signDateClause_zh: 本合同于{signedDate}在{signedPlace}签订。, signDateClause_en: This Agreement is entered into on {signedDate} at {signedPlace} on the date set forth below.调用时传入格式化好的日期字符串。为什么用整句模板因为法律句子的语序跟普通口语不一样英文模板里on the date set forth below这类法律套语必须由翻译人员整体把控代码不能擅自组织。合同领域特别忌讳看起来每个词都对但整句话不像人话的翻译。跟日期相关还有一个细节归档时同时存UTC时间戳和签约当地时间的可读字符串快照。国际化的场景里如果归档后重新对时间戳做格式化会因为时区变化或者localde变化显示不同的日期这在证据链上是个隐患。我们的做法是签署完成那一瞬间把按照当时区域格式化的日期字符串直接固化到合同数据里之后任何页面展示都用这个快照不再重新格式化。4.2 金额格式化与中文大写金额界面上的金额可以用NumberFormat.currency来做NumberFormat.currency(locale: zh_CN, symbol: ¥)输出¥1,234.56英文界面配美元符号输出$1,234.56。但这里有一个业务层面的约束金额单位是合同元数据跟界面语言无关。合同币种是人民币即使用户把界面切成了英文展示的也应该是CNY 1,234.56或¥1,234.56而不是变成美元符号。更关键的是合同正文里的金额格式。我们的规定是合同正文展示金额一律用三位分组 两位小数 ISO币种代码的固定格式不随界面语言切换数字分组符号。为什么这么较真因为NumberFormat.decimalPattern在德语环境可能输出1.234,56在英语环境输出1,234.56欧洲用户看前者是1234.56美国用户看后者也是1234.56可一旦这两者出现在同一份合同里就会产生这个逗号到底是分组还是小数点的争议。法律文本不能留这种灰色地带所以宁可牺牲一点本地化习惯也要用无歧义的格式。金额内部存储我们坚持用整数分cents不用double。这个原则在普通App里是推荐项在合同App里是强制项——一分钱的浮点误差在UI上可能显示不出来但在合同条款的自动核对逻辑里就是大问题。中文大写金额是另一个独立模块它不是文案是算法。我们实现了amountToChineseUpper(int cents)查表映射零壹贰叁肆伍陆柒捌玖分角拾佰仟万亿测试用例覆盖了0、整数、带角分、中间连续零、末尾零等各种边界。英文合同里的对应物是in words: One Thousand Two Hundred Thirty-Four and 56/100这种写法逻辑类似按locale选择不同的实现函数。这块代码建议单独放一个文件不要硬编码在页面里。4.3 签署流程锁定语言、归档记录语言元数据这一节是这个项目里让我印象最深的设计决策。当时产品经理提了一个需求签署过程中能不能让用户切换语言我说不行理由很简单用户在看中文合同的过程中把界面切成英文然后再点确认签署这算他签的是中文版还是英文版将来有纠纷的时候对方律师问一句他到底看懂了哪份这个问题就解释不清。所以我们定了规则从进入签署确认页开始语言切换入口隐藏或禁用当前语言快照写入签署会话。用户想换语言可以先退出签署流程换好再重新进来。规则看起来很死板但它保证了看什么签什么的基本逻辑。合同数据模型上加了两个字段contractLocale和contractVersion。签署完成后合同正文渲染一律使用contractLocale跟当前UI语言完全无关。这样即使三个月后手机系统切了英文用户打开旧合同看到的还是签署时的那版中文落款、附件、时间戳全部一致。UI界面语言可以灵活切换合同正文语言一旦签署就不可变。如果产品要支持双语合同数据模型要升级为语言对language pair比如中文为主、英文对照或英文为主、中文对照两种模式。这个最好在需求评审阶段就想清楚因为数据模型定下来之后再改涉及合同创建、签署、归档、验真一整条链路的返工成本非常高。5. OpenHarmony适配实测系统语言、字体与插件缺口5.1 系统语言获取与变更监听Flutter默认会通过平台层获取系统locale理论上WidgetsBinding会把这件事处理好。但我们在OpenHarmony目标机上实测发现某些版本的系统语言上报并不完整系统设置切到简体中文Flutter侧拿到的却还是en_US。这导致中文ARB永远不会命中界面内容看着像没有翻译。解决办法是自己加一条平台通道从OpenHarmony侧直接取系统语言。Dart侧代码长这样class OhosLocaleService { static const _channel MethodChannel(com.example.app/locale); static FutureLocale? getSystemLocale() async { try { final tag await _channel.invokeMethodString(getSystemLocale); if (tag null) return null; return _normalizeLocale(tag); } on PlatformException { return null; } } static Locale _normalizeLocale(String tag) { final parts tag.replaceAll(-, _).split(_); if (parts.length 2) return Locale(parts[0], parts[1]); return Locale(parts[0]); } }OpenHarmony侧的实现要点是在应用的模块里调用系统提供的语言能力把类似zh-Hans-CN这样的标签归一化成Flutter能识别的形式再回传。不同API版本差异不小这里不建议我写死代码你直接以手头SDK文档为准。重点在于必须做归一化因为OpenHarmony设备的返回格式真的不统一。系统语言变更的监听也有讲究。应用在后台时用户切了系统语言回到前台怎么感知我们用的方案是onResume生命周期兜底回到前台后重新拉一次系统语言跟当前缓存的语言比较不一致就刷新。最初试过用EventChannel订阅系统语言变化事件但实测部分设备不触发这个事件不如onResume兜底稳。这条经验属于用脚踩出来的——文档里写得再漂亮都不如真机行为靠谱。5.2 字体、RTL与排版差异阿拉伯语、希伯来语这类从右往左书写的语言Flutter的Directionality会随locale自动切换但这个自动只对框架层生效。如果你在自定义绘制里写死了Alignment.centerLeft或者用EdgeInsets.only(left: ...)到了RTL界面就会乱掉。电子合同App里签名画布、骑缝章位置、条款对齐线都是自定义绘制这些地方必须用Directionality.of(context)来取当前方向或者用AlignmentDirectional、EdgeInsetsDirectional这些逻辑属性不能写死物理方向。字体覆盖是另一个坑。OpenHarmony系统默认字体对中文和拉丁文覆盖没问题但阿拉伯文、泰文、印地文不一定全。我们的做法是把目标语言的字体文件打进assets然后在主题里配置fontFamilyFallbackThemeData( fontFamily: default, textTheme: textTheme.apply( fontFamilyFallback: const [NotoNaskhArabic, NotoSansThai], ), )注意这个fallback要同时配置在theme和textTheme两层否则部分组件不会生效。加字体文件会增大包体积但合同App本来就不适合做极致轻量内容完整可读比体积重要。还有文本缩放。OpenHarmony系统设置里的字体缩放会通过textScaleFactor影响Flutter布局。合同预览页面内容是按固定字号设计的字体放大1.3倍就容易溢出。我们没有对合同正文禁用缩放而是用可滚动布局加弹性行高来容纳同时把1.3倍缩放设置为验收必测项。因为合同正文必须保证老年用户放大也能读这是可用性问题不是bug。5.3 第三方插件的本地化空白Flutter社区大量插件自带文字。最典型的例子是某个签名板插件它的CLEAR / SAVE按钮是硬编码英文根本不支持传参数。遇到这种情况我们没有去fork插件而是在它外面包了一层自定义组件把按钮替换成自己的本地化文案。原因很简单fork之后要长期维护而且上游更新了你还得追自定义覆盖只需要在UI层拦截代码量少风险可控。还遇到过需要日期选择和时间选择的流程一开始想用某个第三方picker插件但它的OpenHarmony适配有问题本地化文案也对不齐最后干脆退回Flutter自带的material date picker配合GlobalMaterialLocalizations之后自动跟着locale走省心不少。在OpenHarmony上做项目我的经验是能上官方组件就上官方组件第三方插件慎用尤其是涉及到文字、方向、系统能力调用的插件每个都是潜在的国际化黑盒。顺手提一个约定shared_preferences在OpenHarmony适配版本里存key时是原样字符串。我们给语言key统一加了locale.前缀避免跟其他缓存数据冲突。这种看起来不起眼的约定在多语言、多账号场景下特别有用否则哪天排查数据串了你会后悔没早点加前缀。6. 多语言验收伪本地化、溢出排查与回归清单6.1 伪本地化测试翻译缺失的放大镜伪本地化pseudo-localization是国际化的一个成熟测试手段国内团队用得不多但它对付字符串没抽干净布局容易撑爆这两类问题效率极高。做法是在已有ARB基础上建一个伪本地化资源文件比如app_xx_PSEUDO.arb把每条文案前面加标记并复制三遍{ locale: xx, appTitle: [[ Contract Sign ]] [[ Contract Sign ]] [[ Contract Sign ]], signConfirmTitle: [[ Confirm signing ]] [[ Confirm signing ]] [[ Confirm signing ]] }然后临时把这个locale加进supportedLocales跑一遍全页面。凡是溢出的、被截断的、还是硬编码英文没被替换的字符串全部会暴露出来。为什么要复制三遍因为德语、俄语文案通常比中文长30%-50%阿拉伯语可以到70%以上。三倍长度虽然夸张但能覆盖绝大多数极端场景。对合同App这种追求稳妥的产品来说用这个粗暴办法比逐个语言人工翻页检查效率高太多。跑完之后有一个重要动作把这个伪资源从正式包里移除。我们曾经因为忘记移除让用户在语言设置里看到了一个XX选项点进去全是带括号的英文虽然只影响测试版但看着非常业余。6.2 溢出与截断的高发区域多语言做完之后布局溢出主要发生在几个固定区域。一是表格和清单的列头。合同预览页的金额列如果写死了宽度换成德语马上崩。解决办法是用Expanded分配空间文本超出用ellipsis加tooltip。二是签名确认弹窗。文案带占位符时实参比如合同名称一旦超长按钮会被撑到屏幕外。给文本设置maxLines让长文本滚动而不是硬撑。三是底部按钮组的文案中文的确认签署四个字翻译成德文可能是十几个字符如果两个按钮并排空间一定不够。有几个原则要记牢。不要滥用FittedBox合同条款字号被FittedBox缩到看不清法律上等于没展示。该换行就换行该滚动就滚动不能用缩放掩盖布局问题。另外做RTL适配时建议在代码审查阶段就用脚本扫一遍工程里有没有Alignment.left、EdgeInsets.only(left:这种物理属性写法提前拦截而不是等QA的RTL测试用例跑挂了再说。6.3 回归验收清单国际化不是一次性功能新增页面、新增文案都会引入新问题。我们总结了一份验收清单每次发版前必须过一遍检查项说明通过标准全页面截图中英文逐页对比无硬编码字符串、无遗漏翻译系统语言切换切语言后冷启动语言自动跟随无闪屏App内语言切换设置页热切换界面即时刷新状态不丢失签署流程锁定签署中尝试切语言切换入口隐藏或禁用归档合同复现换语言后打开旧合同合同正文保持签署时语言金额格式抽查三种币种3个金额分组符、币种码一致日期格式抽查界面/合同正文各2种中英文日期格式正确RTL方向检查阿拉伯语页面对齐方向正确自定义绘制正常字体缩放开启1.3倍字体无溢出、无截断伪本地化遍历xx_PSEUDO资源跑全量无新增溢出这份清单看起来朴素但每一行几乎都是真实翻过车之后补进去的。从最初的四五条慢慢扩充到现在的十条现在回归基本不慌了。最后说一句个人体会。这个项目做下来最花时间的不是把字符串挪进ARB文件而是把UI文案和合同正文文案彻底分开并让产品经理、UI设计师、翻译、法务都理解这个边界。技术层面的国际化Flutter的工具链已经非常成熟intl和flutter_localizations在OpenHarmony上都能正常用真正需要你自己拿主意的是业务规则。合同类应用宁可少支持一种语言也要保证已有语言没有任何歧义和遗漏。如果你也在做类似的东西我建议先从签署页和归档页开始改造这两个页面是合同证据链的核心优先保证它们的语言一致性再去铺其他功能页面。