关于多语言插件报错,我动手解析生成代码的这件事

2,811 阅读7分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第5天,点击查看活动详情


起因

今天本想用 Flutter Intl 插件来玩玩 多语言 ,不知道是 AndroidStudio 版本问题,还是什么,没想到添加语言时一直报错。不就是生成几个类,解析一下资源文件嘛,自己动手丰衣足食。再加上之前写个一个简单的多语言解析 ,刚好借此来稍微完善一下。

另外 Flutter Intl 插件的工作方式会实时监听 arb 文件的变化,生成代码。我并不喜欢这种时时监听的感觉,还是觉得写个小脚本,想跑就跑,又快又便捷。 自己把握核心逻辑,这样就不必看插件的 “脸色”


一、 使用介绍

代码已经开源,在 【toly1994328/i18n_builder】 中可获取脚本源码,同时这也是一个非常精简的多语言切换示例。


如何使用
  • 1.把这个脚本文件拷贝到你项目文件夹,
  • 2.在命令行中,进入 script/i18n_builder 文件,运行 dart run.dart . 即可生成默认的文件。
cd script/i18n_builder # 进入脚本文件夹
dart run.dart . # 在 lib 下创建名为 I18n 的相关文件


如果不想通过命令行,在 run.dart 中直接点运行也是可以的。


2. 定制化参数

有两个可定制的参数,分别是生成文件的文件夹,以及调用名。通过命令行可指定参数:

cd script/i18n_builder # 进入脚本文件夹
dart run.dart -D=lib,src,app -N=S # 在 lib/src/app 下创建名为 S 的相关文件

比如上面的命令可以指定在 lib/src/app 生成文件,并且调用的类为 S。也就是说,在代码中通过下面语句进行访问属性: 默认的调用类是 I18n ,你可以自由指定:

S.of(context).XXX


如果直接运行,可以在此进行指定:


3.资源说明

字符资源通过 json 的形式给出,如果你想添加一个新语言,只需要提供 languageCode_countryCode.json 的文件即可。

其中支持 参数变量 ,使用 {变量名} 进行设定。另外还支持变量的默认参数,通过 {变量名=默认参数} 进行指定:

I18n.of(context).info2(count: '$_counter')
I18n.of(context).info2(count: '$_counter',user: 'toly')

一、支持多语言的流程

我们先来看一下对于 Flutter 来说,该如何支持多语言。如下所示,先给一个最精简的案例实现:

中文英文

1. 准备工作

首先在 pubspec.yaml 文件中添加 flutter_localizations 的依赖:

dependencies:
  #...
  flutter_localizations: 
    sdk: flutter

在使用时我们需要在 MaterialApp 中配置三个参数:

  • tag1 : 代理类列表。其中 I18nDelegate 是自定义的代理( 通过脚本生成 )。
  • tag2: 语言支持的列表。
  • tag3 : 当前支持的语言。
MaterialApp(
		//...
    localizationsDelegates: [ // tag1
      ...GlobalMaterialLocalizations.delegates,
      I18nDelegate.delegate 
    ],
    supportedLocales: I18nDelegate.delegate.supportedLocales, // tag2
    locale: const Locale('zh', 'CH'), // tag3
);

多语言切换的功能实现其实非常简单,修改 tag3 处的 locale 参数即可。所以关键还是代理类的实现。


2. 代理类的书写

其中 supportedLocales 表示当前支持的语言:

///多语言代理类
class I18nDelegate extends LocalizationsDelegate<I18N> {
  I18nDelegate._();

  final List<Locale> supportedLocales = const [ // 当前支持的语言
    Locale('zh', 'CH'),
    Locale('en', 'US'),
  ];

  @override
  bool isSupported(Locale locale) => supportedLocales.contains(locale);

  ///加载当前语言下的字符串
  @override
  Future<I18N> load(Locale locale) {
    return SynchronousFuture<I18N>(I18N(locale));
  }

  @override
  bool shouldReload(LocalizationsDelegate<I18N> old) => false;

  ///代理实例
  static I18nDelegate delegate = I18nDelegate._();
}

I18N 类中进行文字的获取构造,其实整个流程还是非常简洁易懂的:

class I18N {
  final Locale locale;
  I18N(this.locale);

  static const Map<String, Map<String,String>> _localizedValues = {
    'en_US': {
      "title":"Flutter Demo Home Page",
      "info":"You have pushed the button this many times:",
      "increment":"Increment:",
    }, //英文
    'zh_CH': {
      "title":"Flutter 案例主页",
      "info":"你已点击了多少次按钮: ",
      "increment":"增加",
    }, //中文
  };

  static I18N of(BuildContext context) {
    return Localizations.of(context, I18N);
  }

  get title => _localizedValues[locale.toString()]!['title'];
  get info => _localizedValues[locale.toString()]!['info'];
  get increment => _localizedValues[locale.toString()]!['increment'];
}

3. 使用方式

使用方式也非常简洁,通过 .of 的方式从上下文中获取 I18N 对象,再获取对应的属性即可。

I18N.of(context).title

从这里也可以看出,本质上这也是通过 InheritedWidget 组件实现的。多语言的关键类是 Localization 组件,其中使用了 _LocalizationsScope 组件。


二、如何自己写脚本

本着代码本身就是字符串的理念,我们只要根据资源来生成上面所述的字符串即可。这里考虑再三,还是用 json 记录数据。文件名使用 languageCode_countryCode 来标识,比如 zh_CH 标识简体中文,zh_HK 标识繁体中文。另外如果不知道对应的 语言代码表 ,稍微搜索一下就行了。


1. 文件夹的解析

先来根据资源文件解析处需要支持的 Local 信息与 Attr 属性信息,如下所示:

