Flutter Intl 远程管理,动态化更新语言包

lxf2023-05-04 04:54:01

持续创作,加速成长!这是我参与「AdminJS · 6 月更文挑战」的第2天,点击查看活动详情

背景

之前有个需求做Flutter的多语言国际化嘛,然后立马想起有Intl,看着文档唰唰唰写完了,自测,嗯,没有问题,转头就扔去测试。

然后开会领导问我,“你这个文案都是在哪里管理啊?” “emmm,项目代码内,本地憋” “嗯?那我要修改怎么办?” “改完发版咯” “那不行啊!那文案有问题我还等你发版啊?文案你自己管理吗?” “emmm,好吧”

行吧,看来不能简单的摸鱼了。

分析

首先分析一下,一个简单的intl结构如下

-generated
--intl
---messages_all.dart
---messages_en.dart
---messages_zh.dart
--l10n.dart
-l10n
--intl_en.arb
--intl_zh.arb

generated中的为自动生成的文件,l10n中则为我们的多语言文件,intl_**.arb的内容基本是键值对的json(不考虑有‘@’注释辅助信息的情况)。

看起来好像是无法处理,因为重点在generated中,而generated都是自动生成的,即使修改,再次运行命令就会被覆盖

不妨往下接着看看,首先l10n.dart中有两个类,一个为S,即我们获取Localizations.of()拿到文案的类;一个是AppLocalizationDelegate

S我是使用flutter_intl自动生成的,除非想自己写S,不然不大可能修改S来另外管理。以下是一个基本的S

class S {
  S();

  static S? _current;

  static S get current {
    assert(_current != null,
        'No instance of S was loaded. Try to initialize the S delegate before accessing S.current.');
    return _current!;
  }

  static const AppLocalizationDelegate delegate = AppLocalizationDelegate();

  static Future<S> load(Locale locale) {
    final name = (locale.countryCode?.isEmpty ?? false)
        ? locale.languageCode
        : locale.toString();
    final localeName = Intl.canonicalizedLocale(name);
    return initializeMessages(localeName).then((_) {
      Intl.defaultLocale = localeName;
      final instance = S();
      S._current = instance;

      return instance;
    });
  }

  static S of(BuildContext context) {
    final instance = S.maybeOf(context);
    assert(instance != null,
        'No instance of S present in the widget tree. Did you add S.delegate in localizationsDelegates?');
    return instance!;
  }

  static S? maybeOf(BuildContext context) {
    return Localizations.of<S>(context, S);
  }

  /// `test title`
  String get test {
    return Intl.message(
      'test title',
      name: 'test',
      desc: '',
      args: [],
    );
  }
}

AppLocalizationDelegate就不同了,它基本是固定的,主要功能是注册到MaterialApplocalizationsDelegates,然后我们就可以在Localizations拿到对应的翻译文案。所以我们可以考虑编写自己的Delegates

class AppLocalizationDelegate extends LocalizationsDelegate<S> {
  const AppLocalizationDelegate();

  List<Locale> get supportedLocales {
    return const <Locale>[
      Locale.fromSubtags(languageCode: 'en'),
      Locale.fromSubtags(languageCode: 'zh'),
    ];
  }

  @override
  bool isSupported(Locale locale) => _isSupported(locale);
  @override
  Future<S> load(Locale locale) => S.load(locale);
  @override
  bool shouldReload(AppLocalizationDelegate old) => false;

  bool _isSupported(Locale locale) {
    for (var supportedLocale in supportedLocales) {
      if (supportedLocale.languageCode == locale.languageCode) {
        return true;
      }
    }
    return false;
  }
}

简单来说,就是定义支持的语言列表,以及load怎么加载得到对应的S

那么这个load就是重点信息了,我们怎么把我们自己的资源加载进去呢?看看intl是怎么做的,即S.load()

  static Future<S> load(Locale locale) {
    final name = (locale.countryCode?.isEmpty ?? false)
        ? locale.languageCode
        : locale.toString();
    // 获取intl可识别格式的localeName
    final localeName = Intl.canonicalizedLocale(name);
    // 加载对应localeName的翻译Messages
    return initializeMessages(localeName).then((_) {
      // 加载成功后设置defaultLocale,并把S实例返回
      Intl.defaultLocale = localeName;
      final instance = S();
      S._current = instance;

      return instance;
    });
  }

上面我简单写了下注释,重点即是initializeMessages,跟着继续来到messages_all.dart

