本文对 Flutter 移动应用开发做了入门讲解,如想全面和深入学习相关知识,可报名学习 叽歪课程 - Flutter 移动应用开发实战

Flutter 介绍

Flutter 是 Google 在 2017 I/O 大会上推出的全新 UI 框架,首个 1.0 正式版本发布于 2018/12/4。Flutter 目前主攻移动平台,包括 Android 和 iOS,未来还会进一步扩展到 Web、桌面和嵌入式等平台。

相比于 React Native 这类 Hybrid UI 框架,Flutter 可以说是革命性的。Flutter 有自己的组件渲染引擎,而不是依赖于底层系统来渲染,因此它仅仅需要底层系统的 Canvas 和 Events 支持,很容易实现跨平台。笔者之前使用过 React Native,如果只是做数据展示还好,一旦 UI 交互比较多或者调用原生功能,坑就比较多。下面通过设计架构来对比一下各类移动开发技术,以此来说明为什么 Flutter 是革命性的。

Native APP

738f9d3e4f451b0796ed76b070bc583d

Native APP 使用原生 SDK 来开发,可以直接与系统通信,比如创建 UI 或访问系统相机。架构简单,性能高效,但需要为每个平台单独开发 APP。每个平台采用的技术都不一样,开发语言也完全不同,因此开发成本比较高。

H5 APP

5d9ff44f90953fbe0516d9fb8f8d2aaf

H5 APP 使用 Web 技术来开发移动应用,大大降低了 Web 前端开发人员进入移动开发领域的门槛。这类技术框架的代表有 PhoneGap、Cordova、Ionic 等,在应用内内嵌一个 WebView 来渲染 UI,本质上就是内嵌的网页。调用原生系统功能需要通过一个 Bridge 来完成。由于 WebView 的性能跟原生应用差距较大,此类技术基本已被淘汰。

Hybrid APP

9081ff763ad73254aca18adaf76fd2d2

Hybrid APP 使用 JavaScript 来编写 UI 组件,但这些组件最终会转换成对应的原生组件来渲染。因此具有同原生应用的外观,性能接近于原生应用。不过不管是组件渲染还是原生功能调用都需要经过 Bridge,因此在 UI 比较复杂、动画效果很多或原生功能调用非常频繁的场景,就会出现性能瓶颈。

Flutter APP

d4388e172f16374760a15d7a88ad3605

Flutter 使用静态类型的 Dart 语言来开发,代码会预编译为平台原生代码,这样就可以直接跟原生平台通信,从而省去了使用 Bridge 带来的上下文切换开销。这是它在架构上区别于其它技术的最重要的点。

相比于其它技术,Flutter 具备以下优点:

  1. 采用响应式视图,但不需要 Bridge。
  2. 快速、流畅、可预测,代码会预编译为原生代码。
  3. Dart 语言兼具动态语言的灵活性和静态语言的安全性。
  4. 带有大量美观,可定制的 UI 组件。
  5. 强大的开发者工具,惊人的热加载速度。
  6. 性能更好,兼容性更好,开发起来更有乐趣。

当然,还有最不值得一提的跨平台!

开发环境搭建

如果是初次接触 Flutter,建议先阅读官方文档 Get started,学会如何搭建本地开发环境,以及开发和运行你的第一个 Flutter 应用。下面以笔者使用的 macOS 系统为例,做一个大致的说明。

安装 Flutter 开发工具包

下载跟你操作系统对应的安装包,安装完成后需要添加 flutter/bin 路径到 PATH 里,以便后续可以找到 flutter 命令。安装完成后执行 flutter doctor 来验证是否安装成功,后续如果开发环境出现问题,也可以运行此命令来进行诊断。详细步骤可参考 官方文档

国内由于被墙的缘故,下载安装包和其它资源时可能会失败或很慢,可以参考官方针对中国区的 说明文档

安装平台开发工具

Flutter 代码在运行之前会预编译为原生代码,因此需要先安装各平台的原生开发工具包。对于 Android 平台需要安装 Android Studio,iOS 平台需要安装 Xcode。只需要把这些工具安装好就可以,后续开发过程中可以不使用这些工具,直接通过命令行或其它 IDE 来编译和运行应用。这些工具的安装步骤比较繁琐,具体步骤可参考 官方文档

