从 0 开始手把手带你搭建一套规范的 Flutter-mvp 项目工程环境

3,747 阅读12分钟

前言

随着Flutter2.0的发布,Flutter对桌面和Web的支持也正式宣布进入stable渠道。相信过不了多久,Flutter必会成为另一主流。前一阵子,基于个人的需要以及想真正的体验一下Flutter开发,本人就使用Flutter开发了一款记账类APP。对于这次开发的体验总结一下:就是爽!开发体验非常棒!还没尝试过的同学可以从本文开始学习,从0开始搭建一套规范的Flutter项目工程环境。

本文篇幅较长,会从以下几个方面展开:

  • 环境安装
  • 架构搭建
  • Flutter MVP规范
  • 常用插件
  • 代码规范
  • 提交规范(待定)
  • 单元测试
  • 打包发布

本项目完整的代码托管在 Gitee 仓库,欢迎点亮小星星。

技术栈

  • 编程语言:Dart + Flutter
  • 路由工具:fluro: ^2.0.3
  • 网络请求库:dio: ^3.0.10
  • 接口服务封装工具:retrofit: 1.3.4+1
  • toast插件:fluttertoast: ^7.1.5
  • 状态管理:provider: ^4.3.3
  • 事件总线:^2.0.0

环境安装

配置与工具要求

  • 操作系统: Windows 7 或更高版本 (64-bit)
  • 磁盘空间: 2G.
  • 工具 : Flutter 依赖下面这些命令行工具.

获取Flutter SDK

去flutter官网下载其最新可用的安装包,点击下载

这里使用版本

解压如下:

image-20210421112227644

image-20210421112257965

配置环境变量

  • 我的电脑->右键属性->高级系统设置->环境设置

    • 系统变量找到Path追加flutter/bin

      image-20210421112547766

      image-20210421112737141

    • 用户环境变量添加PUB_HOSTED_URL和FLUTTER_STORAGE_BASE_URL

      PUB_HOSTED_URL=https://pub.flutter-io.cn
      FLUTTER_STORAGE_BASE_URL=https://storage.flutter-io.cn
      

      image-20210421113201288

运行flutter doctor

使用管理员身份运行git bash或PowerShell

image-20210421113358718

flutter doctor

效果如下:

image-20210421113615638

获取Android SDK

image-20210421141240356

这里使用版本

image-20210421141431738

安装相关工具

image-20210421154334350

image-20210421154358739

再次配置环境变量

  • 我的电脑->右键属性->高级系统设置->环境设置

    • 用户环境变量添加ANDROID_HOME

      image-20210421154913329

    • 系统变量找到Path追加platform-tools和tools

      主要是platform-tools/adb.exe

      image-20210421155108836

安装夜神模拟器

  1. 替换夜神模拟器adb.exe

将上面的platform-tools/adb.exe覆盖夜神模拟器的Nox/bin/nox_adb.exe

覆盖前先备份

image-20210421160525723

  1. 打开夜神模拟器

    image-20210421161556573

  2. 使用flutter devices查看设备情况

    flutter devices
    

    修改环境变量后,要重新打开git bash。

    image-20210421161318674

    这里会看到有三个设备,VOG AL10就是夜神模拟器,另外两个为浏览器。

    到此,环境安装完成。

安装VSCode

架构搭建

使用flutter create命令初始化项目雏形

使用flutter create命令创建一个project

# 默认为Kotlin语言,如果使用java语言,则需要-a参数
flutter create -a java fluttermvp
cd fluttermvp

默认工程目录如下图:

image-20210421152201316

运行应用程序

  • 检查Android设备是否在运行。如果没有显示

    flutter devices
    
  • 运行flutter run命令来运行应用程序

    flutter run
    

    因刚才安装的Android SDK build-tools工具版本不对,会报如下错

    image-20210421162308353

    打开SDK Manager.exe安装对应版本即可

    image-20210421162401874

    Android SDK Build-tools安装完后,还会报错,因为还有一个问题未解决。

    image-20210421165453458

    目前最高版本只有29,所以要只能选下载29的,然后再修改fluttermvp/android/app/gradle.bulid文件

    compileSdkVersion 30 ==> compileSdkVersion 29
    targetSdkVersion 30 ==> targetSdkVersion 29
    

    安装android SDK Platform

    image-20210421163329930

    image-20210421163347122

  • 如果 一切正常,在应用程序建成功后,您应该在您的设备或模拟器上看到应用程序:

    image-20210421170009511

使用VSCode打开工程

image-20210421170258717

暂时安装3个常用插件

image-20210421170658755

体验一波热重载

Flutter 可以通过 热重载(hot reload) 实现快速的开发周期,热重载就是无需重启应用程序就能实时加载修改后的代码,并且不会丢失状态(译者语:如果是一个web开发者,那么可以认为这和webpack的热重载是一样的)。简单的对代码进行更改,然后告诉IDE或命令行工具你需要重新加载(点击reload按钮),你就会在你的设备或模拟器上看到更改。

  1. 打开文件lib/main.dart
  2. 将字符串 'You have pushed the button this many times:' 更改为 'You have clicked the button this many times:'
  3. 不要按“停止”按钮; 让您的应用继续运行.
  4. 要查看您的更改,请调用 Save (cmd-s / ctrl-s), 或者点击 热重载按钮 (带有闪电图标的按钮).

你会立即在运行的应用程序中看到更新的字符串

将上前面运行的命令行关闭。使用VSCode启动调试