先定义如下的实体类,用于收录信息。其中 ParserResult 类是最终的解析结果:

class LocalInfo {
  final String languageCode;
  final String? countryCode;

  LocalInfo({required this.languageCode, this.countryCode});
}

class AttrInfo {
  final String name;
  AttrInfo({required this.name});
}

class ParserResult {
  final List<LocalInfo> locals;
  final List<AttrInfo> attrs;
  final String scriptPath;

  ParserResult({required this.locals, required this.attrs,required this.scriptPath});
}

Parser 类中,遍历 data 文件,通过文件名来收集 Local ,核心逻辑通过 _parserLocal 方法实现。然后读取第一个文件来对属性进行收集,核心逻辑通过 _parserAttr 方法实现。

class Parser {
  Future<ParserResult> parserData(String scriptPath) async {
    Directory dataDir =
        Directory(path.join(scriptPath, 'script', 'i18n_builder', 'data'));
    List<FileSystemEntity> files = dataDir.listSync();
    List<LocalInfo> locals = [];
    List<AttrInfo> texts = [];

    for (int i = 0; i < files.length; i++) {
      if (files[i] is File) {
        File file = files[i] as File;
        locals.add(_parserLocal(file.path));
        if (i == 0) {
          String fileContent = await file.readAsString();
          Map<String, dynamic> decode = json.decode(fileContent);
          decode.forEach((key, value) {
            texts.add(_parserAttr(key,value.toString()));
          });
        }
      }
    }
    return ParserResult(locals: locals, attrs: texts,scriptPath: scriptPath);
  }
}

如下是 _parserLocal_parserAttr 的实现:

  // 解析 LocalInfo
  LocalInfo _parserLocal(String filePath) {
    String name = path.basenameWithoutExtension(filePath);
    String languageCode;
    String? countryCode;
    if (name.contains('_')) {
      languageCode = name.split('_')[0];
      countryCode = name.split('_')[1];
    } else {
      languageCode = name;
    }
    return LocalInfo(
      languageCode: languageCode,
      countryCode: countryCode,
    );
  }

  // 解析属性
  AttrInfo _parserAttr(String key, String value){
   return AttrInfo(name: key);
  }

2.根据分析结果进行代码生成

现在 食材 算是准备完毕了,下面来对它们进行加工。主要目标就是点击运行,可以在指定文件夹内生成相关代码,如下所示:

如下通过 Builder 类来维护生成代码的工作,其中 dir 用于指定生成文件的路径, caller 用于指定调用类。比如之前的是 I18n.of(context) ,如果用 Flutter Intl 的话,可能习惯于S.of(context) 。其实就是在写字符串时改个名字而已,暴露出去,使用者可以更灵活地操作。

class Builder {
  final String dir;
  final String caller;
  
  Builder({
    required this.dir,
    this.caller = 'I18n',
  });
  
  void buildByParserResult(ParserResult result) async {
    await _ensureDirExist();
    await _buildDelegate(result);
    print('=====${caller}_delegate.dart==文件创建完毕==========');
    await _buildCaller(result);
    print('=====${caller}.dart==文件创建完毕==========');
    await _buildData(result);
    print('=====数据文件创建完毕==========');
  }

另外 buildByParserResult 方法负责根据解析结构生成文件,就是字符串的拼接而已,这里就不看贴了。感兴趣的可以自己去源码里看 【i18n_builder】


三、支持字符串解析

有时候,我们是希望支持变量的,这也就表示需要对变量进行额外的解析,这也是为什么之前 _parserAttr 单独抽出来的原因。比如下面的 info2 中有两个参数,可以通过 正则表达式 进行匹配。


1. 属性信息的优化

下面对 AttrInfo 继续拓展,增加 args 成员,来记录属性名列表:

class AttrInfo {
  final String name;
  List<String> args;

  AttrInfo({required this.name});
}


2. 解析的处理

正则表达式已经知道了,解析一下即可。代码如下:

// 解析属性
AttrInfo _parserAttr(String key, String value){
  RegExp regExp = RegExp(r'{(?<tag>.*?)}');
  List<String> args = [];
  List<RegExpMatch> allMatches = regExp.allMatches(value).toList();
  allMatches.forEach((RegExpMatch match) {
    String? arg = match.namedGroup('tag');
    if(arg!=null){
      args.add(arg);
    }
  });
  print("==$key==$args");
  return AttrInfo(name: key,args: args);
}

然后对在文件对应的属性获取时,生成如下字符即可:


这样在使用该属性时,就可以传递参数,使用及效果如下:

Text(
  I18n.of(context).info2(user: 'toly', count: '$_counter'),
),
中文英文

3.支持默认参数

在解析时,通过校验 {=} 号,提供默认参数。

在生产代码是对于有 = 的参数,使用可空处理,如果有默认值,通过正则解析出默认值,进行设置:


4. 支持命令行

为了更方便使用,可以通过命令行的方式来使用。

cd script/i18n_builder # 进入脚本文件夹
dart run.dart -D=lib,src,app -N=S # 在 lib/src/app 下创建名为 S 的相关文件

需要额外进行的就是对入参字符串列表的解析:

main(List<String> args) async {
  ...
  if(args.isNotEmpty){
    scriptPath = Directory.current.parent.parent.path;
    args.forEach((element) { 
      if(element.contains("-D")){
        String dir = element.split('=')[1];
        List<String> dirArgs = dir.split(',');
        String p = '';
        dirArgs.forEach((d) {
          p = path.join(p,d);
        });
        distDir= p;
      }
      if(element.contains("-N")){
        caller = element.split('=')[1];
      }
    });
  }

这样总体来说就比小完善了,如果你有什么意见或建议,欢迎提出 ~