安装 IDE

IDE 目前有两种选择,Android Studio/IntelliJ 和 VS Code。其中 Android Studio 和 IntelliJ 可以算是同一种,Android Studio 底层也是使用的 IntelliJ IDE,不过它们都比较重型。推荐使用更轻量和美观的 VS Code,微软出品,品质也有保障。安装好 IDE 后还需安装 Flutter 插件,这样才能支持 Flutter 应用开发。具体步骤可参考 官方文档

编写 Hello World 应用

创建项目

打开 VS Code,按照以下步骤来创建 Flutter 项目:

  1. 从菜单栏依次选择 View > Command Palette,将打开命令面板。
  2. 输入 flutter,然后选择 Flutter: New Project,将打开创建新项目的对话框。
  3. 输入项目名字,比如 myapp,然后回车确认.
  4. 选择存放新项目的目录。
  5. 稍等一会儿项目就会创建完成,此时会自动打开 main.dart 文件,也即是程序入口。

也可使用命令行工具来创建项目:

flutter create --org org.example --description "My first app." myapp

其中 --org 为应用包路径,一般为应用所属个人或组织的域名倒序,跟最后的应用名一起组成完整的应用 ID。该 ID 代表应用的身份,提交到应用市场时,每个应用的 ID 不能重复。

编写应用

替换 main.dart 文件里的内容为如下:

import 'package:flutter/material.dart';

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Welcome to Flutter',
      home: Scaffold(
        appBar: AppBar(
          title: Text('Welcome to Flutter'),
        ),
        body: Center(
          child: Text('Hello World'),
        ),
      ),
    );
  }
}

void main() => runApp(MyApp());

上面的代码里创建了一个名为 MyApp 的无状态组件,代表整个应用。该应用是一个 MaterialApp,标题为“Welcome to Flutter”,标题用在 Android 系统的进程管理里,对 iOS 系统没有用。页面结构为标准的 Scaffold,顶部 AppBar 的标题为“Welcome to Flutter”,页面主体内容是一段居中显示的文字“Hello World”。

可以看到 Dart 代码非常清晰易懂,配合 IDE 的自动提示功能,编写代码的过程也非常愉悦。如果对 Dart 语言不了解,可以先学习这篇官方的 Language Tour

运行应用

可按如下步骤来运行 Flutter 应用:

  1. 在 VS Code 底部的 Status Bar 里,会显示已经连接上的手机设备,如果没有会显示 No Device。点击 No Device 将打开设备选择对话框,可从中选择一款 Android 或 iOS 模拟器。对于真实的手机设备,需要按照“安装平台开发工具”步骤里的说明配置设备才能连接上。
  2. 确保已经连接上手机设备或者启动了模拟器,从菜单栏选择 Debug > Start Debugging 或者直接按 F5 键,即可启动调试。此时会先编译应用代码,然后部署到手机设备或模拟器上并启动应用。
  3. 调试过程中,如果代码有任何更新,将通过 Hot Reload 及时更新运行中的应用,还可使用调试工具条执行各种调试操作。

VS Code 调试界面:
--2020-02-2911.21.43

iOS 模拟器运行效果:

开发实战

接下来我们来编写一个类似于抖音的实际应用“围观”,从而学习到 Flutter 应用的完整开发过程,以及各种开发工具、常见组件和第三库的用法。这里我们只挑选其中的重点来讲解,完整的代码可从 GitHub 仓库 Flutter in Practice 获取。

项目结构

本项目里包含了两个应用,一个是 Flutter Demo,用来演示常用 Flutter 组件的用法,另一个是围观,一个功能完整的应用,也是本文所要讲解的。Flutter 支持通过命令行参数来指定应用入口文件(默认为 lib/main.dart),因此可以很方便地在一个代码库中包含多个应用的代码。具体目录结构如下:

