大家好,我是微微笑的蜗牛,🐌。
今天这篇文章主要想讲讲,Flutter 多插件语言本地化遇到的问题,原因以及解决方案。
本地化
在 Flutter 开发中,多语言本地化可以使用 Android Studio 提供的 Flutter intl 插件,它能帮开发者自动生成本地化相关的代码。
不过首先得在 App 的 localizationsDelegates
添加 S.delegate
,并添加 supportedLocales
来指定支持的区域。
在使用文本的地方,只需调用 S.of(context).xx
便可获取 xx 对应的文本。
在该插件的辅助下,实现多语言非常简单。
问题
最近在做 Flutter 插件化,各个插件中有自己的本地化信息,然后会在 App/Submodule 中集成多个插件。
这样,各个插件的 S.delegate
都需要添加到宿主的 localizationsDelegates
中。
但此时,多插件的本地化就出现问题了。
具体表现为:只有第一个插件的本地化生效了。什么意思呢?
假设当前是中文,只有第一个添加到 localizationsDelegates
的插件显示中文,其他插件都显示英文。
其实这么说还不太准确,如果再进一步的话,还要看各插件中使用的 message key
值是否一样。
- 如果使用的 key 值在第一个插件中存在,那么它显示的就是第一个插件中该 key 对应的中文;
- 如果不存在,显示的则是本插件中 key 对应的英文。
下面,我们来举个栗子看看。
栗子
假设有一个 Flutter App 的工程。
它内部有三个本地插件,分别是 plugin_a、plugin_b、plugin_c
。
每个插件都提供了一个一毛一样的 widget,居中显示文字。只不过背景色有所区别,分别如下:
- 插件 a:红色
- 插件 b:蓝色
- 插件 c:绿色
另外,这三个插件中都有中英文的本地化信息,即包含 intl_en.arb、intl_zh_CN.arb
。
App 的工程结构如下:
然后,App 以本地依赖的方式引入了这三个插件。
flutter_plugin_a:
path: './flutter_plugin_a'
flutter_plugin_b:
path: './flutter_plugin_b'
flutter_plugin_c:
path: './flutter_plugin_c'
同时,在宿主的 localizationsDelegates
中添加了三个插件的 S.delegate
。
localizationsDelegates: const [
plugin_a_localization.S.delegate,
plugin_b_localization.S.delegate,
plugin_c_localization.S.delegate,
GlobalMaterialLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
GlobalWidgetsLocalizations.delegate
],
arb 内容
- 「插件 a」 的 arb 内容如下:
// intl_en.arb
{
"text": "plugin_a",
"edit": "Edit"
}
// intl_zh_CN.arb
{
"text": "插件a",
"edit": "编辑"
}
- 「插件 b」 的 arb 内容如下:
// intl_en.arb
{
"text": "plugin_b",
"close": "Close"
}
// intl_zh_CN.arb
{
"text": "插件b",
"close": "关闭"
}
- 「插件 c」 的 arb 内容如下:
// intl_en.arb
{
"text": "plugin_c",
"search": "Search"
}
// intl_zh_CN.arb
{
"text": "插件c",
"search": "搜索"
}
💡 温馨提示:它们的 arb 中都有个相同的 key,即 "text"
。
接下来,我们将进行实验,看看在使用相同 key 和不同 key 时,文字的显示情况。假设区域是中国。
相关 demo 代码可查看:https://github.com/silan-liu/flutter_app_localization。
相同 key
假设三个插件都使用相同 key
值,"text"
。
✅ 正确结果应该为:插件a、插件b、插件c。
💣 但是呢,显示结果却如下:插件a、插件a、插件a。
这里就有点奇怪了,o(╥﹏╥)o。
「插件 b」 和「插件 c」 取到的 text
值竟然都是「插件 a」的!
不同 key
假设三个插件分别使用各自的 key 值。
- 插件 a,使用
text
。 - 插件 b,使用
close
。 - 插件 c,使用
search
。
✅ 正确结果应该为:插件a、关闭、搜索。
💣 但是呢,显示结果却如下:插件a、Close、Search。
我们可以看到,只有「插件 a」正确的显示了中文,其他插件显示了英文。
这究竟又是为什么?
追本溯源
首先,我们得搞清楚 Intl 内部是如何查找文本的?
通过查看生成的 l10n.dart
代码,发现 Intl
在查找 message
时,会调用到 Intl.message
。
String get text {
return Intl.message(
'plugin_a',
name: 'text',
desc: '',
args: [],
);
}
而 Intl.message
内部会使用 MessageLookup
对象来寻找对应的文本,也就是下面代码中的 helpers.messageLookup
。
static String? _lookupMessage(String? messageText, String? locale,
String? name, List<Object>? args, String? meaning) {
return helpers.messageLookup
.lookupMessage(messageText, locale, name, args, meaning);
}
在本地化相关代码初始化时,当前 locale 的本地化信息会被添加到该 messageLookup
对象中。
另外,在添加 locale 对应的信息时,会先判断 locale 是否已存在。如果已经存在,则不会进行添加。
相关代码在 CompositeMessageLookup
的 addLocale
里。
void addLocale(String localeName, Function findLocale) {
if (localeExists(localeName)) return;
// ...省略
}
注意看第一句代码,localeExists
用于判断是否已添加 localeName。如果存在,则不往下执行。
到这里,一切看起来还挺正常的。
但最致命的问题在于,该对象是一个全局对象。所以在多插件场景下,它们共用的是同一个对象。
也就是说,如果有一个插件注册了某区域的本地化信息,其他插件就不可能再注册进去了。
所以,归根结底,还是因为 messageLookup
对象的共用导致。
栗子解释
看到这里,也就能解释栗子中出现的两种情形了。
Q1:为什么使用相同的 key 时,全都显示插件 a 中的文本?
A:因为只有插件 a 的本地化信息添加进去了。如果此时其他插件也使用相同的 key,那么自然获取到的是插件 a 中的值。
Q2:为什么使用不同 key 值时,其他插件显示的是英文呢?
A:这是因为,如果注册的本地化信息中没有这个 key 值,会默认取该 key 对应的英文文本。
比如下面这段获取 close
对应文本的方法,它的第一个参数就是 Close
,也就是对应的英文文本。
/// `Close`
String get close {
return Intl.message(
'Close',
name: 'close',
desc: '',
args: [],
);
}
那为什么要传入英文文本呢?
想必是有作用的,跟进代码中会发现,它是用于作为默认文本。
相关问题
经查阅资料,发现有人也遇到了类似的问题。如下:
- Flutter Package with Intl Localization
- https://github.com/localizely/flutter-intl-intellij/issues/22
文中提到的两种解决方式,大同小异,即:每个插件提供自己的 messageLookup
对象,防止共用。
下面,我们就参照该思路,一步步解决问题。
解决方案
主体思路
思路已经很清晰了,解决对象的共用问题即可。
上面我们也已经提到过,Intl.message
内部最终调用到了全局的 messageLookup
对象来进行查找。
那么,在这一步进行查找的时候,我们可以将其替换为自己的 messageLookup
对象,以此达到目的。
所以,思路整理下来,就是如下两点:
- 生成插件自己的
messageLookup
对象。 - 修改
message
查找方法。
但现在最重要的一点是,如何让每个插件提供自己的 messageLookup
对象?
在回答这个问题之前,我们得先看看全局 messageLookup
对象是如何生成的?
全局 messageLookup 对象的生成
经过查阅代码,发现 messageLookup
对象是在 messages_all.dart
文件中生成的。
具体代码在 initializeMessages
中,初始化的类型是其子类 CompositeMessageLookup
。
Future<bool> initializeMessages(String localeName) async {
// ...省略
// 这里初始化为 CompositeMessageLookup 的实例
initializeInternalMessageLookup(() => new CompositeMessageLookup());
// 添加本地化信息
messageLookup.addLocale(availableLocale, _findGeneratedMessagesFor);
return new Future.value(true);
}
那么,参照它的实现,我们可以在每个插件中生成自己 CompositeMessageLookup
实例,在查找文本时用这个内部对象。
插件 messageLookup 对象的生成
由于 initializeMessages
内部涉及到一些私有变量和方法的使用,因此,如果我们想生成 messageLookup
对象,需要在 messages_all.dart
中添加代码。
跟 initializeMessages
的流程差不多,只是在最后一步,返回新生成的对象即可。如下所示:
Future<MessageLookup?> getMessageLookup(String localeName) async {
// ... 省略
// 生成 CompositeMessageLookup 对象
final messageLookup = new CompositeMessageLookup();
messageLookup.addLocale(availableLocale, _findGeneratedMessagesFor);
return messageLookup;
}
何时初始化
不过还有个问题,原有的全局 messageLookup
对象是什么时候进行初始化的?
看看生成的 l10n.dart
的代码,S
中有个 load
方法,就是在做这个事情。
而 S.load
方法的调用,又是什么时候呢?
通过断点调试,发现 Localizations
在初始化时,会调用到各个 LocalizationsDelegate
的 load
方法,最后调用到 S.load
。
由于我们需要将全局 messageLookup
替换成自己生成的对象,那么可考虑在 S.load
方法中进行操作。
为了便捷性,可将 S.load
方法的实现替换为插件内部自己的 load
实现,以达到替换目的。
实现替换
上面提到 message
的查找是通过 Intl.message
方法,那么我们可以模仿它的方式来进行插件内的文本查找。
说起来,就是在插件内部定义自己的 Intl
类,同样提供 message
方法,只不过在查找时使用插件生成的独立 messageLookup
对象。
另外,再提供一个 load
方法,用于替换 S.load
实现。
也就是说,自定义的 Intl
会包含如下部分:
class Intl {
// 查找 message
static String message(xx);
// 自己定义的 messageLookup 对象
static MessageLookup myMessageLookup;
// load 生成自己的 messageLookup 对象
static Future<S> load(Locale locale);
}
而原 S.load
的实现,会被替换成如下方式:
// Intl 为自定义的类
static Future<S> load(Locale locale) => Intl.load(locale);
这样最核心的问题就解决了。
整体流程
经过上面的分析,我们可以得知,这个解决方案涉及到的文件有:
-
messages_all.dart
:用于生成自定义的messageLookup
对象。 -
l10n.dart
:用于替换S.load
方法。 - 另外,还有新增的自定义
Intl
类。
梳理一下,整体的解决流程如下:
- 在插件内新定义
Intl
类,实现上述提到的方法。 - 在
messages_all.dart
中添加生成内部messageLookup
的实现。假定方法名为getMessageLookup
。 - 替换
l10n.dart
中S.load
方法为Intl.load
,同时屏蔽原intl.dart
头文件。
但是 messages_all.dart
等文件是自动生成的,随时可能会发生变化。若以手动的方式修改,不太可取。
另外,工程中涉及到多个插件,一个个修改肯定不是个事。
因此,考虑将以上流程以脚本的方式自动化进行。
脚本自动化
整体思路也比较简单,如下所示:
- 准备一份已经定义好的
intl
文件,也就是内部有自己的messageLookup
。 - 遍历工程目录,判断是否为插件目录。如果是插件,且存在
l10n.dart
和messages_all.dart
,则将步骤 1 中的intl
文件拷贝到插件目录下。 - 修改
messages_all.dart
文件,新增getMessageLookup
方法。若已存在,则进行替换。 - 修改
l10n.dart
文件,替换S.load
实现。
这样,在 intl
插件生成代码后,运行脚本,则可修正问题。
完整的 ruby
脚本可查看:flutter_app_localization/intl.rb at main · silan-liu/flutter_app_localization。
不足之处
由于本地化代码是 IDE 插件自动生成的,那么在开发过程中,很有可能在代码变动后忘记执行脚本,而直接提交了代码。
这样一来,插件的本地化还是会存在问题。
因此,后续考虑两种方案:
- 在 ci 打包时,添加执行脚本这一步骤,让打出的包是正确的。
- 添加
pre-commit hook
,在提交代码之前先执行脚本,保证提交代码的正确性。