Loading...
墨滴

岛上码农@公众号同名

2021/11/22  阅读:17  主题:橙心

Flutter 使用 Redux 的中间件实现异步状态管理

前言

上一篇我们介绍了 ReduxFlutter 中的基本概念和简单示例,看起来好像也没有那么复杂。但是这些操作都是同步的,点击按钮、发起 Action 调度、然后更新状态、最后更新界面是连贯的。那如果有一个异步请求怎么办,也就是我们可能是点击一个按钮发起的并不是 Action,而是异步网络请求,这个时候又如何通知更新状态?通常来说,在这种场景里我们需要发起三个 Action

  • 网络加载提示:界面通知用户当前正在请求数据,请等待,通常是一个Loading 提示。
  • 网络请求成功:接收到后端数据,然后通知界面以最新状态的数据刷新界面。
  • 网络请求失败:界面通知用户请求失败,通常是一个错误提示。

这个时候通过按钮点击回调肯定没法完成这样的操作,这个时候就需要利用到 Redux 的中间件了。本篇我们以联系人列表为例,来讲述如何使用中间件完成异步网络请求。

准备工作

  1. 首先请更新最新的后端代码:后端代码(基于 Express.js),更新后在目录下运行 node seedContactor.js 产生数据库 Mock 数据,注意图片这里使用的是本机的附件地址,在后端项目的public/upload/image 下面,如果需要展示联系人头像的自己可以找一张图片,然后修改一下seedContactor.js中的 avatar 字段为对应图片文件名。联系人的接口地址为:http://localhost:3900/api/contactor
  2. 更新依赖文件,包括如下插件:
  • redux: ^5.0.0:最新版的 Redux 插件
  • flutter_redux: ^0.8.2:Flutter 适配 Redux 的插件
  • dio: ^4.0.0:网络请求插件
  • flutter_easyrefresh: ^2.2.1:上拉、下拉刷新组件。
  • cached_network_image: ^3.1.0:支持缓存的网络图片加载组件。
  • flutter_easyloading: ^3.0.0:全局的弹窗提醒组件。
  • shared_preferences: ^2.0.6:本地离线简单键值对存储插件。
  1. 拷贝和初始化:从之前的代码中拷贝网络请求的工具类到本工程,完成如 CookieManagerEasyLoading 的初始化。当然,你可以直接从这里下载本专栏关于Redux 篇章的代码:基于 Redux 的状态管理

完成上述工作后,我们就可以开始撸本篇的代码了。

Redux 三板斧

上篇也说过,Redux 的好处之一就是状态管理的形式是统一的,三个元素 ActionStoreReducer 缺一不可,因此,我们先来梳理联系人列表业务中对应的这三个元素的内容。 image.png 首先来定义 Action,列表页面交互上会涉及2个Action,刷新和加载更多。但逻辑上还有另外两个动作:获取数据成功和获取数据失败,因此一共有4个Action

  • 刷新:获取第一页的数据,定义为 RefreshAction,在交互时使用下来刷新时调度该 Action
  • 加载更多:获取下一页的数据,定义为 LoadAction,在交互时使用上拉加载时调用该 Action
  • 加载成功:网络请求成功,定义为SuccessAction
  • 加载失败:网络请求异常或错误,定义为 FailedAction

成功和失败这两个是异步操作,没有用户交互主动 调度的可能,这里留给本篇的主角中间件来处理,稍后再单独介绍。Action 的代码如下,成功和失败因为要携带数据更新状态,因此他们有自己的成员属性:

class RefreshAction {}

class LoadAction {}

class SuccessAction {
  final List<dynamic> jsonItems;
  final int currentPage;

  SuccessAction(this.jsonItems, this.currentPage);
}

class FailedAction {
  final String errorMessage;

  FailedAction(this.errorMessage);
}

接下来是 Store的状态对象,我们要明确需要哪些数据。首先肯定的是,需要有网络请求成功后的联系人列表数据;其次是当前请求的页码,我们在加载更多的时候需要根据该页面请求下一页数据;之后是 Loading 状态标记和错误信息,Loading 状态标记在某些场合可以用于提示,而错误信息则用于错误提醒。因此,Store 对应的状态数据有:

  • contactors:联系人列表数据,为 List<dynamic>类型(要与 Dio 接收的数据匹配,只能是该类型)。
  • isLoading:加载标识,默认是 false,当调度 RefreshActionLoadAction 的时候,标记为 true,当请求成功或失败为 true,标记为 false
  • errorMessage:错误信息,允许为空,因此定义为 String?
  • currentPage:当前请求页码,默认为1。