.
├── README.md
├── android # Android 原生项目
├── assets # 图片等资源
├── ios # iOS 原生项目
├── lib # 应用代码
│   ├── demo # Flutter Demo 应用
│   └── weiguan # 围观应用
│       ├── adapter # 适配层
│       ├── config.dart # 应用配置
│       ├── container.dart # IoC 容器
│       ├── entity # 实体层
│       ├── main.dart # 应用入口
│       ├── main_dev.dart # 应用入口(开发模式)
│       ├── ui # 界面层
│       ├── usecase # 用例层
│       └── util # 工具类和函数
├── pubspec.yaml # 依赖包及其版本号
└── test # 测试

其中围观应用采用了干净架构,这能保证业务代码的稳定性和可测试性。虽然干净架构更适合业务相对复杂的服务端项目,但也不妨碍在客户端应用中使用,这样还能保持各个项目结构的一致性,尤其方便全栈开发人员进行开发。干净架构的四个层级从外到内依次为界面层、适配层、用例层和实体层,代码依赖关系遵循向内依赖原则,只有外层代码可以调用内层代码,不能反其道而行之。实体层和用例层保存着业务逻辑,它们是整个应用的核心,不受外层所用框架和工具的影响。用例层需要的外部依赖都通过接口进行了抽象,这些接口在适配层得以实现,以避免违反向内依赖原则。更多有关干净架构的内容可阅读此文 干净架构最佳实践

此外围观应用还使用了 IoC 容器来管理对象,特别是对象的创建。有关 IoC 容器的更多内容可阅读此文 使用 IoC 容器来简化业务对象的管理

对象模板方法生成

业务对象许多时候需要持久化到外部存储或通过网络传输。内存中的对象没办法直接跟进程外的系统进行交换,发送的时候需要序列化成字符串或字节流,接受的时候需要反序列化成为业务对象。序列化的格式一般采用 JSON,Dart 里如果对象要支持 JSON 序列化,需要为对象实现 toJson()fromJson() 方法。为了方便修改不可变对象,通常会为对象实现 copyWith() 方法。为了支持按值比较,需要同时覆盖 ==() 操作符和 hashCode() 方法。为了方便在日志里打印对象属性,可以覆盖 toString() 方法,将每个属性及其值一一打印出来。这些方法对每个对象来说结构上都非常类似,手动编写的话非常重复和啰嗦,对于这种带模板性质的方法,完全可以自动生成。

Dart 官方提供了 build_runner 工具来支持代码生成,社区在此基础之上已经提供了上面那些方法的生成工具。如果要生成 JSON 序列化和反序列化方法,可以使用 json_annotation。如果要生成 copyWith()==()hashCode()toString() 方法,可以使用 functional_data。只需要给需要添加这些方法的类添加相应的注解,并在项目根目录下执行命令 flutter packages pub run build_runner build --delete-conflicting-outputs lib,即可自动生成这些方法。如果想持续监测代码变化并自动执行生成命令,可将前面命令中的 build 替换为 watch。下面以 UserEntity 为例来演示:

lib/weiguan/entity/user.dart

import 'package:json_annotation/json_annotation.dart';
import 'package:functional_data/functional_data.dart';

import 'entity.dart';

part 'user.g.dart';

@JsonSerializable()
@FunctionalData()
class UserEntity extends $UserEntity {
  final int id;
  final String username;
  final String password;
  final String mobile;
  final String email;
  final int avatarId;
  final String intro;
  final DateTime createdAt;
  final DateTime updatedAt;
  final FileEntity avatar;
  final UserStatEntity stat;
  @JsonKey(defaultValue: false)
  final bool following;

  const UserEntity({
    this.id,
    this.username,
    this.password,
    this.mobile,
    this.email,
    this.avatarId,
    this.intro,
    this.createdAt,
    this.updatedAt,
    this.avatar,
    this.stat,
    this.following,
  });

  factory UserEntity.fromJson(Map<String, dynamic> json) =>
      _$UserEntityFromJson(json);

  Map<String, dynamic> toJson() => _$UserEntityToJson(this);
}