image-20210421171621618

image-20210421171940553

image-20210421172033459

Flutter配置文件

后续使用到再依次说明

name: fluttermvp
description: A new Flutter project.
publish_to: 'none' 
version: 1.0.0+1

environment:
  sdk: ">=2.7.0 <3.0.0"

dependencies:
  flutter:
    sdk: flutter
  cupertino_icons: ^1.0.2
dev_dependencies:
  flutter_test:
    sdk: flutter
flutter:
  uses-material-design: true

规范目录结构

├── android/                 # 安卓工程
├── ios/                     # ios工程
├── lib/                     # flutter&dart代码
	├── api/                 # 接口层
    ├── base/                # 基类
    ├── event/               # eventbus相关
    ├── http/                # http请求工具
    ├── iconfont/            # 阿里云矢量图标
    ├── model/               # 实体层
	├── modules/             # 功能模块
   	├── router/              # 路由
    └── tool/				 # 工具库
├── test/					 # 测试
├── web/                     # web工程
└── pubspec.yaml			 # flutter配置文件

为了后续导包统一,这里建议修改一下pubspec.yaml的name为app。修改后需要重启一下vscode,这样导包功能才生效。

name: app                       # 这里由之前的fluttermvp->app
description: A new Flutter project.
publish_to: 'none' 
version: 1.0.0+1

environment:
  sdk: ">=2.7.0 <3.0.0"

dependencies:
  flutter:
    sdk: flutter
  cupertino_icons: ^1.0.2
dev_dependencies:
  flutter_test:
    sdk: flutter
flutter:
  uses-material-design: true

集成路由工具fluro

fluro: ^2.0.3

image-20210421175321399

  1. 获取插件

image-20210421180028102

  1. 新建两个页面moudules/example/route_a.dartmoudules/example/RouterBPage.dart

image-20210510085428532

  1. stateful与stateless这里暂时不说区别,选stateful,输入名称

image-20210510085514493

  1. 快速修复,导包

image-20210510085539615

image-20210422093539323

image-20210510085614793

  1. 最后代码修改成如下:
import 'package:flutter/material.dart';

class RouterAPage extends StatefulWidget {
  @override
  _RouterAPageState createState() => _RouterAPageState();
}

class _RouterAPageState extends State<RouterAPage> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("RouterA"),
      ),
      body: new Center(
        child: new Text("RouterA"),
      ),
    );
  }
}


  1. route_b.dart重复上述操作。

  2. 新建路由处理文件router/route_handles.dart

    import 'package:app/modules/example/route_b.dart';
    import 'package:fluro/fluro.dart';
    import 'package:flutter/material.dart';
    import 'package:app/main.dart';
    import 'package:app/modules/common/error.dart';
    import 'package:app/modules/example/route_a.dart';
    
    // 根页面
    var rootHandler = new Handler(
        handlerFunc: (BuildContext context, Map<String, List<String>> params) {
      return MyApp();
    });
    // 空页面
    var emptyHandler = new Handler(
        handlerFunc: (BuildContext context, Map<String, List<String>> params) {
      return ErrorPage();
    });
    // RouterPageA页面
    var routerAHandler = new Handler(
        handlerFunc: (BuildContext context, Map<String, List<String>> params) {
      return RouterAPage();
    });
    // RouterPageB页面
    var routerBHandler = new Handler(
        handlerFunc: (BuildContext context, Map<String, List<String>> params) {
      return RouterBPage();
    });
    
    
    

    上述的空页面请参考RouterPageA或RouterPageB的方式自行创建。

    这里要注意的是这里的导包

    import 'package:app/main.dart';

    app对应的就是pubspec.yaml的name,在没有修改之前就是

    import 'package:fluttermvp/main.dart';

  3. 新建路由配置文件router/routes.dart

    import 'package:app/router/router_handlers.dart';
    import 'package:fluro/fluro.dart';
    
    class Routes {
      static void configureRoutes(FluroRouter router) {
        //空页面
        router.notFoundHandler = emptyHandler;
        // 根页面
        router.define("/", handler: rootHandler);
        // RouterPageA
        router.define("/routerA", handler: routerAHandler);
        // RouterPageB
        router.define("/routerB", handler: routerBHandler);
      }
    }
    
    
    
  4. 新建路由工具类tool/NavTool.dart

    import 'package:fluro/fluro.dart';
    import 'package:flutter/material.dart';
    
    class NavTool {
      static FluroRouter router;
    
      /// 设置路由对象
      static void setRouter(FluroRouter router) {
        router = router;
      }
    
      /// 跳转到首页
      static void goRoot(BuildContext context) {
        router.navigateTo(context, "/", replace: true, clearStack: true);
      }
    
      /// 跳转到指定地址
      static void push(BuildContext context, String path,
          {bool replace = false, bool clearStack = false}) {
        FocusScope.of(context).unfocus();
        router.navigateTo(context, path,
            replace: replace,
            clearStack: clearStack,
            transition: TransitionType.native);
      }
    
      /// 跳转到指定地址,有回调
      static void pushResult(
          BuildContext context, String path, Function(Object) function,
          {bool replace = false, bool clearStack = false}) {
        FocusScope.of(context).unfocus();
        router
            .navigateTo(context, path,
                replace: replace,
                clearStack: clearStack,
                transition: TransitionType.native)
            .then((value) {
          if (value == null) {
            return;
          }
          function(value);
        }).catchError((onError) {
          print("$onError");
        });
      }
    
      /// 跳转到指定地址-传参
      static void pushArgumentResult(BuildContext context, String path,
          Object argument, Function(Object) function,
          {bool replace = false, bool clearStack = false}) {
        router
            .navigateTo(context, path,
                routeSettings: RouteSettings(arguments: argument),
                replace: replace,
                clearStack: clearStack)
            .then((value) {
          if (value == null) {
            return;
          }
          function(value);
        }).catchError((onError) {
          print("$onError");
        });
      }
    
      /// 跳转到指定地址-传参
      static void pushArgument(BuildContext context, String path, Object argument,
          {bool replace = false, bool clearStack = false}) {
        router.navigateTo(context, path,
            routeSettings: RouteSettings(arguments: argument),
            replace: replace,
            clearStack: clearStack);
      }
    
      /// 回退
      static void goBack(BuildContext context) {
        FocusScope.of(context).unfocus();
        Navigator.pop(context);
      }
    
      static void goBackWithParams(BuildContext context, result) {
        FocusScope.of(context).unfocus();
        Navigator.pop(context, result);
      }
    
      /// 替换当前地址
      static String changeToNavigatorPath(String registerPath,
          {Map<String, Object> params}) {
        if (params == null || params.isEmpty) {
          return registerPath;
        }
        StringBuffer bufferStr = StringBuffer();
        params.forEach((key, value) {
          bufferStr
            ..write(key)
            ..write("=")
            ..write(Uri.encodeComponent(value))
            ..write("&");
        });
        String paramStr = bufferStr.toString();
        paramStr = paramStr.substring(0, paramStr.length - 1);
        print("传递的参数  $paramStr");
        return "$registerPath?$paramStr";
      }
    }
    
  5. 入口页新增路由配置

    
    void main() {
      /// 配置路由开始
      FluroRouter router = FluroRouter();
      Routes.configureRoutes(router);
      NavTool.router = router;
    
      /// 入口
      runApp(MyApp());
    }
    
  6. 布局代码片段

    new RaisedButton(
        child: new Text("RouterA"),
        onPressed: () {
            NavTool.push(context, "/routerA");
        }),
    new RaisedButton(
        child: new Text("RouterB"),
        onPressed: () {
            NavTool.push(context, "/routerB");
        })
    
  7. 效果截图

    image-20210422102127010

    image-20210422102143909

    image-20210422102203330

    至此,路由算是集成完毕,路由的进一步学习这里就先不展开。

