这个年过的是在是闷死了。在家里我基本没事干,开始还可以,之后就实在没心情了,根本做不住。由于我自己做的项目是和自己的工作有关,其余的练手还有仿App的项目我没怎么做过。刚好这一段时间在网上看着我一个朋友自己写的一个小demo,感觉挺有用的我就把这个项目自己敲了一遍,看了一下架构。很经典的MVVM架构,感觉很适合有点基础并且学Flutter的初学者。
这是基本的项目分布。
这是基本的工程项目结构,api文件的里面放我们正常api请求回来的Model,还有我们的ViewModel(在这里说一下,我们所说的MVVM和前端是有差别的,因为我们只是用的那个思想框架,但是他并没有实现双向绑定,只是我们按照MVVM框架的思想去分割我们的逻辑,达到代码的可复用性);common文件里的是我们基本的工具类封装;http文件里是我们基本的MVVM的封装(MVVM的讲解在上一个括号里有自己的差别,这个文件包括封装好基本的网络请求还json转为对象的封装方法。);config文件里是我们做的基本的全局配置(包括api请求的地址,全局变量还有全局key等);pages文件很简单,就是我们的ui界面;widget文件是我们封装的控件还有修改源码的控件;main.dart是我们的main入口文件,app.dart是我们的MyApp入口。
这里是packages文件下我们所需要的库的名称:
我的Flutter版本有点低,到时候按照自己工程版本号来使用。
正题来了,接下来我就给大家慢慢的分析和解析一下文件的架构。
由于我们的项目最基本的就是网络请求,那么直接就看网络请求的基本代码(我前面的文章有个基本的网络请求封装,但是不是太好,这边我们做的是我们项目中最常用的网络封装,当然你也可以直接拿下来用,注意:我的Flutter版本有点低,在使用的时候要看好底层api反射的使用。):
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:flutter_app_pneumonia/commom/check.dart';
import 'package:flutter_app_pneumonia/config/api.dart';
import 'package:flutter_app_pneumonia/config/config.dart';
// 请求计数
var _id = 0;
/*
* 请求类型枚举
* */
enum RequestType { GET, POST }
class ReqModel {
// 请求url路径
String url() => null;
// 请求参数
Map params() => {};
/*
* get请求
* */
Future<dynamic> get() async {
return this._request(
url: url(),
method: RequestType.GET,
params: params(),
);
}
/*
* post请求
* */
Future post() async {
return this._request(
url: url(),
method: RequestType.POST,
params: params(),
);
}
/*
* post请求-文件上传方式
* */
Future postUpload(
ProgressCallback progressCallBack, {
FormData formData,
}) async {
return this._request(
url: url(),
method: RequestType.POST,
formData: formData,
progressCallBack: progressCallBack,
params: params(),
);
}
/*
* 请求方法
* */
Future _request({
String url,
RequestType method,
Map params,
FormData formData,
ProgressCallback progressCallBack,
}) async {
Dio _client;
final httpUrl = '${API.reqUrl}$url';
if (_client == null) {
BaseOptions options = new BaseOptions();
options.connectTimeout = connectTimeOut;
options.receiveTimeout = receiveTimeOut;
options.headers = const {'Content-Type': 'application/json'};
options.baseUrl = API.reqUrl;
_client = new Dio(options);
}
final id = _id++;
int statusCode;
try {
Response response;
if (method == RequestType.GET) {
///组合GET请求的参数
if (mapNoEmpty(params)) {
response = await _client.get(
url,
queryParameters: params,
);
} else {
response = await _client.get(
url,
);
}
} else {
if (mapNoEmpty(params) && formData != null) {
response = await _client.post(
url,
data: formData ?? params,
onSendProgress: progressCallBack,
);
} else {
response = await _client.post(
url,
);
}
}
statusCode = response.statusCode;
if (response != null) {
print('HTTP_REQUEST_URL::[$id]::$httpUrl');
if (mapNoEmpty(params)) print('HTTP_REQUEST_BODY::[$id]::$params');
print('HTTP_RESPONSE_BODY::[$id]::${json.encode(response.data)}');
return response.data;
}
///处理错误部分
if (statusCode < 0) {
return _handError(statusCode);
}
} catch (e) {
return _handError(statusCode);
}
}
///处理异常
static Future _handError(int statusCode) {
String errorMsg = 'Network request error';
Map errorMap = {"errorMsg": errorMsg, "errorCode": statusCode};
print("HTTP_RESPONSE_ERROR::$errorMsg code:$statusCode");
return Future.value(errorMap);
}
}
这里做的封装已经基本算是完善了,在这里有异常还有错误的处理。由于现在大部分的请求还是post或者get,所以这里仅仅只做了这两个封装,如果你的需求不仅限于这两个,那么你可以按照这个逻辑去做,当然你要是觉得你写的比较完善,你也可以自己去写自己的。
代码我也就不多说了,就是我们基本的Dio库的网络请求。当然你要是还有优化的需求,可以加上工厂和单例,这个也可以。
然后接下来我们拿到数据后,就要转化为对象来处理,挨个的JsonToDart也是可以,但是基本的过程都是一样的,所以我们也把它进行封装。
看代码:
其实我们所说的ViewModel就是我们转换到使用的对象处理的过程,还是那句话,他并不会和前端一样实现双向绑定!
以上就是我们做好的http网络请求以及数据的模型转换。根据你的需要自己去分配,这就是两个基类,我们使用只需要继承就好了。
可能跟着敲的朋友会发现有问题,有的没有这样的方法,当然前面我也说了,这是我们自己写的,当然也有自己的方法,接下来我们就看一下我们我们封装的工具类,目录自然就是common文件下的了,我们简单的取名为check.dart,顾名思义就是我们检查工具。这里可以直接拿来复用,甚至你拿到别的工程直接CV就好了。
代码:
import 'package:flutter/material.dart';
/// 手机号正则表达式->true匹配
bool isMobilePhoneNumber(String value) {
RegExp mobile = new RegExp(r"(0|86|17951)?(1[0-9][0-9])[0-9]{8}");
return mobile.hasMatch(value);
}
///验证网页URl
bool isUrl(String value) {
RegExp url = new RegExp(r"^((https|http|ftp|rtsp|mms)?:\/\/)[^\s]+");
return url.hasMatch(value);
}
///校验身份证
bool isIdCard(String value) {
RegExp identity = new RegExp(r"\d{17}[\d|x]|\d{15}");
return identity.hasMatch(value);
}
///正浮点数
bool isMoney(String value) {
RegExp identity = new RegExp(
r"^(([0-9]+\.[0-9]*[1-9][0-9]*)|([0-9]*[1-9][0-9]*\.[0-9]+)|([0-9]*[1-9][0-9]*))$");
return identity.hasMatch(value);
}
///校验中文
bool isChinese(String value) {
RegExp identity = new RegExp(r"[\u4e00-\u9fa5]");
return identity.hasMatch(value);
}
///校验支付宝名称
bool isAliPayName(String value) {
RegExp identity = new RegExp(r"[\u4e00-\u9fa5_a-zA-Z]");
return identity.hasMatch(value);
}
/// 字符串不为空
bool strNoEmpty(String value) {
if (value == null) return false;
return value.trim().isNotEmpty;
}
/// 字符串不为空
bool mapNoEmpty(Map value) {
if (value == null) return false;
return value.isNotEmpty;
}
///判断List是否为空
bool listNoEmpty(List list) {
if (list == null) return false;
if (list.length == 0) return false;
return true;
}
/// 判断是否网络
bool isNetWorkImg(String img) {
return img.startsWith('http') || img.startsWith('https');
}
/// 判断是否资源图片
bool isAssetsImg(String img) {
return img.startsWith('asset') || img.startsWith('assets');
}
double getMemoryImageCashe() {
return PaintingBinding.instance.imageCache.maximumSize / 1000;
}
void clearMemoryImageCache() {
PaintingBinding.instance.imageCache.clear();
}
String stringAsFixed(value, num) {
double v = double.parse(value.toString());
String str = ((v * 100).floor() / 100).toStringAsFixed(2);
return str;
}
String hiddenPhone(String phone){
String result = '';
if(phone != null && phone.length >= 11){
String sub = phone.substring(0,3);
String end = phone.substring(8,11);
result = '$sub****$end';
}
return result;
}
///去除后面的0
String stringDisposeWithDouble(v, [fix = 2]) {
double b = double.parse(v.toString());
String vStr = b.toStringAsFixed(fix);
int len = vStr.length;
for (int i = 0; i < len; i++) {
if (vStr.contains('.') && vStr.endsWith('0')) {
vStr = vStr.substring(0, vStr.length - 1);
} else {
break;
}
}
if (vStr.endsWith('.')) {
vStr = vStr.substring(0, vStr.length - 1);
}
return vStr;
}
///去除小数点
String removeDot(v) {
String vStr = v.toString().replaceAll('.', '');
return vStr;
}
这些工具就是我们基本的使用,因为可能版本的迭代会使得底层的api会进行变化,所以这里面也有我们封装的Empty工具,也有正则的匹配(反正正则我没有学会,看的头疼但是基本的开始结束我是会的,哈哈)。我基本也是每次项目做好自己的工程目录这个直接CV下来就好。当然还是那句话,是否符合你的需求,就看你自己的了。
接下来还有其他工具类的封装。
时间日期的封装:
import 'package:intl/intl.dart';
import 'check.dart';
class DateTimeForMater {
static String full = "yyyy-MM-dd HH:mm:ss";
static String formatDateV(DateTime dateTime, {bool isUtc, String format}) {
if (dateTime == null) return "";
format = format ?? full;
if (format.contains("yy")) {
String year = dateTime.year.toString();
if (format.contains("yyyy")) {
format = format.replaceAll("yyyy", year);
} else {
format = format.replaceAll(
"yy", year.substring(year.length - 2, year.length));
}
}
format = _comFormat(dateTime.month, format, 'M', 'MM');
format = _comFormat(dateTime.day, format, 'd', 'dd');
format = _comFormat(dateTime.hour, format, 'H', 'HH');
format = _comFormat(dateTime.minute, format, 'm', 'mm');
format = _comFormat(dateTime.second, format, 's', 'ss');
format = _comFormat(dateTime.millisecond, format, 'S', 'SSS');
return format;
}
static String _comFormat(
int value, String format, String single, String full) {
if (format.contains(single)) {
if (format.contains(full)) {
format =
format.replaceAll(full, value < 10 ? '0$value' : value.toString());
} else {
format = format.replaceAll(single, value.toString());
}
}
return format;
}
}
String formatTimeStampToString(timestamp, [format]) {
assert(timestamp != null);
int time = 0;
if (timestamp is int) {
time = timestamp;
} else {
time = int.parse(timestamp.toString());
}
if (format == null) {
format = 'yyyy-MM-dd HH:mm:ss';
}
DateFormat dateFormat = new DateFormat(format);
var date = new DateTime.fromMillisecondsSinceEpoch(time * 1000);
return dateFormat.format(date);
}
String timeHandle(int time) {
double createTimeDouble = strNoEmpty('$time') ? time / 1000 : 0;
int createTime = int.parse('${stringDisposeWithDouble(createTimeDouble)}');
return '${formatTimeStampToString(createTime) ?? '未知'}';
}
这个地方我要说一下,当后台时间戳返回给前端时,前端要把时间戳转化为具体的时间,这个很简单,使用DateTime方法就行。但是由于dart的时间戳要求是13位的,而后台返回给我们的可能是10位的,这样就会造成转化的日期不对。
当然有问题就有解决方法(这是我以前写过的解决方法):
以上仅仅是解决时间戳在Dart和Flutter上面使用的问题。而我们的工具类是封装好的时间日期格式。
还有File的封装:
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter/services.dart';
import 'package:flutter_app_pneumonia/config/api.dart';
import 'package:flutter_app_pneumonia/config/config.dart';
import 'package:path_provider/path_provider.dart';
class FileUtil {
static FileUtil _instance;
static FileUtil getInstance() {
if (_instance == null) {
_instance = FileUtil._internal(); //初始化
}
return _instance;
}
FileUtil._internal();
Future<String> getSavePath(String endPath) async {
Directory tempDir = await getApplicationDocumentsDirectory();
String path = tempDir.path + endPath;
Directory directory = Directory(path);
if (!directory.existsSync()) {
directory.createSync(recursive: true);
}
return path;
}
void copyFile(String oldPath, String newPath) {
File file = File(oldPath);
if (file.existsSync()) {
file.copy(newPath);
}
}
Future<List<String>> getDirChildren(String path) async {
Directory directory = Directory(path);
final childrenDir = directory.listSync();
List<String> pathList = [];
for (var o in childrenDir) {
final filename = o.path.split("/").last;
if (filename.contains(".")) {
pathList.add(o.path);
}
}
return pathList;
}
///[assetPath] 例子 'images/'
///[assetName] 例子 '1.jpg'
///[filePath] 例子:'/myFile/'
///[fileName] 例子 'girl.jpg'
Future<String> copyAssetToFile(String assetPath, String assetName,
String filePath, String fileName) async {
String newPath = await FileUtil.getInstance().getSavePath(filePath);
String name = fileName;
bool exists = await new File(newPath + name).exists();
if (!exists) {
var data = await rootBundle.load(assetPath + assetName);
List<int> bytes =
data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes);
await File(newPath + name).writeAsBytes(bytes);
return newPath + name;
} else
return newPath + name;
}
void downloadFile(
{String url,
String filePath,
String fileName,
Function onComplete}) async {
final path = await FileUtil.getInstance().getSavePath(filePath);
String name = fileName ?? url.split("/").last;
Dio _client;
if (_client == null) {
BaseOptions options = new BaseOptions();
options.connectTimeout = connectTimeOut;
options.receiveTimeout = receiveTimeOut;
options.headers = const {'Content-Type': 'application/json'};
options.baseUrl = API.reqUrl;
_client = new Dio(options);
}
if (_client != null)
_client.download(
url,
path + name,
onReceiveProgress: (int count, int total) {
final downloadProgress = ((count / total) * 100).toInt();
if (downloadProgress == 100) {
if (onComplete != null) onComplete(path + name);
}
},
options: Options(receiveTimeout: 360 * 1000),
);
}
}
这里指出了我们的临时路径,还有我们需要更新时的方式(更新也是一个网络请求)。
这里是我们存储的封装:
class SharedUtil {
factory SharedUtil() => _getInstance();
static SharedUtil get instance => _getInstance();
static SharedUtil _instance;
SharedUtil._internal() {
//内部初始化
//init
}
static SharedUtil _getInstance() {
if (_instance == null) {
_instance = new SharedUtil._internal();
}
return _instance;
}
}
这就是我们最最最,必须学会的封装方式,单例加工厂,用法一样,看不懂就背过!!!!。存储方式我没有放,这个就看我们平时的需要了,我一般都是临时存储登录进来的token还有部分信息就好。至于工具自己写吧。
这里的ui是我们封装的填充组件:
import 'package:flutter/material.dart';
class HorizontalLine extends StatelessWidget {
final double height;
final Color color;
final double horizontal;
HorizontalLine({
this.height = 0.5,
this.color = const Color(0xFFEEEEEE),
this.horizontal = 0.0,
});
@override
Widget build(BuildContext context) {
return new Container(
height: height,
color: color,
margin: new EdgeInsets.symmetric(horizontal: horizontal),
);
}
}
class VerticalLine extends StatelessWidget {
final double width;
final double height;
final Color color;
final double vertical;
VerticalLine({
this.width = 1.0,
this.height = 25,
this.color = const Color.fromRGBO(209, 209, 209, 0.5),
this.vertical = 0.0,
});
@override
Widget build(BuildContext context) {
return new Container(
width: width,
color: Color(0xffDCE0E5),
margin: new EdgeInsets.symmetric(vertical: vertical),
height: height,
);
}
}
class Space extends StatelessWidget {
final double width;
final double height;
Space({this.width = 10.0, this.height = 10.0});
@override
Widget build(BuildContext context) {
return new Container(width: width, height: height);
}
}
这里有人也会说,为什么这样做,SizedBox不是可以吗?这里就看你的处理方式了。个人习惯!
这个工具封装是我们使用部分控件的高度,很有用处!!!(打个比方,如果你需要使用输入框并且把布局顶上去这个时候就需要弹出键盘的高度了!):
import 'dart:ui';
import 'package:flutter/material.dart';
double winWidth(BuildContext context) {
return MediaQuery.of(context).size.width;
}
double winHeight(BuildContext context) {
return MediaQuery.of(context).size.height;
}
double winTop(BuildContext context) {
return MediaQuery.of(context).padding.top;
}
double winBottom(BuildContext context) {
return MediaQuery.of(context).padding.bottom;
}
double winLeft(BuildContext context) {
return MediaQuery.of(context).padding.left;
}
double winRight(BuildContext context) {
return MediaQuery.of(context).padding.right;
}
double winKeyHeight(BuildContext context) {
return MediaQuery.of(context).viewInsets.bottom;
}
double statusBarHeight(BuildContext context) {
return MediaQueryData.fromWindow(window).padding.top;
}
double navigationBarHeight(BuildContext context) {
return kToolbarHeight;
}
double topBarHeight(BuildContext context) {
return kToolbarHeight + MediaQueryData.fromWindow(window).padding.top;
}
还有我们自己封装的缓存工具类和混入工具类:
import 'package:flutter_app_pneumonia/commom/data/store.dart';
class NCOVActions {
static String msg() => 'msg';
static String voiceImg() => 'voiceImg';
static String toTabBarIndex() => 'toTabBarIndex';
}
class Data {
static String msg() => Store(NCOVActions.msg()).value = '';
static String voiceImg() => Store(NCOVActions.voiceImg()).value = '';
}
import 'package:flutter/material.dart';
typedef Callback(data);
class Notice {
Notice._();
static final _eventMap = <String, List<Callback>>{};
static Callback addListener(String event, Callback call) {
var callList = _eventMap[event];
if (callList == null) {
callList = new List();
_eventMap[event] = callList;
}
callList.add(call);
return call;
}
static removeListenerByEvent(String event) {
_eventMap.remove(event);
}
static removeListener(Callback call) {
final keys = _eventMap.keys.toList(growable: false);
for (final k in keys) {
final v = _eventMap[k];
final remove = v.remove(call);
if (remove && v.isEmpty) {
_eventMap.remove(k);
}
}
}
static once(String event, {data}) {
final callList = _eventMap[event];
if (callList != null) {
for (final item in new List.from(callList, growable: false)) {
removeListener(item);
_errorWrap(event, item, data);
}
}
}
static send(String event, [data]) {
var callList = _eventMap[event];
if (callList != null) {
for (final item in callList) {
_errorWrap(event, item, data);
}
}
}
static _errorWrap(String event, Callback call, data) {
try {
// xlog(() => 'Bus>>>$event>>>$data');
call(data);
} catch (e) {
// xlog(() => 'Bus>>>$event>>>$e');
// xlog(() => 'Bus>>>$event>>>$s');
}
}
}
mixin BusStateMixin<T extends StatefulWidget> on State<T> {
List<Callback> _listeners;
void bus(String event, Callback call) {
_listeners ??= new List();
_listeners.add(Notice.addListener(event, call));
}
void busDel(Callback call) {
if (_listeners.remove(call)) {
Notice.removeListener(call);
}
}
void busDelAll() {
_listeners?.forEach(Notice.removeListener);
_listeners?.clear();
}
@override
void dispose() {
busDelAll();
super.dispose();
}
}
import 'package:flutter/material.dart';
import 'dart:async';
import 'package:shared_preferences/shared_preferences.dart';
import 'notice.dart';
typedef Widget StoreBuilder<T>(T item);
final _storeMap = <String, dynamic>{};
class Store<T> {
final String _action;
const Store(this._action);
T get value => _storeMap[_action];
set value(T v) {
if (!(v is List) && !(v is Set) && !(v is Map) && v == _storeMap[_action])
return;
_storeMap[_action] = v;
Notice.send('Store::$_action', v);
}
clear() => dispose(_action);
notifyListeners() => Notice.send('Store::$_action', _storeMap[_action]);
static dispose(String action) {
for (final key in _storeMap.keys.toList(growable: false)) {
if (key.startsWith(action)) {
final v = _storeMap.remove(key);
Notice.send('Store::$key', null);
Notice.send('Store::$key::dispose', v);
}
}
}
}
class CacheWidget<T> extends StatefulWidget {
final String action;
final StoreBuilder<T> builder;
final data;
CacheWidget(this.action, this.builder, {Key key,this.data}) : super(key: key);
@override
_CacheWidgetState createState() => new _CacheWidgetState<T>();
}
class _CacheWidgetState<T> extends State<CacheWidget<T>>
with BusStateMixin {
T item;
void init() {
final action = widget.action;
item = _storeMap[action] as T;
bus('Store::$action', onData);
}
@override
void initState() {
super.initState();
init();
widget.builder(item);
}
@override
void didUpdateWidget(CacheWidget oldWidget) {
super.didUpdateWidget(oldWidget);
busDel(onData);
init();
}
void onData(_) {
if (mounted) Timer.run(() => setState(() => item = _));
}
@override
Widget build(BuildContext context) => widget.builder(item);
}
storeString(String k,v) async {
SharedPreferences prefs = await SharedPreferences.getInstance();
prefs.setString(k, v);
}
Future<String> getStoreValue(String k) async {
SharedPreferences prefs = await SharedPreferences.getInstance();
return prefs.get(k);
}
这些都是最基本的工具封装,我也不想多解释什么。如果你实在不会那就得重现学学你的基础了!
这也是我们基本的使用结构,给你贴上去,不是为了让你去按个复制,只是让你不要混了,好友点逻辑性。因为我所发的每个代码块都是一个工具的封装,目录结构这是我的方式,你要是看明白了可以直接复制,看不明白自己一个一个的文件写就好了!