上面的代码中首先引入了 json_annotationfunctional_data 两个包。自动生成的代码将存放在同级目录下名为 user.g.dart 的文件中,其中 .g 表示 generate(生成)。自动生成的文件和原文件隶属于同一个库,因此需要在原文件中使用 part 'user.g.dart' 来声明其它部分,而在生成的文件中使用 part of 'user.dart' 来声明其是 user 库的一部分。由于是在同一个库中,这些 part 可以互相访问私有成员,并且共享 import 导入的内容。

对于 json_annotation 生成的两个函数 _$UserEntityFromJson()_$UserEntityToJson(),需要在原始类中添加对应的 fromJson()toJson() 方法来引用。而对于 functional_data 生成的那些方法,需要原始类通过继承自动生成的 $UserEntity 类得到。

状态管理

状态管理是个比较复杂的话题,小到一个按钮组件,大到整个应用组件,都可以有状态。对于组件内的状态,管理很简单,直接保存为实例变量,需要更新时调用 setState() 方法即可。当涉及到跨组件共享状态时就变得比较复杂了,一般会把需要共享的状态放在所有引用此状态的组件的最近共同祖先组件中。为了方便子组件找到某种类型的祖先组件,Flutter 提供了 InheritedWidget,只要组件继承于 InheritedWidget,那么就可以在下层组件中使用 BuildContext.dependOnInheritedWidgetOfExactType() 方法来寻找到上层组件里距离最近的指定类型的祖先组件,并且同时会监听该祖先组件的状态变化。为了简化和规范 InheritedWidget 在复杂场景的使用,可以使用 provider 这个更高层的包装库。

通过直接使用 InheritedWidgetprovider 这样的高层库,可以解决大部分有关状态共享的问题,但对于大型复杂的应用来说,这还不够。比如应用状态的集中管理和持久化,这时我们需要借助于更强大的状态管理工具,比如 redux。熟悉前端 React 框架的同学应该对 Redux 不陌生,使用 Redux 可以让我们集中管理应用状态,因此很方便将应用状态持久化到外部存储,由于采用单向数据流,调试起来也很方便。有关 Flutter 里状态管理的更多选择,可阅读此文 List of state management approaches

Redux 的核心是 Store,里面存放了应用状态,只能通过发送 Action 来更新 Store 里的状态,一般来说每个应用只有一个 Store。围观应用里创建 Store 的代码如下:

lib/weiguan/ui/redux/store.dart

import 'package:redux/redux.dart';
import 'package:redux_persist/redux_persist.dart';
import 'package:redux_persist_flutter/redux_persist_flutter.dart';
import 'package:redux_logging/redux_logging.dart';

import '../../util/util.dart';
import '../../container.dart';
import 'redux.dart';

Future<Store<AppState>> createStore() async {
  final config = WgContainer().config;
  var initialState = WgContainer().initialAppState;
  final actionLogger = WgContainer().actionLogger;

  final List<Middleware<AppState>> wms = [];
  if (config.logAction) {
    wms.add(LoggingMiddleware<AppState>(logger: actionLogger));
  }

  if (config.persistState) {
    final persistor = Persistor<AppState>(
      storage: FlutterStorage(key: config.packageInfo.packageName),
      serializer: JsonSerializer<AppState>((json) {
        if (json == null) {
          return initialState;
        }
        return AppState.fromJson(json);
      }),
      transforms: Transforms(
        onLoad: [
          (state) {
            if (compareVersion(state.version, config.packageInfo.version, 2) !=
                0) {
              state = initialState;
            }
            return state;
          }
        ],
      ),
    );

    initialState = await persistor.load();

    wms.add(persistor.createMiddleware());
  }

  return Store<AppState>(
    appReducer,
    initialState: initialState,
    middleware: wms,
  );
}

创建 Store 时,指定了用来处理 Action 的 Reducer appReducer,应用初始状态 initialState,以及可选的中间件列表 wms。中间件用来增强 Store 的功能,比如通过添加 LoggingMiddleware 来打印 Action 日志,以方便调试。此外,为了在应用状态发生变化时及时保存到外部存储,还添加了 Persistor 中间件(动态创建的)。Persistor 是一个持久化器,创建它时需要指定存储位置 storage,序列化器 serializer,以及可选的状态转换器 transforms

