阅读 513

Flutter高级(二)——国际化与换肤

Flutter发布到现在有一段时间了,目前为止,很多公司都还没有接受Flutter进行开发,这原因是多方面的,毕竟在没有足够的“探索者”前,贸然使用遇到麻烦时,解决起来太过麻烦。

现在市面上感觉做的比较好的一款产品,除了官方demo:Gallery,再就是第一款Flutter开发的Github客户端,详细信息请移步:gitme

参照着Flutter中文网以及,大牛正在编写中的书籍:Flutter实战,大致梳理了一下Flutter开发流程,不过由于架构的不同,之前iosAndroid最主要的还是操作dom,而Flutter则与RN类似的采用的响应式操作,这就导致在迁移开发平台时,会不自主想将以前的经验进行搬移时遇到麻烦。

android、ios以及web端,开发框架流程等都已经很纯熟和完善了,而Flutter就目前的生态来说,还是有些“薄弱”,具体的开发还是需要进行系统的学习,这里只是简单给出一种方式来完成前端很常见的国际化与换肤功能

完整代码移步github-demo:flutter_skin_locale

效果图如下:

一、基本思路解析

因为Flutter出身同为Androidgoogle,所以这里暂时以Android实现方式进行比对;

1、安卓实现

国际化

安卓中国际化操作比较简单,因为系统已经提供了这方面的解决方案,我们只要将对应的资源文件放入不同的res资源目录下面就可以了,系统会根据当前的locale值查找对应的 res 资源目录,然后根据资源id查找资源名称,最后根据资源名称查找到具体文件。这个流程对于应用层开发人员来说是无感知的,所有要做的只是配置...然后打包。

换肤

安卓中原生是不支持换肤的,其实在安卓诞生时候也没有这方面的需求,后来大厂商为了某些销售活动,或者为了更好适应夜间模式及个性化,才有了这方面需求。就目前来看使用最多的是换肤框架,比如很多star的Android-skin-support;即便有框架的支持,在涉及大量自定义控件的情况下,仍需要做很多的适配工作。

2、Flutter实现

字符串国际化

很有意思的是,在 Android 中只需要依照配置就可以完成的国际化功能,在Flutter中很难行得通,因为Flutter中没有了Android的 res 系统,所有需要操作的图片,图标颜色值,都只能通过文件或者代码硬性插入,这一点很不舒服,虽然官方给了flutter_localizations库,但使用起来就知道,真的相当麻烦,我们不妨抛开Flutter部分平台的机制,看自己搭轮子是否可以实现换肤功能;

在真正开始之前,还是得先了解一点Flutter中入口的逻辑,否则肯定找不到头绪:

return MaterialApp(
  title: 'Flutter Title',
  routes: ...,
  localizationsDelegates: [
    S.delegate,
    GlobalMaterialLocalizations.delegate, //Material 组件库所使用的字符串
    GlobalWidgetsLocalizations.delegate, // 在当前的语言中,文字默认的排列方向
  ],
  supportedLocales: S.delegate.supportedLocales,
  localeResolutionCallback: S.delegate.resolution(fallback: const Locale('en', '')), 
  locale: mapLocales[SupportLocale.values[gCurrentSupportLocale]],
);
复制代码

Flutter 入口程序一般都是这个样子,针对涉及国际化的每个字段,简单的说明一下:

字段 含义
localizationsDelegates 国际化代理类,这个只需知道是国际化字符串资源类的集合即可,一般我们会将自定义的国际化字符串对象在这里声明;具体功能可以查看[LocalizationsDelegate]类
supportedLocales 系统支持的语言环境,比如中文简体,中文繁体等等,注意的是,locale要同时赋值语言和国家,以英文为例:Locale("en", "")
localeResolutionCallback 如果当前手机设置的语言环境或者说宿主app设置的语言环境不在 supportedLocales 中,那么需要默认一个locale值,不默认也可以,系统会默认取支持列表supportedLocales中第一个值
locale 自己设定一个当前语言locale,如果不设置或者设置为null,就取宿主app当前的语言环境(等价于设置语言环境为:"跟随系统")

我们大致只需要知道上面所说的部分即可,假设现在需要实现一个语言切换的功能,需要包含以下几种类型:

  1. 跟随系统
  2. 简体中文
  3. 繁体台湾
  4. 繁体香港
  5. 英文