集成Flutter常用工具类

flustars: ^2.0.1

flustars依赖于Dart常用工具类库common_utils,以及对其他第三方库封装,致力于为大家分享简单易用工具类。如果你有好的工具类欢迎PR. 目前包含SharedPreferences Util, Screen Util, Directory Util, Widget Util, Image Util。

集成网络请求库dio+retrofit+json

因为dart不支持反射,确切说是Flutter 禁用了dart:mirror无法使用反射,所以在json to bean上处理并不是很友好,不过我们可以借助一些工具,通过命令在编译期触发,能尽可能的还原原生开发处理的舒适度。

dependencies环境依赖包:

dio: ^3.0.10

retrofit: 1.3.4+1

json_annotation: ^3.0.1

dev_dependencies环境依赖包:

retrofit_generator: 1.4.1+3

build_runner: ^1.7.3

json_serializable: ^3.1.1

安装Json To Dart插件

该插件可以将json转成Dart 的bean

image-20210508103514032

插件小试:

{
    "userId": 1,
    "userName": "张三",
    "avatar": ""
}

复制上述json字符串->选中要创建dart文件的目录右键->Covert Json from Clipboard Here

image-20210508103636907

输入类名回车

image-20210508141932065

选择yes回车

image-20210508142012380

选择yes回车

image-20210508141640768

最终生成如下user_vo.dart


class UserVo {
  int userId;
  String userName;
  String avatar;

  UserVo({this.userId, this.userName, this.avatar});

  UserVo.fromJson(Map<String, dynamic> json) {
    if(json["userId"] is int)
      this.userId = json["userId"];
    if(json["userName"] is String)
      this.userName = json["userName"];
    if(json["avatar"] is String)
      this.avatar = json["avatar"];
  }

  Map<String, dynamic> toJson() {
    final Map<String, dynamic> data = new Map<String, dynamic>();
    data["userId"] = this.userId;
    data["userName"] = this.userName;
    data["avatar"] = this.avatar;
    return data;
  }
}

在dart中,json to map是默认支持的,这里就不再说明,由json to bean共有两步,第一步是json to map,第二步是map to bean。

从UserVo类的结构可以看出,要想将map to bean 或 bean to map,需要定义四个部分内容:

  • 属性字段
  • 构造方法
  • map to bean方法
  • bean to map 方法

因为dart中没有反射,所以需要一个个字段去转换,该工作可以由上述插件帮转换。

完整的接口请求样例

  1. 找到接口请求返回的样例数据

    这里以我个人记账app的系统分类接口做为举例。

    {
      "code": 0,
      "msg": "查询分类成功",
      "data": [
        {
          "id": 94,
          "name": "职业收入",
          "sort": 10,
          "icon": "m_zhiyeshouru",
          "selected": false,
          "children": [
            {
              "id": 95,
              "name": "薪资",
              "sort": 10.65,
              "icon": "m_xinzi",
              "selected": false
            },
            {
              "id": 97,
              "name": "奖金",
              "sort": 10.67,
              "icon": "m_jiangjin",
              "selected": false
            }
          ]
        }
      ]
    }
    
  2. 使用Json to Dart插件转成实体类