创建好 Store 之后,就可以为每种 UI 操作创建对应的 Action 及其处理方法。比如设置当前登录用户的:

lib/weiguan/ui/redux/action/user.dart

import 'package:meta/meta.dart';

import '../../../entity/entity.dart';
import '../../ui.dart';

class UserLoggedAction extends BaseAction {
  final UserEntity user;

  UserLoggedAction({
    @required this.user,
  });
}

lib/weiguan/ui/redux/reducer/user.dart

import 'package:redux/redux.dart';

import '../../ui.dart';

final userReducer = combineReducers<UserState>([
  TypedReducer<UserState, UserLoggedAction>(_logged),
]);

UserState _logged(UserState state, UserLoggedAction action) {
  return state.copyWith(
    logged: action.user,
  );
}

然后在需要的时候就可以使用 Store.dispatch(UserLoggedAction(user: user)) 来在应用状态里设置当前登录用户,其它有监听此状态的组件就可及时显示当前登录用户。

TabBar 导航

TabBar 导航是大多数移动应用所采用的导航方式,通过页面底部固定的几个 Tab 来在应用的不同功能模块之间切换。每个 Tab 内都有一个嵌套的导航器,这样 Tab 内的导航不会影响到其它 Tab。Flutter 的导航器 Navigator 支持嵌套,可以通过操作不同层级的导航器来在不同层级的页面之间跳转。

围观应用在进入 Tab 主页之前,会先进入启动页,在启动页里会执行一些初始化操作,比如检查是否已登录。只有在已经登录情况下才会进入 Tab 主页,否则进入登录页。启动页、登录页、注册页、Tab 主页由根导航器管理,Tab 主页内的每个 Tab 有自己的导航器,它们嵌套在根导航器内。具体的页面结构如下:

Root Navigator
├── BootstrapPage
├── LoginPage
├── RegisterPage
└── TabPage
    ├── Home Tab Navigator
    │  ├── HomePage
    │  └── ...
    ├── Publish Tab Navigator
    │  ├── PublishPage
    │  └── ...
    └── Me Tab Navigator
       ├── MePage
       └── ...

根导航器不用我们自己创建,每个 MaterialApp 都会自动创建,而每个 Tab 的导航器需要自行创建。这个工作是在 Tab 主页里完成的:

lib/weiguan/ui/page/tab.dart

import 'dart:async';

import 'package:flutter/material.dart';

import '../ui.dart';

class TabPage extends StatefulWidget {
  @override
  _TabPageState createState() => _TabPageState();
}

class _TabPageState extends State<TabPage> {
  final _navigatorKeys =
      WgTabBar.tabs.map((v) => GlobalKey<NavigatorState>()).toList();
  var _tab = 0;

  bool _handleSwitchTabNotification(SwitchTabNotification notification) {
    setState(() {
      _tab = notification.tab;
    });
    return true;
  }

  Future<bool> _onWillPop() async {
    final maybePop = await _navigatorKeys[_tab].currentState.maybePop();
    return Future.value(!maybePop);
  }

  @override
  Widget build(BuildContext context) {
    return WillPopScope(
      onWillPop: _onWillPop,
      child: NotificationListener<SwitchTabNotification>(
        onNotification: _handleSwitchTabNotification,
        child: IndexedStack(
          index: _tab,
          children: WgTabBar.tabs
              .asMap()
              .entries
              .map(
                (entry) => Navigator(
                  key: _navigatorKeys[entry.key],
                  onGenerateRoute: (settings) {
                    WidgetBuilder builder;
                    switch (settings.name) {
                      case '/':
                        builder = entry.value['builder'];
                        break;
                      default:
                        throw Exception('Unknown route: ${settings.name}');
                    }
                    return MaterialPageRoute(
                      builder: builder,
                      settings: settings,
                    );
                  },
                ),
              )
              .toList(),
        ),
      ),
    );
  }
}