Store 的状态对象类的代码如下:

class ContactorState {
  final List<dynamic> contactors;
  final isLoading;
  final String? errorMessage;
  final int currentPage;

  ContactorState(this.contactors,
      {this.isLoading = falsethis.errorMessage, this.currentPage = 1});

  factory ContactorState.initial() => ContactorState(List.unmodifiable([]));
}

最后是 Reducer 了,Reducer 定义是一个函数,根据旧的状态对象和当前的 Action 来返回新的状态对象。这里的业务逻辑如下:

  • RefreshAction处理:标记请求状态isLoadingtrue,当前页码currentPage1,其他保留和原先的状态一致。
  • LoadAction处理:标记请求状态isLoadingtrue,当前页码currentPage为旧状态的页面加1,其他保留和原先的状态一致。
  • FailedAction 处理:更新状态的错误消息,标记请求状态isLoadingfalse
  • SuccessAction 处理:需要根据最新的请求页码来决定如何更新列表数据。如果当前页码是1,则使用最新的的数据替换掉原有列表;如果当前页面大于1,则将新的数据拼接到旧的数据后面。之后则是更新状态的isLoadingfalse,页码为当前 action 的页码,以及清空 errorMessage

Reducer 的代码如下所示:


ContactorState contactorReducer(ContactorState state, dynamic action) {
  if (action is RefreshAction) {
    ContactorState contactorState = ContactorState(state.contactors,
        isLoading: true, errorMessage: null, currentPage: 1);
    return contactorState;
  }
  if (action is LoadAction) {
    ContactorState contactorState = ContactorState(state.contactors,
        isLoading: true,
        errorMessage: null,
        currentPage: state.currentPage + 1);
    return contactorState;
  }

  if (action is SuccessAction) {
    int currentPage = action.currentPage;
    List<dynamic> contactors = state.contactors;
    if (currentPage > 1) {
      contactors += action.jsonItems;
    } else {
      contactors = action.jsonItems;
    }
    ContactorState contactorState = ContactorState(contactors,
        isLoading: false, errorMessage: null, currentPage: currentPage);
    return contactorState;
  }

  if (action is FailedAction) {
    ContactorState contactorState = ContactorState(
      state.contactors,
      isLoading: false,
      errorMessage: action.errorMessage,
    );
    return contactorState;
  }

  return state;
}

中间件

所谓的中间件,其实就和我们之前的 Dio 的拦截器类似,也就是在调度 Action 前会先执行中间件方法,处理完之后再交给下一个中间件处理。Redux 的拦截器定义在 Store 构造方法中,形式为:

void (Store<T> store, action, NextDispatcher next)

在这里,我们定义的中间件方法名为:fetchContactorMiddleware,需要在构建 Store 对象时加入到 middleware 参数中。middleware本身是一个数组,因此我们可以添加多种中间件,以便进行不同的处理。

final Store<ContactorState> store = Store(
  contactorReducer,
  initialState: ContactorState.initial(),
  middleware: [
    fetchContactorMiddleware,
  ],
);

在中间件中我们可以获取到当前的 Action和状态,因此可以根据 Action 做不同的业务。在这里我们只需要处理刷新和加载更多:

  • 刷新时,将页码置为1,请求数据,请求成功后发起 SuccessAction 调度,通知状态更新。
  • 加载更多时,将页码加1后再请求数据,请求成功后发起 SuccessAction 调度,通知状态更新。
  • 请求失败都发起 FailedAction 调度,通知状态请求失败。

处理完之后,记得调用 next 方法,将当前action传递过去,一般完成正常的调度过程。中间件的代码如下:

void fetchContactorMiddleware(
    Store<ContactorState> store, action, NextDispatcher next) {
  const int pageSize = 10;
  if (action is RefreshAction) {
    // 刷新取第一页数据
    ContactorService.list(1, pageSize).then((response) {
      if (response != null && response.statusCode == 200) {
        store.dispatch(SuccessAction(response.data, 1));
      } else {
        store.dispatch(FailedAction('请求失败'));
      }
    }).catchError((error, trace) {
      store.dispatch(FailedAction(error.toString()));
    });
  }

  if (action is LoadAction) {
    // 加载更多时页码+1
    int currentPage = store.state.currentPage + 1;
    ContactorService.list(currentPage, pageSize).then((response) {
      if (response != null && response.statusCode == 200) {
        store.dispatch(SuccessAction(response.data, currentPage));
      } else {
        store.dispatch(FailedAction('请求失败'));
      }
    }).catchError((error, trace) {
      store.dispatch(FailedAction(error.toString()));
    });
  }

  next(action);
}

页面代码

页面代码和上一篇的结构类似,但是本篇构建了一个 ViewModel 类,使用了 StoreConnectorconverter 方法将状态的中的列表数据转换为页面展示所需要的对象。

class _ViewModel {
  final List<_ContactorViewModel> contactors;

  _ViewModel(this.contactors);

  factory _ViewModel.create(Store<ContactorState> store) {
    List<_ContactorViewModel> items = store.state.contactors
        .map((dynamic item) => _ContactorViewModel.fromJson(item))
        .toList();

    return _ViewModel(items);
  }
}

class _ContactorViewModel {
  final String followedUserId;
  final String nickname;
  final String avatar;
  final String description;

  _ContactorViewModel({
    required this.followedUserId,
    required this.nickname,
    required this.avatar,
    required this.description,
  });

  static _ContactorViewModel fromJson(Map<Stringdynamic> json) {
    return _ContactorViewModel(
        followedUserId: json['followedUserId'],
        nickname: json['nickname'],
        avatar: UploadService.uploadBaseUrl + 'image/' + json['avatar'],
        description: json['description']);
  }
}

页面的build方法如下,可以看到页面中没有体现中间件部分的代码,而是在 dispatch 过程中自动完成了。

@override
Widget build(BuildContext context) {
  return StoreProvider<ContactorState>(
    store: store,
    child: Scaffold(
      //省略 appBar
      body: StoreConnector<ContactorState, _ViewModel>(
        converter: (Store<ContactorState> store) => _ViewModel.create(store),
        builder: (BuildContext context, _ViewModel viewModel) {
          return EasyRefresh(
            child: ListView.builder(
              itemBuilder: (context, index) {
                return ListTile(
                  leading:
                      _getRoundImage(viewModel.contactors[index].avatar, 50),
                  title: Text(viewModel.contactors[index].nickname),
                  subtitle: Text(
                    viewModel.contactors[index].description,
                    style: TextStyle(fontSize: 14.0, color: Colors.grey),
                  ),
                );
              },
              itemCount: viewModel.contactors.length,
            ),
            onRefresh: () async {
              store.dispatch(RefreshAction());
            },
            onLoad: () async {
              store.dispatch(LoadAction());
            },
            firstRefresh: true,
          );
        },
      ),
      // 省略其他代码
    ),
  );

这里需要注意,EasyRefresh 组件要放置在 StoreConnector 的下一级,否则会因为在刷新的时候找不到下级ScrollView,报null错误。

运行结果

运行结果如下图所示,整个运行和之前我们使用Provider没有太大区别,但是从封装性来看,使用 Redux 的封装性会更好,比如网络请求部分的业务放在了中间件,对于组件层面来说只需要关心要发起什么动作,而不需要关心具体动作后要怎么处理。代码已提交至:Redux 相关代码

屏幕录制2021-08-21 下午9.28.26.gif
屏幕录制2021-08-21 下午9.28.26.gif

总结

先来梳理一下 Redux 加了中间件的整个流程,如下图所示。

Redux状态管理.png
Redux状态管理.png

加入了中间件后,在异步完成后可以触发异步操作相关的 Action,以便将异步结果通过Reducer处理后更新状态。引入中间件后,可以使得异步操作与界面交互分离,进一步降低的耦合性和提高了代码的可维护性。

岛上码农@公众号同名

2021/11/22  阅读:17  主题:橙心

作者介绍

岛上码农@公众号同名