sys_cate_resp.dart


class SysCateResp {
  int code;
  String msg;
  List<Data> data;

  SysCateResp({this.code, this.msg, this.data});

  SysCateResp.fromJson(Map<String, dynamic> json) {
    if(json["code"] is int)
      this.code = json["code"];
    if(json["msg"] is String)
      this.msg = json["msg"];
    if(json["data"] is List)
      this.data = json["data"]==null?[]:(json["data"] as List).map((e)=>Data.fromJson(e)).toList();
  }

  Map<String, dynamic> toJson() {
    final Map<String, dynamic> data = new Map<String, dynamic>();
    data["code"] = this.code;
    data["msg"] = this.msg;
    if(this.data != null)
      data["data"] = this.data.map((e)=>e.toJson()).toList();
    return data;
  }
}

class Data {
  int id;
  String name;
  int sort;
  String icon;
  bool selected;
  List<Children> children;

  Data({this.id, this.name, this.sort, this.icon, this.selected, this.children});

  Data.fromJson(Map<String, dynamic> json) {
    if(json["id"] is int)
      this.id = json["id"];
    if(json["name"] is String)
      this.name = json["name"];
    if(json["sort"] is int)
      this.sort = json["sort"];
    if(json["icon"] is String)
      this.icon = json["icon"];
    if(json["selected"] is bool)
      this.selected = json["selected"];
    if(json["children"] is List)
      this.children = json["children"]==null?[]:(json["children"] as List).map((e)=>Children.fromJson(e)).toList();
  }

  Map<String, dynamic> toJson() {
    final Map<String, dynamic> data = new Map<String, dynamic>();
    data["id"] = this.id;
    data["name"] = this.name;
    data["sort"] = this.sort;
    data["icon"] = this.icon;
    data["selected"] = this.selected;
    if(this.children != null)
      data["children"] = this.children.map((e)=>e.toJson()).toList();
    return data;
  }
}

class Children {
  int id;
  String name;
  double sort;
  String icon;
  bool selected;

  Children({this.id, this.name, this.sort, this.icon, this.selected});

  Children.fromJson(Map<String, dynamic> json) {
    if(json["id"] is int)
      this.id = json["id"];
    if(json["name"] is String)
      this.name = json["name"];
    if(json["sort"] is double)
      this.sort = json["sort"];
    if(json["icon"] is String)
      this.icon = json["icon"];
    if(json["selected"] is bool)
      this.selected = json["selected"];
  }

  Map<String, dynamic> toJson() {
    final Map<String, dynamic> data = new Map<String, dynamic>();
    data["id"] = this.id;
    data["name"] = this.name;
    data["sort"] = this.sort;
    data["icon"] = this.icon;
    data["selected"] = this.selected;
    return data;
  }
}
  1. 新建接口类cate_service.dart

    import 'package:app/model/sys_cate_resp.dart';
    import 'package:dio/dio.dart';
    import 'package:retrofit/retrofit.dart';
    part 'cate_service.g.dart';
    
    @RestApi()
    abstract class CateService {
      factory CateService(Dio dio, {String baseUrl}) = _CateService;
      @POST("/bill/category/listCategory")
      Future<SysCateResp> listSysCate(@Field() String tallyType);
    }
    

    注意两个地方,开始没有生成相关文件,会报错。

    • part 'cate_service.g.dart';
    • factory CateService(Dio dio) = _CateService;
  2. vscode打开新终端执行如下命令

    flutter pub run build_runner build
    

    image-20210508145854184

  3. 查看生成文件

    在cate_service.dart同级目录下会生成cate_service.g.dart文件,其实也就是part指定的文件。

    // GENERATED CODE - DO NOT MODIFY BY HAND
    
    part of 'cate_service.dart';
    
    // **************************************************************************
    // RetrofitGenerator
    // **************************************************************************
    
    class _CateService implements CateService {
      _CateService(this._dio, {this.baseUrl}) {
        ArgumentError.checkNotNull(_dio, '_dio');
      }
    
      final Dio _dio;
    
      String baseUrl;
    
      @override
      Future<SysCateResp> listSysCate(tallyType) async {
        ArgumentError.checkNotNull(tallyType, 'tallyType');
        const _extra = <String, dynamic>{};
        final queryParameters = <String, dynamic>{};
        final _data = {'tallyType': tallyType};
        _data.removeWhere((k, v) => v == null);
        final _result = await _dio.request<Map<String, dynamic>>(
            '/bill/category/listCategory',
            queryParameters: queryParameters,
            options: RequestOptions(
                method: 'POST',
                headers: <String, dynamic>{},
                extra: _extra,
                baseUrl: baseUrl),
            data: _data);
        final value = SysCateResp.fromJson(_result.data);
        return value;
      }
    }
    
  4. 新建一个单元测试类

    test/main_test.dart

    import 'package:app/api/cate_service.dart';
    import 'package:app/model/sys_cate_resp.dart';
    import 'package:dio/dio.dart';
    
    Future<void> main() async {
      CateService cateService =
          new CateService(new Dio(), baseUrl: "http://bill-app.mldong.com");
      SysCateResp cateResp = await cateService.listSysCate("10");
      print(cateResp.toJson());
    }
    
  5. 打开文件Ctrl+F5运行,或者鼠标点击Run

    image-20210510090234933

    控制台输出:

    image-20210510090322088