其中使用了 IndexedStack 来管理多个 Tab 的可见性,只有 index 属性指向的那个 Tab 当前才可见。通过使用 NotificationListener,Tab 主页可以监听到来自下层组件的 SwitchTabNotification 通知,并切换到指定的 Tab。每个 Tab 都位于自己的 Navigator 之下,Tab 内的组件使用 Navigator.of 默认找到的是离它最近的所处 Tab 的导航器,如果想获取根导航器,可以指定 rootNavigator 参数为 true。为了防止嵌套导航器无路可退(没有页面可以返回了),这里使用了 WillPopScope,以便在此时触发根导航器的页面返回操作。

调用 API

为了能够保存和查询大量数据,一般会把应用数据保存在服务端,客户端应用需要通过调用 API 来提交数据到服务端或从服务端下载数据。围观应用同时支持 REST API 和 GraphQL API,它们都基于 HTTP 协议来传输数据。REST API 通常会一次返回某个操作需要的全部数据,或者由客户端多次请求得到。相比来说,GraphQL API 就更灵活一些,它允许客户端指定本次操作需要返回的数据结构,从而在提升 API 普适性的同时避免传输多余数据。

为了屏蔽底层 REST API 和 GraphQL API 的差异性,遵循干净架构的要求,在用例层里定义了围观服务端 API 接口。

lib/weiguan/usecase/port/service/weiguan.dart

import '../../../entity/entity.dart';

abstract class WeiguanService {
  Future<UserEntity> userRegister(UserEntity userEntity);

  Future<UserEntity> userLogin(String username, String password);

  Future<UserEntity> userLogged();

  Future<UserEntity> userLogout();

  Future<UserEntity> userModify(UserEntity userEntity, [String code]);

  Future<UserEntity> userInfo(int id);

  ...
}

下面是基于 REST 协议的 API 实现:

lib/weiguan/adapter/service/weiguan_rest.dart

import 'dart:io';
import 'dart:async';

import 'package:dio/dio.dart';
import 'package:meta/meta.dart';
import 'package:logging/logging.dart';
import 'package:path/path.dart';
import 'package:mime/mime.dart';
import 'package:redux/redux.dart';

import '../../entity/entity.dart';
import '../../ui/ui.dart';
import '../../usecase/usecase.dart';
import '../../config.dart';

class WeiguanRestService implements WeiguanService {
  final WgConfig config;
  final Store<AppState> appStore;
  final Logger logger;
  final Dio client;

  WeiguanRestService({
    @required this.config,
    @required this.appStore,
    @required this.logger,
    @required this.client,
  });

  Future<Map<String, dynamic>> request(String method, String path,
      [Map<String, dynamic> data]) async {
    if (config.logApi) {
      logger.fine('$method $path $data');
    }
    Response response;
    try {
      final Map<String, dynamic> headers = {};
      final oAuth2State = appStore.state.oauth2;
      if (config.enableOAuth2Login && oAuth2State.accessToken != null) {
        headers['authorization'] = 'Bearer ' + oAuth2State.accessToken;
      }

      response = await client.request(
        path,
        queryParameters: method == 'GET' ? data : null,
        data: method == 'POST' ? data : null,
        options: Options(method: method),
      );
    } catch (e) {
      throw ServiceException('fail', '$e');
    }
    if (config.logApi) {
      logger.fine('${response.statusCode} ${response.data}');
    }

    var result = response.data;
    if (response.statusCode == 401 || result['code'] == 'unauthenticated') {
      throw UnauthenticatedException("未认证");
    }

    if (result['code'] != 'ok') {
      throw ServiceException(result['code'], result['message']);
    }

    return result['data'];
  }

  Future<Map<String, dynamic>> get(String path, [Map<String, dynamic> data]) {
    data?.removeWhere((k, v) => v == null);
    return request('GET', path, data);
  }

  Future<Map<String, dynamic>> post(String path, Map<String, dynamic> data,
      [List<String> files]) {
    data?.removeWhere((k, v) => v == null);

    if ((files ?? []).length > 0) {
      data['file'] = files
          .map(
            (v) => UploadFileInfo(File(v), basename(v),
                contentType: ContentType.parse(lookupMimeType(v))),
          )
          .toList();
      return request('POST', path, FormData.from(data));
    } else {
      return request('POST', path, data);
    }
  }

