Flutter开始干系列-完整列表与Provider3实战

1,635 阅读7分钟

直接开始干,没有为什么~


在上一篇一个完整的登录实践,使用 connectivityshared_preferencesDio 实现一个完整的登录操作,包含网络连接检测、请求、数据存储等~

列表在实际开发中必不可少,因此今天在之前的基础上,实现一个"完整"列表界。项目主要功能包含 基础Widget、Dio简单封装、Provider3实践、列表下拉刷新与自动加载等

当然,接口依旧不是我实现的,是由玩Android友情提供。依旧先看效果:

效果图

请求接口图

Gif看不出请求了几次数据,上面是请求接口图,可以看到请求了1~5页。1页请求了2次,因为最后又进行了一次刷新。

Dio 简单封装

因为登录和列表都需要网络请求,还是像登录那样做的话,就有点繁琐了。这里使用单例对 Dio 进行封装。

  1. 单例

网络请求一般都会封装为单例,减少资源消耗。

class Net {
  Dio _dio;

  factory Net() => instance;

  static final Net instance = Net._();

  // 私有构造器
  Net._() {
    ...
  }
  
  ...

  Dio get dio {
    return _dio;
  }
}
  1. 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;
//    };
  }
  1. 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,接下来简单介绍下:

  1. 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,
  })
  1. Card Card 卡片效果,可以设置设置圆角和阴影
Card({
    Key key,
    this.color, // 背景色
    this.elevation, // z 坐标,简单理解控制阴影显示效果
    this.shape, // 卡片形状
    this.borderOnForeground = true,
    this.margin, // 外边距
    this.clipBehavior,
    this.child,
    this.semanticContainer = true,
  })
  1. Row Row 和 Column 类似,不同的是 Row 是横向排列,Column 是竖向排列。
  2. 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,老规矩来个简单说明。

  1. 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,
  })
  1. NotificationListener NotificationListener:通知(通知冒泡)监听,这里通过它监听 ScrollNotification(滚动通知)。
NotificationListener({
    Key key,
    @required this.child, // 监听的子 Widget
    this.onNotification, // 通知回调
  })

封装 ProviderWidget

通过上面的代码可以看到,请求完成后我们通过 setState 重绘界面。在实际使用中我们可能只需要绘制界面的某一块儿。为了方便使用 Provider 控制刷新范围,因此使用 ChangeNotifierProvider 和 Consumer 封装了一个 ProviderWidget 。不熟悉 Provider 可以先看下Flutter开始干系列-状态管理Provider3

  1. 封装
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,
      ),
    );
  }
}

  1. 使用
 
  ...

  @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 ,将刷新粒度控制在列表上。这部分代码做了在项目中做了屏蔽,可以取消注释看看。

使用效果图

最后

说明几点:

  1. 文章不是一天写完的,所以看效果图的数据是不一样的。
  2. 只做了简单的封装,并且都是写在同一个文件,方便查找。
  3. json 转 dart 插件使用的是 FlutterJsonBeanFactory,原本上一章登录就该说明的。

最后附上 Github地址github.com/joker-fu/fl…