Dio全局配置

上述的new Dio()使用的是默认配置,但是大多数情况下我们都是需要做一些全局请求拦截器的,比如打印请求日志、请求中追加token等。

新建一个类http/dio_manager.dart

对Dio对象进行如下处理:

  • 单例Dio
  • 请求头追加版本号
  • 设置请求根地址
  • 请求超时时间
  • 响应超时时间
  • 请求日志打印
/*
 * 网络请求管理类
 */
import 'package:app/config/config.dart';
import 'package:dio/dio.dart';

class DioManager {
  //写一个单例
  //在 Dart 里,带下划线开头的变量是私有变量
  static DioManager _instance;

  Dio dio = new Dio();
  DioManager() {
    // Set default configs
    dio.options.headers = {
      "version": GlobalConfig.API_VERSION,
    };
    dio.options.baseUrl = GlobalConfig.BASE_URL;
    dio.options.connectTimeout = 5000;
    dio.options.receiveTimeout = 3000;
  }
  static DioManager getInstance() {
    if (_instance == null) {
      _instance = DioManager();
    }
    // 调试模式下开启请求日志打印
    if (GlobalConfig.isDebug) {
      _instance.dio.interceptors.add(LogInterceptor(
          request: false, // 不打印请求
          requestBody: true, // 打印请求体
          responseHeader: false, // 不打印响应头
          responseBody: true)); // 打印响应体
    }
    return _instance;
  }
}

调用样例

import 'package:app/api/cate_service.dart';
import 'package:app/http/dio_manager.dart';
import 'package:app/model/sys_cate_resp.dart';

Future<void> main() async {
  CateService cateService = new CateService(DioManager.getInstance().dio);
  SysCateResp cateResp = await cateService.listSysCate("10");
  print(cateResp.toJson());
}

忽略*.g.dart文件

因为*.g.dart文件是由工具生成的,所以不建议将其加入到版本控制,需要在.gitignore文件追加一行

*.g.dart

集成阿里矢量图标库

官网:www.iconfont.cn/

引入svg库

flutter_svg: ^0.22.0

安装flutter-iconfont-cli插件

flutter-iconfont-cli为Nodejs插件,做为工具类,可以基于阿里云的js文件生成对应的dart图标依赖类。

npm install flutter-iconfont-cli -g

image-20210508153522752

阿里矢量图标流程样例

  • 登录

  • 创建项目

    image-20210508160610523

  • 添加图标到项目

  • 点击生成代码

    image-20210508154336424

    image-20210508154417124

  • 使用插件初始化

    npx iconfont-init
    

    image-20210508154555871

  • 打开iconfont.json,将上述的js地址替换如下:

    {
        "symbol_url": "请参考README.md,复制官网提供的JS链接",
        "save_dir": "./lib/iconfont",
        "trim_icon_prefix": "icon",
        "default_icon_size": 18,
        "null_safety": true
    }
    

    ==>

    {
        "symbol_url": "//at.alicdn.com/t/font_2534875_d4lkc1mlsxk.js",
        "save_dir": "./lib/iconfont",
        "trim_icon_prefix": "",
        "default_icon_size": 18,
        "null_safety": false
    }
    
    • symbol_url js链接

      请直接复制iconfont官网提供的项目链接。请务必看清是.js后缀而不是.css后缀。如果你现在还没有创建iconfont的仓库,那么可以填入这个链接去测试:http://at.alicdn.com/t/font_1373348_ghk94ooopqr.js

    • save_dir

      根据iconfont图标生成的组件存放的位置。每次生成组件之前,该文件夹都会被清空。

    • trim_icon_prefix

      如果你的图标有通用的前缀,而你在使用的时候又不想重复去写,那么可以通过这种配置这个选项把前缀统一去掉。

    • default_icon_size

      我们将为每个生成的图标组件加入默认的字体大小,当然,你也可以通过传入props的方式改变这个size值

    • null_safety

      dart 2.12.0 开始支持的空安全特性,开启该参数后,生成的语法会有所变化,所以需要变更sdk以保证语法能被识别。

      environment:
      - sdk: ">=2.7.0 <3.0.0"
      + sdk: ">=2.12.0 <3.0.0"
      

      目前版本不支持null_safety,所以要修改为false。

  • 使用命令生成

    npx iconfont-flutter
    

image-20210508155817427

如果矢量图标有变动,可以再次复复上述流程。

图标使用样例

/// IconFont(IconNames.xxx);
/// IconFont(IconNames.xxx, color: '#f00');
/// IconFont(IconNames.xxx, colors: ['#f00', 'blue']);
/// IconFont(IconNames.xxx, size: 30, color: '#000');

import 'package:app/iconfont/icon_font.dart';
import 'package:flutter/material.dart';

class IconPage extends StatefulWidget {
  @override
  _IconPageState createState() => _IconPageState();
}