  @override
  Future<UserEntity> userRegister(UserEntity userEntity) async {
    final response = await post('/user/register', userEntity.toJson());
    return UserEntity.fromJson(response['user']);
  }

  ...
}

为了方便执行一些统一的操作,所有请求都通过最底层的 request() 方法来发送。在 request() 方法里会打印请求响应日志,以方便后续调试。如果启用了 OAuth2 登录,还会自动添加 Authorization 头。同时还会检查响应头里的 HTTP 状态码是否为 401,或者响应结果里的错误码 code 是否为 unauthenticated,如果是则抛出 UnauthenticatedException 异常,以便上层通过处理该异常来自动跳转到登录页。

接下来是基于 GraphQL 协议的 API:

lib/weiguan/adapter/service/weiguan_graphql.dart

import 'dart:convert';
import 'dart:io';
import 'dart:async';

import 'package:dio/dio.dart';
import 'package:meta/meta.dart';
import 'package:logging/logging.dart';
import 'package:path/path.dart';
import 'package:mime/mime.dart';
import 'package:redux/redux.dart';

import '../../entity/entity.dart';
import '../../ui/ui.dart';
import '../../usecase/usecase.dart';
import '../../config.dart';

class WeiguanGraphQLService implements WeiguanService {
  final WgConfig config;
  final Store<AppState> appStore;
  final Logger logger;
  final Dio client;

  WeiguanGraphQLService({
    @required this.config,
    @required this.appStore,
    @required this.logger,
    @required this.client,
  });

  Future<Map<String, dynamic>> request(String method, String path,
      [Map<String, dynamic> data]) async {
    if (config.logApi) {
      logger.fine('$method $path $data');
    }
    Response response;
    try {
      final Map<String, dynamic> headers = {};
      // Graphql java not accept content type with charset
      // see more on https://github.com/graphql-java/graphql-java-spring/issues/16
      headers['content-type'] = 'application/json';
      final oAuth2State = appStore.state.oauth2;
      if (config.enableOAuth2Login && oAuth2State.accessToken != null) {
        headers['authorization'] = 'Bearer ' + oAuth2State.accessToken;
      }

      response = await client.request(
        path,
        queryParameters: method == 'GET' ? data : null,
        data: method == 'POST' ? data : null,
        options: Options(method: method, headers: headers),
      );
    } catch (e) {
      throw ServiceException('fail', '$e');
    }
    if (config.logApi) {
      logger.fine('${response.statusCode} ${response.data}');
    }

    final result = {'code': 'ok', 'message': '', 'data': response.data['data']};
    if ((response.data['errors'] ?? []).length > 0) {
      final error = (response.data['errors'] as List)[0];
      final extensions = (error['extensions'] ?? {}) as Map;
      result['code'] = extensions['code'] ?? 'fail';
      result['message'] = error['message'];
    }

    if (response.statusCode == 401 || result['code'] == 'unauthenticated') {
      throw UnauthenticatedException("未认证");
    }

    if (result['code'] != 'ok') {
      throw ServiceException(result['code'], result['message']);
    }

    return result['data'];
  }

  Future<Map<String, dynamic>> get(String query,
      {Map<String, dynamic> variables, String operationName}) {
    var data = {
      'query': query,
      'variables': variables == null ? null : jsonEncode(variables),
      'operationName': operationName
    };
    data.removeWhere((k, v) => v == null);
    return request('GET', '/graphql', data);
  }

  Future<Map<String, dynamic>> post(String query,
      {Map<String, dynamic> variables,
      String operationName,
      List<String> files}) {
    var data = {
      'query': query,
      'variables': variables,
      'operationName': operationName
    };
    data.removeWhere((k, v) => v == null);

    if ((files ?? []).length > 0) {
      data['file'] = files
          .map(
            (v) => UploadFileInfo(File(v), basename(v),
                contentType: ContentType.parse(lookupMimeType(v))),
          )
          .toList();
      return request('POST', '/graphql', FormData.from(data));
    } else {
      return request('POST', '/graphql', data);
    }
  }

