“极简诗词”app开发背后:Flutter移动应用快速构建实践——状态管理、国际化、数据持久化、性能优化(一)

前言

在开始之前的提示:虽然Flutter背靠Google这棵大树,但是毕竟还是一个年轻的技术,项目还处于初期阶段,更新非常快,问题也不是一般的多,使用Flutter的话就意味着必须忍受各种奇怪的bug和没有丰富中文资料的头疼,本文不是安利同学们入坑,只是对“极简诗词”app的开发过程进行记录。

另外app已经上架,有兴趣的同学可以下载试试:https://www.coolapk.com/apk/251155

主要界面截图: | 主页 | 暗黑版主页 | 诗集 | 诗集浏览 | | ----- | ------ | ------ | ------ | | | | 诗集详情 | 作者列表 | 作者详情 | 字体选择 | |

和Django快速开发实践的文章一样,本文不讲废话,直接上步骤。

项目文件结构

先设计好项目文件结构,不同的项目有不同的需求,按照自己的实际需要来设计结构就好了,以下是我的项目结构,仅供参考:

lib
├── common
├── i10n
├── models
├── routes
├── states
└── widgets
| 文件夹 | 作用 | | ------ | ------- | | common | 一些工具类,如通用方法类、网络接口类、保存全局变量的静态类等 | | i10n | 存放国际化相关代码 | | models | 通过json to models生成的model类文件都存在这里 | | routes | 存放项目的所有页面代码 | | states | 保存app中需要跨组件共享的状态类 | | widgets | 存放自定义widget |

定义好models

在本项目中,我使用json to models来自动生成models类,为什么使用这个呢?原因很简单,减少工作量,用json定义好app中使用到的模型,生成model类之后可以很方便序列化成json数据进行持久化和或者从配置文件中读取json数据反序列化成model对象,还可以直接根据后台接口返回的json数据生成model类,非常方便。

使用json定义model,例子如下:

在项目根目录下创建json文件夹,添加要进行转换的json文件,内容大概像这样。 poem.json

{
    "strains": [
        "平平平仄仄,平仄仄平平。",
        "仄仄平平仄,平平仄仄平。",
        "平平平仄仄,平仄仄平平。",
        "平仄仄平仄,平平仄仄平。"
    ],
    "author": "作者名称",
    "authorObj": "$author",
    "paragraphs": [
        "秦川雄帝宅,函谷壯皇居。",
        "綺殿千尋起,離宮百雉餘。",
        "連甍遙接漢,飛觀迥凌虛。",
        "雲日隱層闕,風煙出綺疎。"
    ],
    "tags": [
        "战争",
        "生活",
        "冬天",
        "爱国",
        "边塞"
    ],
    "chapter": "国风",
    "section": "周南",
    "rhythmic": "玉楼春",
    "title": "帝京篇十首 一",
    "content": "经传宜独坐读,史鉴宜与友共读。",
    "comment": [
        "孙恺似曰:深得此中真趣,固难为不知者道。",
        "王景州曰:如无好友,即红友亦可。"
    ],
    "notes": [
        "1.小山--写女子的隔夜残妆。小山:女子画眉的式样之一。小山重叠:眉晕褪色。金:额黄,在额上涂黄色。金明灭:褪色的额黄明暗不匀。",
        "2.鬓云欲度--鬓发撩乱如云,低垂下来。香腮雪:洁白如雪的香腮。",
        "3.照花--对镜簪花。用前镜、后镜对照以瞻顾后影。",
        "4.双双--罗襦上用金线绣的成双的鹧鸪鸟。反衬自身孤独。"
    ],
    "anthology": "所属诗集名称",
    "id": "08e41396-2809-423d-9bbc-1e6fb24c0ca1"
}

添加依赖:

dev_dependencies:
  json_model: ^0.0.2
  build_runner: ^1.0.0
  json_serializable: ^2.0.0

好了之后运行:

flutter packages pub run json_model

这样就会自动在lib/models文件夹下面生成models类啦。

有个坑爹的地方是这个json_model库只能支持很老版本的build_runnerjson_serializable,这和我后面要用到的intl就冲突了啊,每次用这两个库的时候我都要不断注释切换依赖的版本,真的麻烦 = =....

状态管理

状态管理是app中最重要的一部分,也是后面主题切换和国际化的基础。 本文是快速开发实践,不过多深入Flutter的状态管理,想了解的同学可以看看大佬写的Flutter教程:https://book.flutterchina.club/chapter7/provider.html

我是使用Provider这个组件来管理app的状态的,它基于InheritedWidget实现,用起来挺方便。

先添加依赖:

dependencies:
  provider: ^3.2.0

lib/states文件夹下添加共享状态的models,例如:

import 'package:flutter/material.dart';
import 'package:minimal_poem/common/global.dart';
import 'package:minimal_poem/models/index.dart';
import 'notifier.dart';

class ProfileChangeNotifier extends ChangeNotifier {
  Profile get profile => Global.profile;

  @override
  void notifyListeners() {
    // 保存Profile变更
    Global.saveProfile();
    Global.saveAllUsers();
    super.notifyListeners(); // 通知依赖的Widget更新
  }
}

class ThemeModel extends ProfileChangeNotifier {
  // 获取当前主题,如果未设置主题,则默认使用蓝色主题
  ColorSwatch get theme => Global.themes.firstWhere((e) => e.value == profile.theme, orElse: () => Colors.blue);