class _IconPageState extends State<IconPage> {
  List<IconNames> iconList = new List();
  @override
  void initState() {
    super.initState();
    IconNames.values.forEach((element) {
      iconList.add(element);
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
          title: Text("图标"),
        ),
        body: new GridView.builder(
            scrollDirection: Axis.vertical,
            gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
              maxCrossAxisExtent: 80, //子控件最大宽度为100
              childAspectRatio: 1.0, //宽高比为1:1
              crossAxisSpacing: 5,
              mainAxisSpacing: 10,
            ),
            padding: EdgeInsets.all(10),
            itemCount: iconList.length,
            itemBuilder: (BuildContext context, int position) {
              IconNames icon = this.iconList[position];
              return new GestureDetector(
                child: new Container(
                    alignment: Alignment.center,
                    decoration: new BoxDecoration(color: Colors.white),
                    child: new Column(
                      mainAxisAlignment: MainAxisAlignment.center,
                      children: [
                        new Container(
                          decoration: new BoxDecoration(
                              color: new Color(0xfff0f0f0),
                              borderRadius:
                                  BorderRadius.all(new Radius.circular(24))),
                          width: 48,
                          height: 48,
                          //child: IconTool.getIcon("${tag.icon}"),
                          child: new Center(
                            child: IconFont(icon),
                          ),
                        )
                      ],
                    )),
              );
            }));
  }
}

image-20210509131448481

集成加载中组件

component/common_components.dart

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

class LoadingDialog extends Dialog {
  /// 显示加载中
  /// @param 当前上下文
  static void show(BuildContext context, {bool mateStyle}) {
    Navigator.of(context).push(DialogRouter(LoadingDialog()));
  }

  /// 隐藏加载中
  /// @param 当前上下文
  static void hide(BuildContext context) {
    Navigator.of(context).pop();
  }

  @override
  Widget build(BuildContext context) {
    return WillPopScope(
        child: Material(
          //创建透明层
          type: MaterialType.transparency, //透明类型
          child: Center(
            //保证控件居中效果
            child: CupertinoActivityIndicator(
              radius: 18,
            ),
          ),
        ),
        onWillPop: () async {
          return Future.value(false);
        });
  }
}

class DialogRouter extends PageRouteBuilder {
  final Widget page;

  DialogRouter(this.page)
      : super(
          opaque: false,
          barrierColor: Color(0x00000001),
          pageBuilder: (context, animation, secondaryAnimation) => page,
          transitionsBuilder: (context, animation, secondaryAnimation, child) =>
              child,
        );
}

集成吐司

fluttertoast: ^8.0.6

Fluttertoast.showToast("登录成功!");

集成状态管理

provider: ^4.3.3

这里使用provider文档的例子讲解:

lib/modules/example/provider_test.dart

定义要共享的对象

class Counter with ChangeNotifier, DiagnosticableTreeMixin {
  int _count = 0;

  int get count => _count;

  void increment() {
    _count++;
    notifyListeners();
  }

  /// Makes `Counter` readable inside the devtools by listing all of its properties
  @override
  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
    super.debugFillProperties(properties);
    properties.add(IntProperty('count', count));
  }
}

定义提供者

为了方便测试,将提供者定义在最上层。

void main() {
  runApp(
    // 可以定义多个提供者
    MultiProvider(
      providers: [
        ChangeNotifierProvider(create: (_) => Counter()),
      ],
      child: const MyApp(),
    ),
  );
}

定义消费者

主要使用context.watch来监听数据变动情况

class Count extends StatelessWidget {
  const Count({Key key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Text(

        /// Calls `context.watch` to make [Count] rebuild when [Counter] changes.
        '${context.watch<Counter>().count}',
        key: const Key('counterState'),
        style: Theme.of(context).textTheme.headline4);
  }
}

MyApp相关

class MyApp extends StatelessWidget {
  const MyApp({Key key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      home: MyHomePage(),
    );
  }
}

class MyHomePage extends StatelessWidget {
  const MyHomePage({Key key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Example'),
      ),
      body: Center(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          mainAxisAlignment: MainAxisAlignment.center,
          children: const <Widget>[
            Text('You have pushed the button this many times:'),

            Count(),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        key: const Key('increment_floatingActionButton'),
        onPressed: () => context.read<Counter>().increment(),
        tooltip: 'Increment',
        child: const Icon(Icons.add),
      ),
    );
  }
}

这里达到的效果是:点击MyHomePage页面组件上的浮动按钮,Count页面组件的值会变化。演示,略。

主题色管理

待定。

集成事件总线

event_bus: ^2.0.0

Flutter MVP规范

创建mvp层基类

base/mvp.dart

import 'package:app/component/common_components.dart';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';

/// v层基类
abstract class BaseView {
  BuildContext getContext();

  //显示加载loading
  void showLoading();

  //隐藏loading
  void hideLoading();

  //显示吐司
  void showToast(String msg);
  /***
   * 设置按钮索引-用于控制禁用启用的
   */
  void setCurrentBtnName(String btnName);
  String getCurrentBtnName();
  // 设置加载状态
  void setLoading(bool loading);
  bool getLoading();
}

/// p层抽象类
abstract class IPresenter {
  void deactivatePresenter();
  void disposePresenter();
  void initPresenter();
}

/// p层基类
class BasePresenter<V extends BaseView> extends IPresenter {
  V view;
  CancelToken _cancelToken;

  @override
  void deactivatePresenter() {}

  @override
  void disposePresenter() {
    //请求取消
    if (_cancelToken != null) {
      if (_cancelToken.isCancelled) {
        _cancelToken.cancel();
      }
    }
  }

