前言
随着Flutter2.0的发布,Flutter对桌面和Web的支持也正式宣布进入stable渠道。相信过不了多久,Flutter必会成为另一主流。前一阵子,基于个人的需要以及想真正的体验一下Flutter开发,本人就使用Flutter开发了一款记账类APP。对于这次开发的体验总结一下:就是爽!开发体验非常棒!还没尝试过的同学可以从本文开始学习,从0开始搭建一套规范的Flutter项目工程环境。
本文篇幅较长,会从以下几个方面展开:
- 环境安装
- 架构搭建
- Flutter MVP规范
- 常用插件
- 代码规范
- 提交规范(待定)
- 单元测试
- 打包发布
本项目完整的代码托管在 Gitee 仓库,欢迎点亮小星星。
文章目录
-
- 前言
- 技术栈
- 环境安装
- 架构搭建
- Flutter MVP规范
- 常用插件
- 代码规范
- 提交规范
- 单元测试
- 打包发布
- 最后
- 前言
- 技术栈
- 环境安装
- 架构搭建
- Flutter MVP规范
- 常用插件
- 代码规范
- 提交规范
- 单元测试
- 打包发布
- 最后
技术栈
- 编程语言: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