Flutter 多插件本地化问题

大家好,我是微微笑的蜗牛,🐌。

今天这篇文章主要想讲讲,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 工程结构

然后,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 内容

  1. 「插件 a」 的 arb 内容如下:
// intl_en.arb
{
  "text": "plugin_a",
  "edit": "Edit"
}

// intl_zh_CN.arb
{
  "text": "插件a",
  "edit": "编辑"
}
  1. 「插件 b」 的 arb 内容如下:
// intl_en.arb
{
  "text": "plugin_b",
  "close": "Close"
}

// intl_zh_CN.arb
{
  "text": "插件b",
  "close": "关闭"
}
  1. 「插件 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 是否已存在。如果已经存在,则不会进行添加。

相关代码在 CompositeMessageLookupaddLocale 里。

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: [],
    );
}

那为什么要传入英文文本呢?

想必是有作用的,跟进代码中会发现,它是用于作为默认文本

相关问题

经查阅资料,发现有人也遇到了类似的问题。如下:

文中提到的两种解决方式,大同小异,即:每个插件提供自己的 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 在初始化时,会调用到各个 LocalizationsDelegateload 方法,最后调用到 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 类。

梳理一下,整体的解决流程如下:

  1. 在插件内新定义 Intl 类,实现上述提到的方法。
  2. messages_all.dart 中添加生成内部 messageLookup 的实现。假定方法名为 getMessageLookup
  3. 替换 l10n.dartS.load 方法为 Intl.load,同时屏蔽原 intl.dart 头文件。

但是 messages_all.dart 等文件是自动生成的,随时可能会发生变化。若以手动的方式修改,不太可取。

另外,工程中涉及到多个插件,一个个修改肯定不是个事。

因此,考虑将以上流程以脚本的方式自动化进行。

脚本自动化

整体思路也比较简单,如下所示:

  1. 准备一份已经定义好的 intl 文件,也就是内部有自己的 messageLookup
  2. 遍历工程目录,判断是否为插件目录。如果是插件,且存在 l10n.dartmessages_all.dart,则将步骤 1 中的 intl 文件拷贝到插件目录下。
  3. 修改 messages_all.dart 文件,新增 getMessageLookup 方法。若已存在,则进行替换。
  4. 修改 l10n.dart 文件,替换 S.load 实现。

这样,在 intl 插件生成代码后,运行脚本,则可修正问题。

完整的 ruby 脚本可查看:flutter_app_localization/intl.rb at main · silan-liu/flutter_app_localization

不足之处

由于本地化代码是 IDE 插件自动生成的,那么在开发过程中,很有可能在代码变动后忘记执行脚本,而直接提交了代码。

这样一来,插件的本地化还是会存在问题。

因此,后续考虑两种方案:

  • 在 ci 打包时,添加执行脚本这一步骤,让打出的包是正确的。
  • 添加 pre-commit hook,在提交代码之前先执行脚本,保证提交代码的正确性。

相关链接

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,362评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,330评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,247评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,560评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,580评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,569评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,929评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,587评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,840评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,596评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,678评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,366评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,945评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,929评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,165评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,271评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,403评论 2 342

推荐阅读更多精彩内容