  @override
  void initPresenter() {}
}

/// State 基类
abstract class BaseState<T extends StatefulWidget, V extends BasePresenter>
    extends State<T> implements BaseView {
  V presenter;
  String currentBtnName = "";
  bool loading = false;
  V createPresenter();
  BaseState() {
    presenter = createPresenter();
    presenter.view = this;
  }
  @override
  BuildContext getContext() {
    return context;
  }

  bool _isShowDialog = false;
  @override
  void hideLoading() {
    if (mounted && _isShowDialog) {
      _isShowDialog = false;
      LoadingDialog.hide(context);
    }
  }

  @override
  void showLoading() {
    /// 避免重复弹出
    if (mounted && !_isShowDialog) {
      _isShowDialog = true;
      Future.delayed(Duration.zero, () {
        LoadingDialog.show(context);
      });
    }
  }

  @override
  void showToast(String msg) {
    Fluttertoast.showToast(msg: msg);
  }

  @override
  void dispose() {
    super.dispose();
    presenter?.disposePresenter();
  }

  @override
  void deactivate() {
    super.deactivate();
    presenter?.deactivatePresenter();
  }

  @override
  void initState() {
    super.initState();
    presenter?.initPresenter();
  }

  @override
  void setCurrentBtnName(String btnName) {
    setState(() {
      this.currentBtnName = btnName;
    });
  }

  @override
  String getCurrentBtnName() {
    return this.currentBtnName;
  }

  @override
  void setLoading(bool loading) {
    setState(() {
      this.loading = loading;
    });
  }

  @override
  bool getLoading() {
    return this.loading;
  }
}

mvp结构说明

本框架中mvp结构共有三个文件

  • reg_contact.dart

    用于定义v与p的接口-抽象类

  • reg_presenter_impl.dart

    p接口的具体实现类-编写业务逻辑

  • reg.dart

    ui层

mvp骨架代码生成工具

generate/index.js

  • 安装依赖

    第一次使用前需要安装依赖,在当前工程下执行如下命令:

    npm install
    
  • 查看帮助

    node generate/index.js -h
    

    image-20210509221233282

  • 生成新模块

    node ./generate/index.js -f reg -co 1
    
  • 生成新模块-覆盖式

    node ./generate/index.js -f reg -co 1
    

上述操作最终生成的模块存放在lib/modules/reg

常用插件

主要是VsCode插件

  • Dart

    Dart代码扩展了VS代码,并支持Dart编程语言,并提供了有效编辑、重构、运行和重新加载Flall移动应用程序和AngularDart web应用程序的工具。

  • Flutter

    这个VS代码扩展增加了对有效编辑、重构、运行和重新加载Flitter移动应用程序的支持,以及对Dart编程语言的支持。

  • Flutter Widget Snippts

    Dart 与 Flutter 语法片段提示

  • Json To Dart

    将json 转成 Dart 实体类工具

代码规范

文件命名

所有文件名采用下划线命名方式。

router_handlers.dart
icon.dart
tools.dart

类名

参考java的命名规则,大驼峰。

class UserService {
}
class UserVo {
}

方法名属性名

参考java的命名规则,小驼峰。

String userName="";
int age = 0;
void loginByUserName(String userName,String password){
    
}

私有方法名与属性名

参考java的命名规则,小驼峰,但以下划线开头

String _userName = "";
int _age = 0;

提交规范

单元测试

开始生成的脚手架默认已经集成了单元测试的依赖

dev_dependencies:
  flutter_test:
    sdk: flutter

简单使用

lib/test/main_test.dart

import 'dart:math';

import 'package:flutter_test/flutter_test.dart';
void main() {
  test("简单判断", () {
    expect(new Random().nextInt(3), 1);
  });
}

点击Run

image-20210510093846636

实际值与预期值不一致

image-20210510093213800

实际值与预期值一致

image-20210510093151892

分组测试

使用 group 合并多个测试,用来测试多个有关联的测试。

import 'dart:math';

import 'package:flutter_test/flutter_test.dart';

void main() {
  group("组测试", () {
    test("测试1", () {
      expect(new Random().nextInt(3), 1);
    });
    test("测试2", () {
      expect(new Random().nextInt(3), 1);
    });
    test("测试3", () {
      expect(new Random().nextInt(3), 1);
    });
  });
}

image-20210510093522198

网络接口测试

import 'package:app/api/cate_service.dart';
import 'package:app/http/http.dart';
import 'package:app/model/sys_cate_resp.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
  test("接口请求测试:", () async {
    CateService cateService = new CateService(DioManager.getInstance().dio);
    SysCateResp cateResp = await cateService.listSysCate("20");
    // 验证 cateResp.code 的是是否为 0
    expect(cateResp.code, 0);
  });
}

image-20210510093819066

Widget测试

  1. 新建一个页面lib/modules/example/unit_test.dart
import 'package:flutter/material.dart';

class UnitPage extends StatefulWidget {
  @override
  _UnitPageState createState() => _UnitPageState();
}

class _UnitPageState extends State<UnitPage> {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: "Unit Test",
      home: Scaffold(
        appBar: AppBar(
          title: Text("Unit Test"),
        ),
        body: new Center(
            child: new RaisedButton(
                key: new Key("btnClickMe"),
                child: new Text("点我"),
                onPressed: () {
                  print("Hello World!");
                })),
      ),
    );
  }
}
  1. 单元测试流程

通过Key获取RaisedButton对象->执行该对象的点击事件

import 'package:app/modules/example/unit_test.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
  testWidgets('这是一个 Widget 测试', (WidgetTester tester) async {
    await tester.pumpWidget(new UnitPage());
    // 获取RaisedButton对象
    final btnClickMe = find.byKey(new Key("btnClickMe"));
    // 验证对象是否存在
    expect(btnClickMe, findsWidgets);
    // 执行一下按钮的点击事件
    tester.tap(btnClickMe);
  });
}
  1. 运行Run