/// User programs should call this before using [localeName] for messages.
Future<bool> initializeMessages(String localeName) async {
  // 验证Locale是否支持是否有效
  var availableLocale = Intl.verifiedLocale(
      localeName, (locale) => _deferredLibraries[locale] != null,
      onFailure: (_) => null);
  if (availableLocale == null) {
    return new Future.value(false);
  }
  // 可以异步等待操作
  var lib = _deferredLibraries[availableLocale];
  await (lib == null ? new Future.value(false) : lib());
  // 实例化MessageLookup()
  initializeInternalMessageLookup(() => new CompositeMessageLookup());
  // 找到对应Locale的Messages并add到messageLookup中
  messageLookup.addLocale(availableLocale, _findGeneratedMessagesFor);
  return new Future.value(true);
}

大致描述了下功能,可以看到,官方已经预留了异步加载的位置,也就是说他的设计支持远程的。异步远程找到地方了,那么重点就是messageLookup.addLocale(availableLocale, _findGeneratedMessagesFor);,远程拉取的资源,还需要添加到messageLookup中,因为最终Localizations获取的文案,就是messageLookup中的。

abstract class MessageLookup {
  String? lookupMessage(String? messageText, String? locale, String? name,
      List<Object>? args, String? meaning,
      {MessageIfAbsent? ifAbsent});
  void addLocale(String localeName, Function findLocale);
}

lookupMessage即是取得对应Message方法

先跟着_findGeneratedMessagesFor走,跳过下中间的检查是否有效的步骤,最终到_findExact

MessageLookupByLibrary? _findExact(String localeName) {
  switch (localeName) {
    case 'en':
      return messages_en.messages;
    case 'zh':
      return messages_zh.messages;
    default:
      return null;
  }
}

很明显就是根据localName获取对应的MessageLookupByLibrary,而看到messages_en.messages的内容,就有点头大了,它是自动生成的固定写死的类。

final messages = new MessageLookup();

typedef String MessageIfAbsent(String messageStr, List<dynamic> args);

class MessageLookup extends MessageLookupByLibrary {
  String get localeName => 'en';

  final messages = _notInlinedMessages(_notInlinedMessages);
  static Map<String, Function> _notInlinedMessages(_) => <String, Function>{
        "test": MessageLookupByLibrary.simpleMessage("test title")
      };
}

MessageLookupByLibrary提供对应的Messages返回给_findExact

至此我们对于intl如何加载翻译的整个流程就大致了解了。

准备冻手!

首先看看messages_en.messages这种东西怎么处理,有复杂的_notInlinedMessages,甚至有占位符还会有各个方法。

static String m16(percent) => "${percent}%";

但让我们无视它们。重点其实就是messageslocaleNamelocaleName_findExact中可以给到;messages的话,简单来说就是map。那么,一个简单通用MessageLookupByLibrary就可以这样定义:

/// intl 可解析的结构体
class MessageLookup extends MessageLookupByLibrary {
  MessageLookup(this.messages, this.localeName);
  final Map<String, dynamic> messages;
  final String localeName;
}

messagesvalue,并不是简简单单的String,而是Function(),然后返回的翻译。那也是说我们需要对远程的资源(arb即json),进行解析,得到对应的格式,然后再赋给messages

其实就是差不多下面的格式:

([v1, v2, v3]) {
    return str;
}

注意你并不确定它具体有多少个入参。它实际是Function.apply()

/// Evaluate the translated message and return the translated string.
String? evaluateMessage(translation, List<dynamic> args) {
  return Function.apply(translation, args);
}

OK,那Messages就解决了。那接下来就是顺水推舟,编写我们自己的Delegate,然后注册到MaterialApplocalizationsDelegates中。 在load中,使用我们已经修改好的initializeDynamicMessages

static Future<S> _load(Locale locale) {
  final name = (locale.countryCode?.isEmpty ?? false)
      ? locale.languageCode
      : locale.toString();
  final localeName = Intl.canonicalizedLocale(name);
  return initializeDynamicMessages(localeName).then((_) {
    Intl.defaultLocale = localeName;
    final instance = S();

    return instance;
  });
}

注册到MaterialApp

localizationsDelegates: const [
  /// Delegate 注册
  AppLocalizationDynamicDelegate.delegate,
  GlobalMaterialLocalizations.delegate,
  GlobalWidgetsLocalizations.delegate,
  GlobalCupertinoLocalizations.delegate,
],

/// 支持的语言列表
supportedLocales:
    AppLocalizationDynamicDelegate.delegate.supportedLocales,

还有支持的语言列表也要改为我们自己的。调整了supportedLocales,也需要GlobalMaterialLocalizations.delegate这几个让系统的文案也支持这几个语言。

然后就能够让我们的国际化使用我们的远程资源了,是不是很简单。

结束

然后再补充一点点的功能,就实现了Flutter Intl的远程动态化,可以远程管理我们的文案翻译。这是我写的一个插件dynamic_intl,然后也会传到pub.dev,怎么用可以看看README,里面也有个简单的Demo。