  // 主题改变后,通知其依赖项,新主题会立即生效
  set theme(ColorSwatch color) {
    if (color != theme) {
      profile.theme = color[500].value;
      notifyListeners();
    }
  }

  bool get darkMode => Global.profile.darkMode;

  set darkMode(bool value) {
    Global.profile.darkMode = value;
    notifyListeners();
  }
}

这些model继承自ProfileChangeNotifier,可以提供数据或者管理数据的修改和保存。

在普通的组件里可以直接使用获取或保存数据,配合provider组件使用可以在model数据改变的时候出发组件的更新动作~

例如我的MyApp类定义,用到了MultiProviderConsumer2

class MyApp extends StatelessWidget {
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MultiProvider(
      providers: <SingleChildCloneableWidget>[
        ChangeNotifierProvider.value(value: ThemeModel()),
        ChangeNotifierProvider.value(value: UserModel()),
        ChangeNotifierProvider.value(value: LocaleModel()),
      ],
      child: Consumer2<ThemeModel, LocaleModel>(
        builder: (BuildContext context, themeModel, localeModel, Widget child) {
          return MaterialApp(
            theme: ThemeData(
              brightness: Global.profile.darkMode ? Brightness.dark : Brightness.light,
              primarySwatch: themeModel.theme,
            ),
            onGenerateTitle: (context) {
              return DaLocalizations.of(context).title;
            },
            home: HomeRoute(),
            //应用主页
            locale: localeModel.getLocale(),
            //我们只支持美国英语和中文简体
            supportedLocales: [
              const Locale('zh', 'CN'), // 中文简体
              const Locale('en', 'US'), // 美国英语
              //其它Locales
            ],
            localizationsDelegates: [
              // 本地化的代理类
              GlobalMaterialLocalizations.delegate,
              GlobalWidgetsLocalizations.delegate,
              // EasyRefresh的多语言支持
              GlobalEasyRefreshLocalizations.delegate,
              // 注册我们的Delegate
              DaLocalizationsDelegate()
            ],
            localeResolutionCallback: (Locale _locale, Iterable<Locale> supportedLocales) {
              if (localeModel.getLocale() != null) {
                //如果已经选定语言,则不跟随系统
                return localeModel.getLocale();
              } else {
                Locale locale;
                // APP语言跟随系统语言,如果系统语言不是中文简体或美国英语,
                // 则默认使用美国英语
                if (supportedLocales.contains(_locale)) {
                  locale = _locale;
                } else {
                  locale = Locale('en', 'US');
                }
                return locale;
              }
            },
          );
        },
      ),
    );
  }
}

国际化支持

国际化就是多语言啦,用到了intl包。

在项目根目录下创建文件夹i10n-arb,在lib/i10n里创建localization_intl.dart

import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'messages_all.dart';

class DaLocalizations {
  String get userNameOrPasswordWrong => null;

  static Future<DaLocalizations> load(Locale locale) {
    final String name = locale.countryCode.isEmpty ? locale.languageCode : locale.toString();
    final String localeName = Intl.canonicalizedLocale(name);
    //2
    return initializeMessages(localeName).then((b) {
      Intl.defaultLocale = localeName;
      return new DaLocalizations();
    });
  }

  static DaLocalizations of(BuildContext context) {
    return Localizations.of<DaLocalizations>(context, DaLocalizations);
  }
  String get auto => Intl.message('auto', name: 'auto', desc: 'set theme mode auto');
}

//Locale代理类
class DaLocalizationsDelegate extends LocalizationsDelegate<DaLocalizations> {
  const DaLocalizationsDelegate();

  //是否支持某个Local
  @override
  bool isSupported(Locale locale) => ['en', 'zh'].contains(locale.languageCode);

  // Flutter会调用此类加载相应的Locale资源类
  @override
  Future<DaLocalizations> load(Locale locale) {
    //3
    return DaLocalizations.load(locale);
  }

  // 当Localizations Widget重新build时,是否调用load重新加载Locale资源.
  @override
  bool shouldReload(DaLocalizationsDelegate old) => false;
}

运行命令生成arb文件:

flutter pub pub run intl_translation:extract_to_arb --output-dir=i10n-arb lib/i10n/localization_intl.dart

之后会在i10n-arb文件夹下生成intl_messages.arb文件,这个本质上是一个json文件,我们还要为不同的语言版本创建对应的翻译,比如本app支持中文和英文,那么需要创建两个文件:intl_zh.arbintl_en.arb

intl_messages.arb文件的内容分别复制到对应语言的翻译文件中,修改成对应语言的版本即可。

{
  "@@last_modified": "2019-12-17T17:04:43.001945",
  "title": "title",
  "@title": {
    "type": "text",
    "placeholders": {}
  },
}

上面这些做完之后运行命令生成对应的类:

# 从arb文件生成dart代码
flutter pub pub run intl_translation:generate_from_arb --output-dir=lib/i10n --no-use-deferred-loading lib/i10n/localization_intl.dart i10n-arb/intl_*.arb

未完待续

原来有这么多内容,限制于篇幅,我将在接下来的文章中继续记录~

欢迎交流

交流问题请在微信公众号后台留言,每一条信息我都会回复哈~ - 微信公众号:画星星高手 - 打代码直播间:https://live.bilibili.com/11883038 - 知乎:https://www.zhihu.com/people/dealiaxy - 简书:https://www.jianshu.com/u/965b95853b9f