从 0 开始手把手带你搭建一套规范的 Flutter-mvp 项目工程环境
前言
随着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 依赖下面这些命令行工具.
- Git for Windows (Git命令行工具)
获取Flutter SDK
去flutter官网下载其最新可用的安装包,点击下载 ;
这里使用版本
解压如下:
配置环境变量
-
我的电脑->右键属性->高级系统设置->环境设置
-
系统变量找到Path追加flutter/bin
-
用户环境变量添加PUB_HOSTED_URL和FLUTTER_STORAGE_BASE_URL
PUB_HOSTED_URL=https://pub.flutter-io.cn FLUTTER_STORAGE_BASE_URL=https://storage.flutter-io.cn
-
运行flutter doctor
使用管理员身份运行git bash或PowerShell
flutter doctor
效果如下:
获取Android SDK
这里使用版本
安装相关工具
再次配置环境变量
-
我的电脑->右键属性->高级系统设置->环境设置
-
用户环境变量添加ANDROID_HOME
-
系统变量找到Path追加platform-tools和tools
主要是platform-tools/adb.exe
-
安装夜神模拟器
- 替换夜神模拟器adb.exe
将上面的platform-tools/adb.exe
覆盖夜神模拟器的Nox/bin/nox_adb.exe
覆盖前先备份
-
打开夜神模拟器
-
使用
flutter devices
查看设备情况flutter devices
修改环境变量后,要重新打开git bash。
这里会看到有三个设备,VOG AL10就是夜神模拟器,另外两个为浏览器。
到此,环境安装完成。
安装VSCode
略
架构搭建
使用flutter create命令初始化项目雏形
使用flutter create
命令创建一个project
# 默认为Kotlin语言,如果使用java语言,则需要-a参数
flutter create -a java fluttermvp
cd fluttermvp
默认工程目录如下图:
运行应用程序
-
检查Android设备是否在运行。如果没有显示
flutter devices
-
运行
flutter run
命令来运行应用程序flutter run
因刚才安装的
Android SDK build-tools
工具版本不对,会报如下错打开SDK Manager.exe安装对应版本即可
Android SDK Build-tools安装完后,还会报错,因为还有一个问题未解决。
目前最高版本只有29,所以要只能选下载29的,然后再修改
fluttermvp/android/app/gradle.bulid
文件compileSdkVersion 30 ==> compileSdkVersion 29 targetSdkVersion 30 ==> targetSdkVersion 29
安装android SDK Platform
-
如果 一切正常,在应用程序建成功后,您应该在您的设备或模拟器上看到应用程序:
使用VSCode打开工程
暂时安装3个常用插件
体验一波热重载
Flutter 可以通过 热重载(hot reload) 实现快速的开发周期,热重载就是无需重启应用程序就能实时加载修改后的代码,并且不会丢失状态(译者语:如果是一个web开发者,那么可以认为这和webpack的热重载是一样的)。简单的对代码进行更改,然后告诉IDE或命令行工具你需要重新加载(点击reload按钮),你就会在你的设备或模拟器上看到更改。
- 打开文件
lib/main.dart
- 将字符串
'You have pushed the button this many times:'
更改为'You have clicked the button this many times:'
- 不要按“停止”按钮; 让您的应用继续运行.
- 要查看您的更改,请调用 Save (
cmd-s
/ctrl-s
), 或者点击 热重载按钮 (带有闪电图标的按钮).
你会立即在运行的应用程序中看到更新的字符串
将上前面运行的命令行关闭。使用VSCode启动调试
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
- 获取插件
- 新建两个页面
moudules/example/route_a.dart
与moudules/example/RouterBPage.dart
- stateful与stateless这里暂时不说区别,选stateful,输入名称
- 快速修复,导包
- 最后代码修改成如下:
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"),
),
);
}
}
-
route_b.dart重复上述操作。
-
新建路由处理文件
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';
-
新建路由配置文件
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); } }
-
新建路由工具类
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"; } }
-
入口页新增路由配置
void main() { /// 配置路由开始 FluroRouter router = FluroRouter(); Routes.configureRoutes(router); NavTool.router = router; /// 入口 runApp(MyApp()); }
-
布局代码片段
new RaisedButton( child: new Text("RouterA"), onPressed: () { NavTool.push(context, "/routerA"); }), new RaisedButton( child: new Text("RouterB"), onPressed: () { NavTool.push(context, "/routerB"); })
-
效果截图
至此,路由算是集成完毕,路由的进一步学习这里就先不展开。
集成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
插件小试:
{
"userId": 1,
"userName": "张三",
"avatar": ""
}
复制上述json字符串->选中要创建dart文件的目录右键->Covert Json from Clipboard Here
输入类名回车
选择yes回车
选择yes回车
最终生成如下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中没有反射,所以需要一个个字段去转换,该工作可以由上述插件帮转换。
完整的接口请求样例
-
找到接口请求返回的样例数据
这里以我个人记账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 } ] } ] }
-
使用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;
}
}
-
新建接口类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;
-
vscode打开新终端执行如下命令
flutter pub run build_runner build
-
查看生成文件
在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; } }
-
新建一个单元测试类
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()); }
-
打开文件Ctrl+F5运行,或者鼠标点击Run
控制台输出:
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
集成阿里矢量图标库
引入svg库
flutter_svg: ^0.22.0
安装flutter-iconfont-cli插件
flutter-iconfont-cli为Nodejs插件,做为工具类,可以基于阿里云的js文件生成对应的dart图标依赖类。
npm install flutter-iconfont-cli -g
阿里矢量图标流程样例
-
登录
略
-
创建项目
-
添加图标到项目
略
-
点击生成代码
-
使用插件初始化
npx iconfont-init
-
打开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
如果矢量图标有变动,可以再次复复上述流程。
图标使用样例
/// 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),
),
)
],
)),
);
}));
}
}
集成加载中组件
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
-
生成新模块
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
实际值与预期值不一致
实际值与预期值一致
分组测试
使用 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);
});
});
}
网络接口测试
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);
});
}
Widget测试
- 新建一个页面
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!");
})),
),
);
}
}
- 单元测试流程
通过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);
});
}
- 运行Run
- 结果
注意:待测试的 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
-
修改目录
android/app/src/main/java/com/example/
==>android/app/src/main/java/com/mldong/
-
修改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 { }
-
修改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">
-
修改build.gradle文件
android/app/build.gradle
大概32行左右,节点
android->defaultConfig->applicationId
applicationId "com.mldong.fluttermvp"
修改图标
可使用图标工场生成图标
将生成的文件复制到android/app/src/res/mipmap-*
目录即可。
文件名为:ic_launcher.png
生成签名文件
-
生成签名文件
keytool -genkey -v -keystore key.jks -keyalg RSA -keysize 2048 -validity 36500 -alias fluttermvp
-
将签名文件复制到android根目录上
fluttermvp/android/key.jks
-
查看签名文件信息(按需)
keytool -list -v -keystore android/key.jks -storepass 123456
-
新建key.properties配置文件
storeFile=../key.jks storePassword=123456 keyAlias=fluttermvp keyPassword=123456
-
修改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
最后
本文从技术选型到架构搭建,从单元测试到打包发布,一步步带领大家如何从一个最简单的Flutter项目骨架到规范的Flutter MVP工程化环境,基本上涵盖了Flutter项目开发的整个流程,特别适合刚接触Flutter工程化的同学学习。
因篇幅较长,所涉及技术点较多,难免会出现错误,希望大家多多指正,谢谢大家!
转载自:https://juejin.cn/post/6960644199855161380