对于跟随系统来说,只要将MaterialApplocale字段置为null,其他四种情况分别对应不同的 locale即可;至于MaterialApp中另外几个字段,也只需要根据支持列表一一填入;最重要的部分是国际化资源代理类:S的创建与更新。

虽说Flutter不支持,但只要程序员够懒,总会有适合的工具出现的,这里给出一个最简单的用于国际化的插件:Flutter i18n

有了这个插件开发起来会方便的多,在安装此插件后,项目中会自动生成${project}/res/values/*.arb以及${project}/lib/generated/i18n.dart文件;*.arb代表多个文件(工具只会帮忙生成strings_en.arb),类似于安卓中多个目录下针对字符串的配置,这个可以自行添加或者删除*.arb文件,多添加一个正确的配置文件,就相当于多一种语言支持,添加*.arb文件后编辑器会自己处理剩下的逻辑,或者点击工具栏顶部的这个图标:

Flutter i18n已经集成了快捷键来提取字符串,这个和android中的这个功能使用方法相同:

继续之前工具自动生成的两部分文件说,*.arb文件类似下面的结构:

注意:strings_en.arb是默认的arb文件,其他arb文件需要根据默认的arb文件生成对应的字符串。

i18n.dart是国际化的核心代码,大致结构如下:

可以看到,里面包含了不同国家地区(自己配置支持的国际语言,和*.arb相对应),同样的,已经替我们生成了MaterialApp中所需的几乎所有配置(具体使用配置可参照demo)。

最后使用的话也很简单,在代码中需要字符串的地方替换为这种:

S.of(context).label_soft_setting
复制代码

S类是i18n.dart自动帮助我们生成的,类似于一个代理类,根据不同的语言环境代理$zh_HK、$zh_TW、$en、$zh_CN这些具体实现类,达到国际化的目的

经过上面的总结,我们来看Flutter i18n到底完成了什么:

  1. 自动帮助生成 strings_en.arb 默认字符串模版
  2. 提供快捷方式,替换文件出现的字符串到*.arb文件中
  3. 根据*.arb自动生成i18n.dart文件,包含支持语言列表,国际化代理类等
  4. i18n.dart 提供在运行时提取不同国家语言字符串的功能方法

整体来看,该工具没有依赖任何库,也就是说相对于官方提供的方法,Flutter i18n不需要对pubspec.yaml做出修改。

基本上,如果项目中没有太过复杂的要求,只提供这种字符串国际化足够,但有些情况下, 针对不同的语言环境,图片也需要动态进行更替,关于更换图片的逻辑,放到文章下面介绍换肤功能的时候再考虑。

纯颜色换肤

对比其他平台换肤,我觉得Flutter换肤最为简单(这里换肤是指应用内换肤,不支持从互联网下载皮肤包换肤),因为系统默认提供了Theme,不得不说,在Flutter中处处可见Android开发的影子,Theme表示主题,表示应用整体的风格,theme中可定义各种类型用途的背景颜色,文字颜色,高亮;甚至于可以修改文字的字体大小,字体库,因此通过theme还可以实现应用内更改字体大小的功能,不过这篇文章先不考虑这个问题,在换肤后功能完成后,要实现应用内换肤是很容易的事情。

对于theme,可以截取一部分查看大致情况:

如上所见,对于分割线,主题色,按钮,高亮等,可以分别定义不同的颜色值,然后我们可以在MaterialApp中设置不同的theme(跟国际化配置在相同的地方):

return MaterialApp(
  title: 'Flutter Mudule',
  theme: themes[gCurrentThemeIndex],
  routes: ...,
  localizationsDelegates: [
    S.delegate,
    GlobalMaterialLocalizations.delegate, //Material 组件库所使用的字符串
    GlobalWidgetsLocalizations.delegate, // 在当前的语言中,文字默认的排列方向
  ],
  supportedLocales: S.delegate.supportedLocales,
  localeResolutionCallback: S.delegate.resolution(fallback: const Locale('en', '')), // 不存对应locale时,默认取值英文
  locale: mapLocales[SupportLocale.values[gCurrentSupportLocale]],
);
复制代码

其中theme字段即为当前应用或者说界面采用的主题,如果我们可以对其进行更改,就相当于对app进行换肤(这里的换肤只是指颜色换肤,真正换肤可能还需要涉及图片更换,情况与国际化类似,下面会提到这种解决方式)。

想要在代码中使用某个颜色时(有些颜色值为自定义,因此系统无法动态感知需要使用哪个样式),可以使用如下方式:

Theme.of(context).textTheme.display1.color
复制代码

图片换肤

到此为止,简单的颜色换肤和国际化处理思路已经清晰,遵循上面的逻辑,基本可以完成大部分的需求,不过就如上面所提,如果涉及图片部分,Flutter框架就无法直接处理了,事实上,flutter 如果需要获取一个图片,是需要知道具体路径的,类似这样:

Image.asset(
    "assets/images/icon_test.png",
    width: 45,
    height: 45,
),
复制代码

相比于android读取图片,Flutter这种方式麻烦的多,并且没有任何的智能提示;但也因为是这种调用方式,给了我们很大的自定义图片读取的空间,比如这样:

定义同级两个文件夹,里面分别放置不同主题样式,然后根据当前选中的主题,取图片时,选择不同的路径,类似这样:

/// 获取图片路径(中转,用于多环境等情况) [PlatformAssetBundle] 类查看资源获取逻辑
///
/// [useDefault] 是否使用默认的主题资源(当多theme使用相同image时,会有这种情况)
/// [picFormat] 图片格式,默认为png,
String dispatcherPictureByName(String picName, {bool useDefault = false, String picFormat = "png"}) {
  RegExp filter = RegExp("^[^.]+\.(png)|(jpg)|(jpeg)|(gif)|(webp)|(bmp)|(wbmp)\$", caseSensitive: false, multiLine: false);

  // 添加后缀
  picName = filter.hasMatch(picName) ? picName : "$picName.$picFormat";

  // 取系统主题颜色
  String pathName = "assets/images-$gCurrentThemeIndex/$picName";

  // 返回需要的路径
  return useDefault ? "assets/images-1/$picName" : pathName;
}
复制代码

其中$gCurrentThemeIndex表示当前主题下标序号,然后在代码中这样使用:

Image.asset(
    dispatcherPictureByName("icon_test",useDefault: true),
    width: 45,
    height: 45,
),
复制代码

现在应该明白了,如果是想让国际化时也取值不同的图片,只要类似这样定义不同的文件包,然后将图片放入即可,这个取值规则是自定义的,根据实际情况可以做出修改。

二、模块依赖项

上面给出了要实现国际化与换肤基本思路,但其中还有很多细节需要思考,比如:

  1. 如何定义主题包?
  2. 字符串获取时使用S.of(context).***,如果需要在没有context的地方获取字符串值,该如何处理?
  3. 当想要修改主题和语言环境时,怎样通知所有界面进行刷新?
  4. 国际化与换肤是要保持状态,如何在切换成功后记录保存?如果是以module的形式混入原生应用,该如何保证原生与Flutter层保持一致?

当然,最后一条可以可以不用管,那个属于混合开发的范畴,真正使用的时候再考虑跨平台混入bridge;对于记录保存,使用第三方的库即可:shared_preferences

针对上面的问题,分别进行讨论:

1、如何定义主题包

主题包的定义可以使用最简单的方式,新建一个文件app_theme_config.dart,将所有预定义的主题放在一起:

List<ThemeData> themes = [
  ThemeData(...),
  ThemeData(...),
  ThemeData(...),
  ThemeData(...),
]
复制代码

里面每一个ThemeData表示一套风格,或者说是一个皮肤;

再新建一个文件app_status_holder.dart,保存当前选择的皮肤的下标,取值范围为:[0,length-1],int类型,这样的话,每次需要更换皮肤时,只需要修改下标的值,然后从themes数组中取出对应的主题,赋值给MaterialApptheme字段即可。

app_status_holder.dart 文件:

/// 当前系统主题(暂不考虑外部引入主题情况)
int gCurrentThemeIndex = 0;
复制代码

2、无context时处理字符串?

跟上面的主题处理方式类似,我们也新建一个文件,保存当前app可能使用的字符串类app_locale_config.dart

/// 某些地方无法 获取context ,但又需要获取国际化的字符串时,但系统切换可能导致文字不会改变,因为字符串没有在 state方法中初始化
List<S> ss = [
    S(),
    $zh_CN(),
    $zh_TW(),
    $zh_HK(),
    $en(),
];

/// 当context 不存在时,通过SS而非S去获取字符串
S get SS {
    return ss[gCurrentSupportLocale];
}
复制代码

在没有context的情况如果想要获取到字符串,就必须知道当前语言环境到底是什么,我们模拟代理类S定义一个代理方法SS,然后在全局记录当前语言环境,间接的读取到正确的字符串值;

当然,只做这个是不够的,如果设置了语言设置为跟随系统,在系统语言进行切换时,调用方法SS获取到的一直会是S(),即系统默认的英文形式,那肯定是不行的,因此,必须在合适的地方调用一次这样的代码:

// 系统语言改变时,如果当前为跟随系统,则需要修改字符串读取对象
if (gCurrentSupportLocale == 0) {
  print("当前系统语言为:${Localizations.localeOf(context)}");
  ss[0] = S.of(context);
}
复制代码

也就是说,需要动态的修改ss数组列表中默认的语言。

3、如何通知界面刷新?

一般来说,修改语言环境或皮肤后,除了当前界面,已打开或创建的界面也都需要进行刷新,对于安卓平台来说,系统默认在config变化后重启界面来达到刷新的目的;

对于Flutter来说,刷新界面不需要重建,只需要调用setState方法即可;方便是方便,但这涉及到事件的推送;消息队列是最简单的推送方式,或者说是事件总线EventBus,这个框架在几乎所有的平台存在。

为了方便界面刷新,我们最好在最顶层监听事件,然后直接刷新MaterialApp,确保所有的界面都可以触发重绘操作,监听操作可以类似这样:

// 当通知系统时,刷新一下状态(换肤/切换语言/涨跌颜色)
eventBus.on<SystemThemeSwitch>().listen((it) {
    setState(() {
        gCurrentThemeIndex = it.currentThemeIndex;
    });
});
复制代码

在修改系统语言和皮肤切换的界面,如果修改成功,则需要触发事件:

  /// 切换主题
eventBus.fire(SystemThemeSwitch(currentThemeIndex: news.index));
setState(() {});
复制代码

三、代码整合查看效果

如上所述,结合了国际化、换肤、本地持久化、路由跳转框架以及图片更改等思路,入口程序应该像这样:

/// 程序入口
void main() => runApp(CustomApp());

/// 自定义包裹 app, 实现换肤等功能
class CustomApp extends StatefulWidget {
    @override
    State createState() => _CustomAppState();
}

class _CustomAppState extends State<CustomApp> {

    @override
    void initState() {
        super.initState();
        // 初始化皮肤取值等全局 所需 参数
        SharedPreferences.getInstance().then((it) {
            setState(() {
                gCurrentThemeIndex = it.getInt(KEY_THEME_MODE) ?? 0;
                gCurrentSupportLocale = it.getInt(KEY_SUPPORT_LOCALE) ?? 0;
            });
        });

        // 当通知系统时,刷新一下状态(换肤/切换语言/涨跌颜色)
        eventBus.on<SystemThemeSwitch>().listen((it) {
            setState(() {
                gCurrentThemeIndex = it.currentThemeIndex;
            });
        });
        eventBus.on<SupportLocaleSwitch>().listen((it) {
            setState(() {
                gCurrentSupportLocale = it.currentSupportLocale;
            });
        });
    }

    @override
    Widget build(BuildContext context) {
        return MaterialApp(
            title: 'Flutter Mudule',
            debugShowCheckedModeBanner: false,
            theme: themes[gCurrentThemeIndex],
            routes: gActivityRoutes,
            localizationsDelegates: [
                S.delegate,
                GlobalMaterialLocalizations.delegate, //Material 组件库所使用的字符串
                GlobalWidgetsLocalizations.delegate, //在当前的语言中,文字默认的排列方向
            ],
            supportedLocales: S.delegate.supportedLocales,
            localeResolutionCallback: S.delegate.resolution(fallback: const Locale('en', '')), // 不存对应locale时,默认取值英文
            locale: mapLocales[SupportLocale.values[gCurrentSupportLocale]],
        );
    }
}
复制代码

前面还提到,需要在程序第一个界面widgetbuild方法添加如下代码:

像上面这样配置后,主流程基本已经完成了,剩下的代码就是编写页面,变量定义等等,事实上在变量少的情况下,使用event-bus尚可。

如果需要改变变量过多逻辑较大的情况下,可以尝试使用flutter_redux,项目中有提供简单的使用方式:main_redux.dart;

更多功能请提issues

完成项目请参照flutter_skin_locale

关注下面的标签,发现更多相似文章
评论