  String userFields({
    bool id: true,
    bool username: true,
    bool mobile: true,
    bool email: true,
    bool avatarId: true,
    bool intro: true,
    bool createdAt: true,
    bool updatedAt: true,
    String avatar,
    String stat,
    bool following: false,
  }) {
    final fields = [];
    if (id) fields.add('id');
    if (username) fields.add('username');
    if (mobile) fields.add('mobile');
    if (email) fields.add('email');
    if (avatarId) fields.add('avatarId');
    if (intro) fields.add('intro');
    if (createdAt) fields.add('createdAt');
    if (updatedAt) fields.add('updatedAt');
    if (avatar != null) fields.add('avatar { $avatar }');
    if (stat != null) fields.add('stat { $stat }');
    if (following) fields.add('following');

    return fields.join(' ');
  }

  ...

  @override
  Future<UserEntity> userRegister(UserEntity userEntity) async {
    final query = '''
mutation(\$user: UserInput!) {
  userRegister(user: \$user) { ${userFields()} }
}''';
    final response = await post(query, variables: {
      'user': userEntity.toJson()..removeWhere((k, v) => v == null),
    });
    return UserEntity.fromJson(response['userRegister']);
  }

  ...
}

类似于 REST API,所有请求也是通过底层的 request() 方法来统一发送。为了简化字段列表的拼接和方便日后维护,为每类对象提供了助手方法来生成所需字段列表,比如 userFields()

此外为了在缺乏服务端 API 的情况仍然可以运行应用,还提供了本地模拟 API weiguan_mock.dart。为了节省篇幅,具体内容就不展开了。

运行应用

为了区分正式和开发两种运行模式,使用了两个入口文件 main.dartmain_dev.dart,这样在切换两个模式时无需修改代码。下面以 main_dev.dart 为例:

import 'package:flutter/material.dart';
import 'package:logging/logging.dart';

import 'ui/ui.dart';
import 'config.dart';
import 'container.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();

  final container = WgContainer(WgConfig(
    debug: true,
    loggerLevel: Level.ALL,
    logAction: true,
    logApi: true,
    enableRestApi: false,
    enableGraphQLApi: false,
    apiBaseUrl: 'http://localhost:8080',
  ));
  await container.onReady;

  runApp(WgApp(
    config: container.config,
    store: container.appStore,
    packageInfo: container.config.packageInfo,
    theme: container.theme.themeData,
  ));
}

因为应用里引用的某个第三包需要在执行 runApp() 之前访问 binary messenger,因此我们需要先调用 WidgetsFlutterBinding.ensureInitialized() 来初始化组件绑定。接下来是创建 IoC 容器 WgContainer,通过传递配置对象 WgConfig 来控制应用的一些运行行为,比如是否开启调试、是否打印 Action 和 API 日志、是否启用 REST API 或 GraphQL API 等。因为容器里的一些对象会异步进行初始化,因此还需执行 await container.onReady 来等待这些初始化完成。最后创建 WgApp 应用组件,并执行 runApp() 来运行改应用组件。

运行应用默认会打开 '/' 页面,对应 BootstrapPage 页面组件,也即是启动页。启动页里会检查当前登录状态,如果已登录直接进入 Tab 主页,否则进入登录页。有关各个页面功能的实现,请阅读源码,或者学习相关视频课程。

参考资料

  1. Flutter in Practice
  2. Flutter
  3. cached_network_image
  4. carousel_slider
  5. cookie_jar
  6. dio
  7. flutter_redux
  8. functional_data
  9. image_picker
  10. injector
  11. json_annotation
  12. logging
  13. meta
  14. package_info
  15. provider
  16. redux
  17. redux_logging
  18. redux_persist
  19. redux_persist_flutter
  20. redux_thunk
  21. video_player
  22. build_runner
  23. flutter_launcher_icons
  24. functional_data_generator
  25. json_serializable