直接开始干,没有为什么~
在上一篇一个完整的登录实践,使用 connectivity 、 shared_preferences 、 Dio 实现一个完整的登录操作,包含网络连接检测、请求、数据存储等~
列表在实际开发中必不可少,因此今天在之前的基础上,实现一个"完整"列表界。项目主要功能包含 基础Widget、Dio简单封装、Provider3实践、列表下拉刷新与自动加载等
当然,接口依旧不是我实现的,是由玩Android友情提供。依旧先看效果:
Gif看不出请求了几次数据,上面是请求接口图,可以看到请求了1~5页。1页请求了2次,因为最后又进行了一次刷新。
Dio 简单封装
因为登录和列表都需要网络请求,还是像登录那样做的话,就有点繁琐了。这里使用单例对 Dio 进行封装。
- 单例
网络请求一般都会封装为单例,减少资源消耗。
class Net {
Dio _dio;
factory Net() => instance;
static final Net instance = Net._();
// 私有构造器
Net._() {
...
}
...
Dio get dio {
return _dio;
}
}
- Dio 统一配置
在私有构造器中配置基本参数和拦截器等,因为登录后接口需要 Cookie ,所以拦截器里面也做了关于 Cookie 的处理。
Net._() {
_dio = Dio();
// 基本配置
_dio.options.baseUrl = 'https://www.wanandroid.com/';
_dio.options.connectTimeout = 5000;
_dio.options.receiveTimeout = 5000;
// 拦截器
_dio.interceptors
..add(
InterceptorsWrapper(
onRequest: (RequestOptions options) async {
var prefs = await SharedPreferences.getInstance();
var userJson = prefs.getString('user');
if (userJson != null && userJson.isNotEmpty) {
UserData user = UserData.fromJson(jsonDecode(userJson));
options.headers
..addAll({
'userId': user.id ?? '',
'token': user.token ?? '',
});
}
// 添加cookie
var cookie = prefs.getString("login_cookies");
if (cookie != null) {
options.headers.addAll({"Cookie": cookie.toString()});
}
return options;
},
onResponse: (Response res) async {
// 保存cookie
var cookies = res.headers['Set-Cookie'];
var prefs = await SharedPreferences.getInstance();
if (cookies != null && cookies.isNotEmpty) {
prefs.setString("login_cookies", cookies.toString());
}
},
),
)
..add(LogInterceptor(requestBody: true, responseBody: true));
// // 设置代理
// var clientAdapter = (dio.httpClientAdapter as DefaultHttpClientAdapter);
//
// clientAdapter.onHttpClientCreate = (HttpClient client) {
// client.findProxy = (uri) {
// //proxy all request to localhost:8888
// return 'PROXY 192.168.10.82:8888';
// };
// client.badCertificateCallback =
// (X509Certificate cert, String host, int port) => true;
// };
}
- post 请求
封装请求,Flutter 就没必要用回调形式了,因为咱们有 Future,好处是可以连续处理,避免嵌套。然后在真正发起请求前还是需要判断一下网络连接是否正常,请求完成后做一个简单预判(实际可以将后端返回 json 做下处理,一般返回格式都是{ "data": *** , "code": 0, "message": "" })。get请求大致相同,看源码就好。
Future post(String path, {data}) async {
// 检测网络连接
var connectivityResult = await (Connectivity().checkConnectivity());
if (connectivityResult == ConnectivityResult.none) {
throw Exception('网络错误~');
}
// 发起请求
Response response = await dio.post(path, data: data);
if (response.statusCode == 200) {
return response.data;
} else {
throw Exception('服务器错误~');
}
}
最后看一下调用
_doLogin() {
LoadingDialog.show(context);
Net.instance
.post('user/login',
data: FormData.fromMap({
"username": _accountController.text.trim(),
"password": _pwdController.text.trim(),
}))
.then((data) async {
UserEntity user = UserEntity.fromJson(data);
if (user.errorCode == 0) {
//登录成功后 保存信息
LoadingDialog.hide(context);
...
} else {
LoadingDialog.hide(context);
...
}
}).catchError((e) {
print(e.toString());
});
}
实现列表
class ProjectListPage extends StatefulWidget {
@override
_ProjectListPageState createState() => _ProjectListPageState();
}
class _ProjectListPageState extends State<ProjectListPage> {
List<ProjectListDataData> list = List();
@override
void initState() {
loadData();
super.initState();
}
/// 构建界面
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: ...,
body: ListView.builder(
itemBuilder: (context, index) {
var item = list[index];
return _buildItem(item);
},
itemCount: list.length,
),
);
}
/// 构建 item
Widget _buildItem(ProjectListDataData item) {
return Card(
child: Padding(
padding: EdgeInsets.all(10),
child: Row(
children: <Widget>[
Image.network(
...
),
SizedBox(
...
),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(...),
Text(...),
Text(...),
],
))
],
),
),
);
}
/// 请求列表数据
loadData() async {
Net.instance
.get('project/list/1/json', queryParameters: {'cid': 294}).then((res) {
ProjectListEntity entity = ProjectListEntity.fromJson(res);
if (entity.errorCode == 0) {
setState(() {
//登录成功后 保存信息
list = entity.data.datas;
});
} else {
throw Exception('响应错误');
}
}).catchError((e) {
print(e.toString());
}).whenComplete(() {});
}
}
在 initState 中调用了 loadData 获取数据,当数据获取完毕后,调用 setState 更新界面数据,这样一个简单的列表就实现了,效果如下。
目前为止我们用到了新的 Widget 有:ListView、Card、Row、Expanded,接下来简单介绍下:
- ListView ListView 是一个线性排列的可滚动部件,构建 ListView 有以下几种方式,
- ListView() 通过构建 children 中所有 widget 实现列表,不管可见与否
- ListView.builder 通过 itembuilder 构建可见 widget 实现列表
- ListView.separated 实现分割线列表
- ListView.custom 自定义 childrenDelegate 实现列表
比较常用的是前3种方式,少量列表用第一种,大量列表用第二种,需要分割线可以用第三种,。
ListView.builder({
Key key,
Axis scrollDirection = Axis.vertical, // 滚动方向
bool reverse = false, // 是否反向
ScrollController controller, // 滚动控制和监听
bool primary,
ScrollPhysics physics, // 滚动响应操作; ClampingScrollPhysics【Android 越界水波纹】、 BouncingScrollPhysics【iOS 越界回弹】
bool shrinkWrap = false, // 滚动视图长度是否只包含内容
EdgeInsetsGeometry padding,
this.itemExtent, // item长度,竖向是高度 横向是宽度 ,如果非空则强制 child 使用,不做测量
@required IndexedWidgetBuilder itemBuilder, // item 构建
int itemCount, // item 数
bool addAutomaticKeepAlives = true,
bool addRepaintBoundaries = true,
bool addSemanticIndexes = true,
double cacheExtent,
int semanticChildCount,
DragStartBehavior dragStartBehavior = DragStartBehavior.start,
})
- Card Card 卡片效果,可以设置设置圆角和阴影
Card({
Key key,
this.color, // 背景色
this.elevation, // z 坐标,简单理解控制阴影显示效果
this.shape, // 卡片形状
this.borderOnForeground = true,
this.margin, // 外边距
this.clipBehavior,
this.child,
this.semanticContainer = true,
})
- Row Row 和 Column 类似,不同的是 Row 是横向排列,Column 是竖向排列。
- Expanded
Expanded 应用在 Flex 布局中,如Row 和 Column 。使包裹的子 Widget 尽可能多的占用空间。
下拉刷新和加载更多
上面已经完成基础了列表,接下来就是添加下拉刷新和加载更多。为什么叫加载更多,而不是上拉加载,因为是根据距离自动触发。下面是加入下拉刷新和加载更多后的代码(包含分页):
...
/// 构建界面
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
automaticallyImplyLeading: false,
centerTitle: true,
title: Text('项目'),
backgroundColor: Colors.white,
),
body: NotificationListener(
child: RefreshIndicator(
child: ListView.builder(
itemBuilder: (context, index) {
var item = list[index];
return _buildItem(item);
},
itemCount: list.length,
),
onRefresh: () {
// 下拉刷新
_pageIndex = 1;
return loadData(_pageIndex);
}),
onNotification: (ScrollNotification notify) {
/// 判断滑动距离【小于等于400 】和 滚动方向
if (notify.metrics.pixels >= (notify.metrics.maxScrollExtent - 400) &&
notify.metrics.axis == Axis.vertical) {
// 加载更多
_pageIndex += 1;
loadData(_pageIndex);
}
return true;
},
),
);
}
...
/// 请求列表数据
loadData(int pageIndex) async {
await Net.instance.get('project/list/$pageIndex/json',
queryParameters: {'cid': 294}).then((res) {
ProjectListEntity entity = ProjectListEntity.fromJson(res);
if (entity.errorCode == 0) {
setState(() {
if(pageIndex == 1){
list = entity.data.datas;
}else{
list.addAll(entity.data.datas);
}
});
} else {
throw Exception('响应错误');
}
}).catchError((e) {
print(e.toString());
}).whenComplete(() {});
}
这一步完成后,就是页面文章的效果了。这一步用到了 RefreshIndicator 和 NotificationListener 两个 Widget,老规矩来个简单说明。
- RefreshIndicator
RefreshIndicator({
Key key,
@required this.child,
this.displacement = 40.0, // 刷新距离
@required this.onRefresh, // 刷新回调 需要有 async 和 await 关键字,缺少 await,刷新图标立马消失,缺少 async,刷新图标不会消失
this.color, // 指示器前景色
this.backgroundColor, //指示器背景色
this.notificationPredicate = defaultScrollNotificationPredicate,
this.semanticsLabel,
this.semanticsValue,
})
- NotificationListener NotificationListener:通知(通知冒泡)监听,这里通过它监听 ScrollNotification(滚动通知)。
NotificationListener({
Key key,
@required this.child, // 监听的子 Widget
this.onNotification, // 通知回调
})
封装 ProviderWidget
通过上面的代码可以看到,请求完成后我们通过 setState 重绘界面。在实际使用中我们可能只需要绘制界面的某一块儿。为了方便使用 Provider 控制刷新范围,因此使用 ChangeNotifierProvider 和 Consumer 封装了一个 ProviderWidget 。不熟悉 Provider 可以先看下Flutter开始干系列-状态管理Provider3
- 封装
class ProviderWidget<T extends ChangeNotifier> extends StatefulWidget {
final T model;
final Widget child;
final ValueWidgetBuilder<T> builder;
final Function(T) onReady;
const ProviderWidget({Key key, this.model, this.builder, this.onReady, this.child})
: super(key: key);
@override
_ProviderWidgetState createState() => _ProviderWidgetState<T>();
}
class _ProviderWidgetState<T extends ChangeNotifier> extends State<ProviderWidget<T>> {
T model;
@override
void initState() {
model = widget.model;
//第一帧回调,避免build中异常,如显示弹窗,当然根据个人实际情况来
WidgetsBinding.instance.addPostFrameCallback((_) {
if (widget.onReady != null) {
widget.onReady(model);
}
});
super.initState();
}
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
builder: (_) => model,
child: Consumer(
builder: widget.builder,
child: widget.child,
),
);
}
}
- 使用
...
@override
Widget build(BuildContext context) {
print('--------build page----------');
return Scaffold(
appBar: AppBar(
automaticallyImplyLeading: false,
centerTitle: true,
title: Text('项目'),
backgroundColor: Colors.white,
),
body: ProviderWidget<ProjectViewModel>(
model: ProjectViewModel(),
onReady: (model) => model.refresh(),
builder: (context, model, _) {
print('--------build list----------');
return NotificationListener(
child: RefreshIndicator(
child: (model.list?.length ?? 0) == 0
? Container()
: ListView.builder(
itemBuilder: (context, index) {
ProjectListDataData item = model.list[index];
return _buildItem(item);
},
itemCount: model.list?.length ?? 0,
),
onRefresh: () => model.refresh(),
),
onNotification: (ScrollNotification notify) {
/// 判断滑动距离和滚动方向
if (notify.metrics.pixels >=
(notify.metrics.maxScrollExtent - 400) &&
notify.metrics.axis == Axis.vertical) {
model.loadMore();
}
return true;
},
);
},
),
);
}
...
示例代码中我使用了日志输出如下,可以发现除了初次构建,都不会再输出 build page。
2019-10-22 15:10:35.239 11686-11742/com.joker.flutter_widgets I/flutter: --------build page----------
2019-10-22 15:10:35.243 11686-11742/com.joker.flutter_widgets I/flutter: --------build list----------
2019-10-22 15:10:42.126 11686-11742/com.joker.flutter_widgets I/flutter: --------build list----------
可能通过日志有的朋友还是不能理解,这里我们在列表顶部再加一个妹子,列表更新的时候妹子是不需要更新的,这时就可以使用 Provider ,将刷新粒度控制在列表上。这部分代码做了在项目中做了屏蔽,可以取消注释看看。
最后
说明几点:
- 文章不是一天写完的,所以看效果图的数据是不一样的。
- 只做了简单的封装,并且都是写在同一个文件,方便查找。
- json 转 dart 插件使用的是 FlutterJsonBeanFactory,原本上一章登录就该说明的。