image-20210510101704718

  1. 结果

image-20210510101720145

注意:待测试的 widget 需要用 MaterialApp() 包裹;

当然,也可以通过StatefulBuilder构造的方式,测试非MaterialApp()包裹的组件。

例1:

import 'package:app/main.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
  testWidgets('这是一个 Widget 测试', (WidgetTester tester) async {
    await tester.pumpWidget(new StatefulBuilder(
        builder: (BuildContext context, StateSetter setState) {
      return new MaterialApp(
        home: new MyHomePage(title: 'Flutter Demo Home Page'),
      );
    }));
    // 获取FloatingActionButton对象
    final btn = find.byType(FloatingActionButton);
    // 验证对象是否存在
    expect(btn, findsWidgets);
    // 执行一下按钮的点击事件
    tester.tap(btn);
  });
}

例2:

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

void main() {
  testWidgets('这是一个 Widget 测试', (WidgetTester tester) async {
    await tester.pumpWidget(new StatefulBuilder(
        builder: (BuildContext context, StateSetter setState) {
      return new MaterialApp(
        home: new Text("T"),
      );
    }));
    // 获取Text对象
    final t = find.byType(Text);
    // 验证对象是否存在
    expect(t, findsWidgets);
  });
}

其他复杂的交互,这里就不一一演示了,更深入的请转

打包发布

因条件有限,这里仅介绍安卓版的打包。

修改应用包名

假定com.example修改成com.mldong

  1. 修改目录

    android/app/src/main/java/com/example/==>android/app/src/main/java/com/mldong/

  2. 修改MainActivity.java文件

    android/app/src/main/java/com/example/MainActivity.java

    com.example==>com.mldong

    package com.mldong.fluttermvp;
    
    import io.flutter.embedding.android.FlutterActivity;
    
    public class MainActivity extends FlutterActivity {
    }
    
  3. 修改AndroidManifest.xml文件

    生产配置:

    android/app/src/main/java/AndroidManifest.xml

    开发配置:

    android/app/src/debug/AndroidManifest.xml

    第2行

    <manifest xmlns:android="http://schemas.android.com/apk/res/android"
        package="com.mldong.fluttermvp">
    
  4. 修改build.gradle文件

    android/app/build.gradle

    大概32行左右,节点

    android->defaultConfig->applicationId

    applicationId "com.mldong.fluttermvp"
    

修改图标

可使用图标工场生成图标

icon.wuruihong.com/

image-20210510111809742

将生成的文件复制到android/app/src/res/mipmap-*目录即可。

文件名为:ic_launcher.png

生成签名文件

  1. 生成签名文件

    keytool -genkey -v -keystore key.jks -keyalg RSA -keysize 2048 -validity 36500 -alias fluttermvp
    

    image-20210510113208960

  2. 将签名文件复制到android根目录上

    fluttermvp/android/key.jks

  3. 查看签名文件信息(按需)

    keytool -list -v -keystore android/key.jks -storepass 123456
    

    image-20210510142241512

  4. 新建key.properties配置文件

    storeFile=../key.jks
    storePassword=123456
    keyAlias=fluttermvp
    keyPassword=123456
    
  5. 修改build.gradle文件

    android/app/build.gradle

    以android节点同级新增如下代码

    def keystoreProperties = new Properties()
    def keystorePropertiesFile = rootProject.file('key.properties')
    if (keystorePropertiesFile.exists()) {
        keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
    }
    

    android节点下新增signingConfigs节点

    android {
    	signingConfigs {
            release {
                keyAlias keystoreProperties['keyAlias']
                keyPassword keystoreProperties['keyPassword']
                storeFile file(keystoreProperties['storeFile'])
                storePassword keystoreProperties['storePassword']
            }
        }
    }
    

    android->buildTypes->release 修改如下:

    signingConfig signingConfigs.debug修改成signingConfig signingConfigs.release

    android {
        buildTypes {
            release {
                signingConfig signingConfigs.release
            }
        }
    }
    

注意:为了安全,key.properties文件不要加入到版本库。

为了兼容key.properties不存在的情况,可以修改为:

android {
   
    if(keystoreProperties['keyAlias'] &&
        keystoreProperties['keyPassword'] &&
        keystoreProperties['storeFile'] &&
        keystoreProperties['storePassword']){
        signingConfigs {
            release {
                keyAlias keystoreProperties['keyAlias']
                keyPassword keystoreProperties['keyPassword']
                storeFile file(keystoreProperties['storeFile'])
                storePassword keystoreProperties['storePassword']
            }
        }
        buildTypes {
            release {
                signingConfig signingConfigs.release
            }
        }
    } else {
        buildTypes {
            release {
            // TODO: Add your own signing config for the release build.
            // Signing with the debug keys for now, so `flutter run --release` works.
                signingConfig signingConfigs.debug
            }
        }
    }
    
}

生成APK文件

flutter build apk

image-20210510141654511

最后

本文从技术选型到架构搭建,从单元测试到打包发布,一步步带领大家如何从一个最简单的Flutter项目骨架到规范的Flutter MVP工程化环境,基本上涵盖了Flutter项目开发的整个流程,特别适合刚接触Flutter工程化的同学学习。

因篇幅较长,所涉及技术点较多,难免会出现错误,希望大家多多指正,谢谢大家!