该文档来源自Github fish-redux仓库的Pull request

传送门:https://github.com/alibaba/fish-redux/pull/242/commits/f660da347cc9f93a6bf28895a57174f75e70cc85#diff-845ce8b7c459ff3146e28c9254d88896 所有归原作者所有,如有侵权,请及时联系我,我将在第一时间删除

说在前面

这篇教程主要是对有一定flutter基础,而对状态管理或者redux,fish-redux几乎0基础的同学了解,如何快速使用fish-redux。这里也吐槽一下之前fish-redux的文档,以我这个水平的同学看懂一部分需要很长一段时间,这篇文章也是我看懂这些文档的一些过程和总结。不过官方在0.1.8版本后文档已经修改了很多,这里表扬一下!

状态管理概念

Flutter中更新的UI都是基于state修改然后对widget树的渲染,而UI刷新逻辑和widget树或者element树(渲染树)逻辑已经是flutter框架写好的,在大部分情况下,开发者不用去干涉,所以开发者要对UI的控制,基本都是对state的控制。以至于我们需要一个合理的流程或者说管理方式来控制state。

这里还有一个问题的就是state是什么?

state可以认为是对数据的一个封装,对于有客户端开发经验的同学,可以认为state就是数据model。

Fish-Redux中state层级

在Fish-Redux中状态可以依据状态的作用域分为以下三个层级

  1. AppState 全局状态 例如:主题,语言版本,用户状态,或者多个页面需要都需要用到的数据,这里重点提一下多页面数据,有些数据可能只有俩个页面使用,例如,进入profile,然后进入编辑,这里的数据需要共享,我们依然需要使用全局数据。这里还有一些其他方法可以做到,但是涉及到之后的知识点,现在暂不说明。
  2. page 页面状态,仅用于单个页面的状态。数据展现和改变只在页面有效。
  3. component 组件状态,仅用于单个组件的状态,状态初始化大部分来源于页面的状态的一部分。这里不细讲。

一个完整page使用流程

这里我们用一个例子来完整的使用一下基于page页面状态的fish-redux使用流程。这里主要展示编写流程和类的概念,所以不涉及到测试。如果使用tdd,需要先写测试用例,在写实现,函数测试和组件测试之后会单独介绍。

需求:用户登陆

  1. 用户输入email 和密码,点击登陆按钮执行登陆
  2. 如果输入email不合法的toast提示email不合法
  3. 输入特定的账户密码,返回登陆成功,toast用户XXX login sucess

State

第一步肯定需要定义,我们需要的状态,也就是展示或修改的数据。

  1. class LoginState implements Cloneable<LoginState> {
  2. static const LoginResult_EmailFail = 1;
  3. static const LoginResult_PassWordFail = 2; // 例子密码不合法,暂不实现
  4. static const LoginResult_NetWorkFail = 3; //例子网络错误,暂不实现
  5. static const LoginResult_LoginSuccess = 4;
  6. int loginResult = 0; //登陆的结果
  7. String userName;
  8. final int age = 0;
  9. //需要重写clone方法,因为reducer生成新的state时会调用
  10. @override
  11. LoginState clone() {
  12. return LoginState()
  13. ..loginResult = this.loginResult
  14. ..userName = this.userName
  15. ..age=this.age;
  16. }
  17. //对比方法,比较俩个实例是否相等,测试验证需要
  18. @override
  19. bool operator ==(dynamic other) {
  20. if (!(other is LoginState)) return false;
  21. return loginResult == other.loginResult && userName == other.userName
  22. &&age==other.age;
  23. }
  24. }
  25. //这里有个静态函数,用于初始化state,具体调用位置之后会介绍
  26. //需要注意的是 这是静态函数,不属于LoginState类
  27. LoginState initState(Map<String, dynamic> args) {
  28. return LoginState()
  29. ..loginResult = 0
  30. ..userName = ""
  31. ..age=0;
  32. }

view

这是一个函数非一个类,返回一个widget

  1. //编辑控制器,用于获取编辑文本
  2. //这里使用有些欠妥,这里定义在外部,在多个相同实例的情况下,可能会有问题,建议还是将这两个字段表达在state中。
  3. //这里是例子,就不做修改了。希望读者在实际使用中注意。
  4. final _emailController = TextEditingController();
  5. final _passwordController = TextEditingController();
  6. //参数
  7. // state用于数据展示
  8. // dispatch用于发送action
  9. // ViewService 用于获取buildcontext,跳转页面需要使用到
  10. Widget buildView(demoState state, Dispatch dispatch, ViewService viewService) {
  11. return MaterialApp(
  12. home: Container(
  13. color: Colors.white,
  14. padding: EdgeInsets.all(20),
  15. child: Center(
  16. child: Column(
  17. children: <Widget>[
  18. SizedBox(
  19. height: 200,
  20. ),
  21. //生成eamil编辑组件
  22. //这里widget较为复杂,都用私有函数生成,建议真实开发中widget嵌套不要超过三层
  23. _buildEdit(
  24. Icon(Icons.email), "email", "input email", _emailController,
  25. key: ValueKey('emailEdit')),
  26. SizedBox(
  27. height: 20,
  28. ),
  29. //生成password编辑组件
  30. _buildEdit(Icon(Icons.lock), "password", "input password",
  31. _passwordController,
  32. key: ValueKey('passwordEdit')),
  33. SizedBox(
  34. height: 20,
  35. ),
  36. //生成登陆按钮组件
  37. _loginBtn(dispatch, viewService, key: ValueKey('loginBtn')),
  38. SizedBox(
  39. height: 20,
  40. ),
  41. //state中的数据展示如果state更新,会自动刷新
  42. Text('name = ${state.userName}',style: TextStyle(fontSize: 16),),
  43. ],
  44. ),
  45. )));
  46. }
  47. //编辑组件
  48. Widget _buildEdit(
  49. Widget icon, String label, String hint, TextEditingController controller,
  50. {Key key}) {
  51. ...
  52. controller: controller,
  53. }
  54. //登陆按钮
  55. Widget _loginBtn(Dispatch dispatch, ViewService viewService, {Key key}) {
  56. return RaisedButton(
  57. ...
  58. onPressed: () {
  59. //获取数据
  60. var email = _emailController.text;
  61. var password = _passwordController.text;
  62. //组装数据
  63. Map<String, dynamic> map = {"email": email, "password": password};
  64. //发送包装好数据Action
  65. dispatch(LoginActionCreator.onLoginAction(map));
  66. },
  67. );
  68. }

Action

Action是Fish-Redux中复杂通讯的纽带,于redux不同,fish-redux已经为开发者定义好了,action封装,开发者只需要定义其Type和数据(需要传递的参数),具体方式如下:

  1. enum LoginAction { login,emailFail,loginSuccess}
  2. //与redux一样使用时用静态方法创建
  3. class LoginActionCreator {
  4. static Action onEmailFail() {
  5. return Action(LoginAction.emailFail);
  6. }
  7. static Action onLoginSuccess(LoginModel loginModel) {
  8. return Action(LoginAction.loginSuccess,payload: loginModel);
  9. }
  10. static Action onLoginAction(params) {
  11. return Action(LoginAction.login,payload: params);
  12. }
  13. }

Effect和Reducer

说完Action,我们来说看看Action的接收者,Fish-Redux把Action的接收者分为俩块,Effict和Reducer都是一组接收Action的函数,主要的区别是,前者不产生新的状态(state),后者产生一个状态后给view并执行view的build方法重绘页面。接下来还是用一个例子说明,实现判断用户输入的email合法性与请求网络并返回登陆成功的需求。

  1. Effect
  2. Effect<LoginState> buildEffect() {
  3. return combineEffects(<Object, Effect<LoginState>>{
  4. //收到type为login的action执行,onlogin方法,这里开发者不用定义参数,是因为已经被Effect定义好的,
  5. //参数为action和Context<T>
  6. demoAction.login: onLogin,
  7. });
  8. }
  9. void onLogin(Action action, Context<demoState> ctx) {
  10. //取出action的登陆参数,参数类型为dynamic,可以是任意对象
  11. Map loginMap = action.payload;
  12. String email = loginMap['email'];
  13. if (!email.contains("@")) {
  14. //由于是例子,就简单的判断email是否含有@字符,如果没有说明email不合法
  15. //如果不合法使用context,发送EmailFailAction给reducer
  16. ctx.dispatch(demoActionCreator.onEmailFail());
  17. }else{
  18. //eamil合法初始化网络请求工具类。备注:这个网络请求工具是基于dio很简单的封装,为了之后的测试mock方便
  19. //读者可以根据自己的业务编写。
  20. //这里的??=翻译一下
  21. //if(API.request==null){
  22. // API.request=HttpRequest(API.BASE_URL)
  23. //}
  24. API.request??=HttpRequest(API.BASE_URL);
  25. //执行post返回一个future
  26. API.request.post(API.Login, loginMap).then((result) {
  27. //由于是例子没有判断result的合法性,直接转为model数据类
  28. //model类可以通过json转dart类工具生成 地址
  29. //https://javiercbk.github.io/json_to_dart/
  30. LoginModel loginModel=LoginModel.fromJson(result);
  31. //发送登陆成功action给reducer,并model做为参数,放在action中一起传递给reducer
  32. ctx.dispatch(demoActionCreator.onLoginSuccess(loginModel));
  33. });
  34. }
  35. }
  36. Reducer
  37. Reducer<LoginState> buildReducer() {
  38. return asReducer(
  39. <Object, Reducer<LoginState>>{
  40. //收到相应type 的action执行相应的方法
  41. LoginAction.emailFail: _onEmailFail,
  42. LoginAction.loginSuccess: _onLoginSuccess
  43. },
  44. );
  45. }
  46. LoginState _onLoginSuccess(LoginState state, Action action) {
  47. //返回的state一定不能拿到旧的state直接修改并返回,
  48. //需要创建一个新的state,大部分情况是用clone创建。
  49. final LoginState newState = state.clone();
  50. //状态赋值
  51. newState.loginResult=LoginState.LoginResult_LoginSuccess;
  52. //action.payload是action的参数,类型也是dynamic,这里不强转也不会报错,只是写的时候IDE不会
  53. //自动联想。所以我建议还是写上比较清晰
  54. newState.userName=(action.payload as LoginModel).session.user.displayName;
  55. newState.age=(action.payload as LoginModel).session.user.age;
  56. return newState;
  57. }
  58. LoginState _onEmailFail(LoginState state, Action action) {
  59. final LoginState newState = state.clone();
  60. //改变state状态为email不合法
  61. newState.loginResult=LoginState.LoginResult_EmailFail;
  62. return newState;
  63. }

这里提醒一下,页面是可以直接发送action给reducer的,不是一定要进过effect,但是如果effect定义接收action的type,reducer就不能收到这个action了。

获取新数据展示

如果认真读了前面的代码的小伙伴,应该可以自己领悟出,新数据展示该怎么写了。这里一起回顾一下,并添加一个email不合法的toast。回到我们的view,

  1. Widget buildView(LoginState state, Dispatch dispatch, ViewService viewService) {
  2. //在build时,查看状态是否为EmailFail。
  3. _showEamilFailToast(state, viewService);
  4. return MaterialApp(
  5. home: Container(
  6. color: Colors.white,
  7. padding: EdgeInsets.all(20),
  8. child: Center(
  9. child: Column(
  10. children: <Widget>[
  11. ...
  12. //state中的数据展示如果state更新,会自动刷新
  13. Text('name = ${state.userName}',style: TextStyle(fontSize: 16),),
  14. ],
  15. ),
  16. )));
  17. }
  18. _showEamilFailToast(LoginState state, ViewService viewService) {
  19. if (state.loginResult == LoginState.LoginResult_EmailFail
  20. ) {
  21. //如果当前状态为EmailFail,便展示toast
  22. Fluttertoast.showToast(
  23. msg: "Login Email Fail",
  24. toastLength: Toast.LENGTH_SHORT,
  25. gravity: ToastGravity.CENTER,
  26. timeInSecForIos: 1,
  27. backgroundColor: Colors.white,
  28. textColor: Colors.black);
  29. }
  30. }

Page拼装

说完了流程和所有相应的组件,接下来介绍一下他们是怎么结合在一起的。(这里暂时只介绍Page的拼装,至于App和compnent的拼装比较大同小异,不同的地方之后会单独介绍)

  1. //声明范型,一个page必须对应一个State,后面的map为启动这个page时传递的参数类型之后会提到
  2. class LoginPage extends Page<LoginState, Map<String, dynamic>> {
  3. LoginPage()
  4. : super(
  5. //这里传入初始化一个初始化函数,也就是state.dart中定义的初始化函数
  6. initState: initState,
  7. //一个返回effect函数,在effect.dart中定义
  8. effect: buildEffect(),
  9. // 面向对象编程
  10. // higherEffect: higherEffect(() => MessageEffect()),
  11. //一个返回reducer函数,在reducer.dart中定义
  12. reducer: buildReducer(),
  13. //一个返回reducer函数
  14. view: buildView
  15. );
  16. }

AppState全局状态

Page组装器路由(Routes)

上面的page使用流程,应该大家对fish-redux的使用,有一个大致的了解。不过还有一个问题没有觉得怎么把page放在flutter中去。因为page这里并不返回一个widget所以不能直接使用,我们需要用路由(routes)来完成对page的组装,并build为具体的widget。fish-redux中routes有俩种类型,分别是PageRoutes和AppRoutes,我们从比较简单的PageRoutes说起。

PageRoutes

PageRoutes是最基本的page组装。我们直接看看代码里使用。

  1. PageRoutes pageRoutes=PageRoutes(
  2. pages: <String, Page<Object, dynamic>>{
  3. //这是一个map,实际使用中login这个字符串应该定义到为一个static const字符串。
  4. //这里为了展示清晰直接使用字符串
  5. 'login': LoginPage()
  6. ...//这里可以声明多个page
  7. },
  8. );
  9. //使用route生成widget
  10. //重点是这里的map参数。这是一个范型,也就是我们page类在定义的时候继承page声明的范型。用于在buildpage时传递数据给page,所以一般以map的key=>value的形式。开发者也可以定义任意形式。
  11. //在page中使用,大家应该还记得initstate,这个函数我们传递了一个map,那就是这个map了。用于初始化state。
  12. Widget loginWidget=pageRoutes.buildPage('login', Map())
  13. ...//生成widget,就可以做任意的组装了。

AppRoutes

认真的读者,会有也许会有这样的疑问,为什么page的路由使用会放到AppState全局状态的内容里?接下来就会提到重点AppRoutes。AppRoutes是一个包含了State,Reducer,pages的路由,整个过程,我们还是以一个例子来讲解,

例子需求

  1. 点击登陆后跳转到一个detail页面,背景色为主题色
  2. 点击detail文字后,背景色替换
  3. 在detail页面退出后再点击login再次进入detail页面,背景色为更换后的主题色

State

我们定义俩个state,一个是appstate,一个是detailstate。之后我们会说这俩个state的连接问题

  1. //复习一下state,实现Cloneable,定义数据,重写clone函数和判等函数
  2. class AppState implements Cloneable<AppState> {
  3. //这里我们只定义了一个color
  4. Color themeColor;
  5. AppState(this.themeColor);
  6. @override
  7. AppState clone() {
  8. return AppState(this.themeColor);
  9. }
  10. @override
  11. bool operator ==(dynamic other) {
  12. if (!(other is AppState)) return false;
  13. return themeColor == other.themeColor;
  14. }
  15. AppState.initialState() {
  16. themeColor=Colors.blue;
  17. }
  18. }
  19. //detailpage使用的state
  20. class DetailState implements Cloneable<DetailState> {
  21. //也只有一个颜色这一个属性
  22. Color themeColor;
  23. @override
  24. DetailState clone() {
  25. return DetailState();
  26. }
  27. }
  28. DetailState initState(Map<String, dynamic> args) {
  29. return DetailState()..themeColor=Colors.red;
  30. }

Action和reducer

这里的action和reducer都比较简单,我们就一起把代码贴出来。这里需要注意的是AppRoutes是没有effect。因为不修改状态的action都应该在page中执行。

  1. //这里是把背景色改为黄色
  2. enum AppAction {changYellow}
  3. class AppActionCreator {
  4. static Action onChangeYellowAction() {
  5. return const Action(AppAction.changYellow);
  6. }
  7. }
  8. Reducer<AppState> buildReducer() {
  9. return asReducer(
  10. <Object, Reducer<AppState>>{
  11. AppAction.changYellow: _changYellow,
  12. },
  13. );
  14. }
  15. AppState _changYellow(AppState state, Action action) {
  16. final AppState newState = state.clone();
  17. newState.themeColor=Colors.yellow;
  18. return newState;
  19. }

ConnOp

这是一个连接器,当我们需要从一个state获取一些数据初始化另一个SubState,并且childstate的变化需要同步到State中,这时我们需要定义一个连接器,来定义SubState如何通过State的数据初始化,和Substate变化时如何改变State。在我们的例子中便是 appstate和 detailstate。具体我们看看代码。

  1. //继承ConnOp
  2. class DetailConn extends ConnOp<AppState, DetailState> {
  3. @override
  4. DetailState get(AppState state) {
  5. //重写get函数,返回一个DetailState,也就是DetailState的绑定
  6. return AppState.detailState;
  7. }
  8. @override
  9. void set(AppState state, DetailState subState){
  10. //重写set函数,定义当DetailState有新的状态时AppState应该如何修改
  11. AppState.detailState=subState;
  12. }
  13. }

连接器是fish-redux比较核心的部分,这里用官方文档的俩句话来描述一下。

  • 它表达了如何从一个大数据中读取小数据,同时对小数据的修改如何同步给大数据,这样的数据连接关系。
  • 它是将一个集中式的 Reducer,可以由多层次多模块的小 Reducer 自动拼装的关键。

这里和下面component的部分都简单的做了使用介绍,没有继续深入。有兴趣的读者,可以之后去看看实现的原理,这里不再赘述。

AppRoutes

我们在看看AppRoutes是怎么完成组装的。其实和page的组件基本相似。

  1. AppRoutes<AppState>(
  2. //初始化状态,这里和page不同,返回不是一个函数,而直接是一个state,
  3. //我们这里用的一个工厂构造方法获取
  4. preloadedState: AppState.initialState(),
  5. //配置page组,和每个page和connOp的关系
  6. slots: {
  7. // 这里有两种写法,效果是一样的,带操作符的写法比较生动,也简短些。
  8. // RoutePath.todoList: DetailPage().asDependent(DetailConn()),
  9. // 这里的加运算符可能部分读者会有疑惑,这里和上面这句代码意义相同,至于为什么可以用这个运算符
  10. // 是因为ConnOp的运算符被重写了
  11. // 具体代码 Dependent<T> operator +(Logic<P> logic) => createDependent<T, P> // (this, logic);
  12. 'detail': DetailConn() + DetailPage(),
  13. },
  14. //配置reducer()和page完全一样
  15. reducer: buildReducer())
  16. ])
  17. //创建page widget和PageRoutes完全相同
  18. //不过这里参数传null,因为我们已经通过ConnOp,把page的state初始化工作完成了。所以不通过参数初始化state
  19. //而且page中的initstate函数也不会被调用
  20. AppRoute.buildPage('detail', null);
  21. //发送action给app的reducer
  22. //开发者需要把自己创建的appRoutes静态暴露出来。在使用的地方直接调appRoutes的store发送action
  23. AppRoute.appRoutes.store.
  24. .dispatch(AppActionCreator.onChangeYellowAction());
  25. //也可以发送action,改变子state,通过ConnOp改变appstate,
  26. //建议使用这种方法,上一种方法可以用与改变appstate,但是这个数据不属于当前page的state数据的情况
  27. dispatch(DetailActionCreator.onChangeYellowAction());

关于全局state的更新

之前介绍了PageRoutes和AppRoutes,根据和fish-redux开发者我的一些问题的解答,这里对之前的一些讲解做一些更正,这里的更正基于0.1.8版本。

用PageRoutes实现主题

之前的例子,我们使用AppRoutes修改全局状态,来改变主题,细心的读者可能发现在连接器中,AppState是包含了整个DetailState,如果我们有成百上千的页面都需要主题色,那AppState不是需要包涵全部的PageState,这是我们不想看到的。对这个疑问,我在issue上跟fush-redux的开发者做了沟通,他给了一个在PageRoutes上实现的方案。

  1. const Store<AppState> appStore = ...;
  2. MainRoutes extends HybridRoutes {
  3. MainRoutes():super(
  4. routes: [
  5. PageRoutes(
  6. pages: <String, Page<Object, dynamic>>{
  7. //这里依然强调数据流的单向性。
  8. //AppState的数据变更,推送到PageStore,引起PageState的变更与否,再由PageState的变更推送到所 //有页面内的组件的UI变更。
  9. 'detail': DetailPage()..connectExtraStore<AppState>(appStore, (DetailState detailState, AppState appState) {
  10. return detailState.clone().. themeColor = appState.themeColor;
  11. },
  12. ),
  13. },
  14. ),
  15. ]
  16. );
  17. }

在0.1.8版本中AppRoutes已经不推荐使用了,各位这样使用PageRoutes完成Page与全局State的绑定关系。

ComponentState组件状态

接下来我们讲解最一个层级的状态,组件状态,这是fish-redux做到分治的核心,开发者可以根据自己的业务,按照自己的颗粒度来编写一个逻辑独立的组件。且这个组件可插拔到任意你想要的地方。这里我们也用一个例子来讲解。

例子需求

  1. 登陆成功和展示用户年龄,并添加一个add按钮
  2. 点击add按钮用户年龄加一

Component

由于Component在action,effect,reducer,state这些使用和page完全一样,这里就重新说明了,具体的可以看看例子源码就懂了,我们着重讲一下Component在page中的使用。

  1. 第一步在page中声明需要使用的Component
  2. 第二步在view中通过viewService创建widget
  1. //在page中声明
  2. class demoPage extends Page<demoState, Map<String, dynamic>> {
  3. demoPage()
  4. : super(
  5. ...
  6. dependencies: Dependencies<demoState>(
  7. adapter: null,
  8. slots: <String, Dependent<demoState>>{
  9. //注册component
  10. //map的key是Component的名字,在view中创建的时候会用到
  11. //map的value是一个ConnOp和对应Component
  12. 'ageChange':AgeChangeConn() + AgechangeComponent()
  13. }),
  14. ...
  15. );
  16. }
  17. //在view中使用
  18. Widget buildView(demoState state, Dispatch dispatch, ViewService viewService) {
  19. return MaterialApp(
  20. home: Container(
  21. color: Colors.white,
  22. padding: EdgeInsets.all(20),
  23. child: Center(
  24. child: Column(
  25. children: <Widget>[
  26. ...
  27. //使用这个函数就可以创建Component的widget
  28. viewService.buildComponent('ageChange')
  29. ],
  30. ),
  31. )));
  32. }

最后在着重讲解一点,Component数据的来源,可能有些读者已经了解,在page中申明 的时候我们同时声明了ConnOp,我们就是通过这个ConnOp把Compotent的state初始化的。

修正

如果页面有可能多次构建,且email和password的输入需要保存,_emailController和_passwordController应该放入state,

测试相关

测试这部分主要是函数测试和widget测试。这里我们还是以之前page那个例子的需求,说一下如果以TDD的形式如果写函数单测和widget测试。

函数测试

函数测试我们主要是对所有的effect和reducer进行测试。且测试代码应该放到相应的文件夹下,对其我们的生产代码。测试代码结构应该为这三个文件。下面我们一一说明。

image-20190518094709960

effect测试

虽然这里写了effect测试,由于fish-redux暂时对effect的单测支持不好,在最新的版本中不能对context.dispatch做验证,但是已经和fish-redux沟通,已经在吧dispatch修改为方法,并合并到master分支上。这里我们已修改后的fish-redux做讲解。

我们主要测试收到type为login的action后执行的onLogin函数。

更正

最新的0.1.8版本已经把dispatch修改为函数。下面代码在0.1.8版本下可以通过测试

  1. //flutter想要mock类,是非常简单的,因为每个类都是一个接口,
  2. // mock类直接继承mock类,并实现你想要mock的类就可以生成mock类
  3. //这里我们mock,effect发送action的context类和网络请求工具类,模拟返回。
  4. class MockContext extends Mock implements Context<demoState> {}
  5. class MockRequest extends Mock implements HttpRequest {}
  6. void main() {
  7. test('onlogin email fail', () {
  8. MockContext context=MockContext();
  9. Map<String, dynamic> map = {"email": '123', "password": '123'};
  10. Action action =demoActionCreator.onLoginAction(map);
  11. //这里onLogin在一般的demo中应该是私有函数,但是我们需要单测,我暂时没有找到测试私有函数的方法
  12. //目前把它改为public的函数,reducer中方法是一样的
  13. onLogin(action,context);
  14. //目前fish-redux 0.1.7版本dispatch还是一个字段,会导致这个验证是失败的
  15. //跟fish-redux的开发者提出这个问题后,已经在代码里做里改进。改动已经合在master分支上了。
  16. verify(context.dispatch(demoActionCreator.onEmailFail()));
  17. });
  18. test('onlogin login success', () {
  19. MockRequest mockRequest = MockRequest();
  20. API.request=mockRequest;
  21. String loginSuccessReslut='{"session": {"token": "5cd961ec5db81","expire": 300,"rong": "*******","user": {"user_id": "5cd961ebb2fea9226c8b4568","display_name": "devcie9poh","gender": 1,"age": 38}}';
  22. //这里提一下,因为post返回是一个future,所有需要用thenAnswer返回。这个写法是固定的
  23. when(mockRequest.post(any, any)).thenAnswer((_) => Future.value(json.decode(loginSuccessReslut)));
  24. MockContext context=MockContext();
  25. Map<String, dynamic> map = {"email": '123@gmail.com', "password": '123456'};
  26. Action action =demoActionCreator.onLoginAction(map);
  27. onLogin(action,context);
  28. verify(context.dispatch(demoActionCreator.onloginSuccess());
  29. });
  30. }

Reducer测试

相比effect 测试,reducer测试相对简单,因为函数直接返回state,且都是不涉及外部变量的纯函数。

  1. void main() {
  2. test('reducer onLoginSuccess', () {
  3. String loginSuccessReslut='{"session": {"token": "5cd961ec5db81","expire": 300,"rong": "*******","user": {"user_id": "5cd961ebb2fea9226c8b4568","display_name": "devcie9poh","gender": 1,"age": 38}}';
  4. LoginModel loginModel=LoginModel.fromJson(json.decode(loginSuccessReslut));
  5. demoState state= onLoginSuccess(demoState(),demoActionCreator.onLoginSuccess(loginModel)) ;
  6. expect(state.loginResult,demoState.LoginResult_LoginSuccess);
  7. expect(state.age,38);
  8. expect(state.userName,"devcie9poh");
  9. });
  10. }

Widget测试

这里的组件测试主要参考fish-redux中对整个框架流程的测试,讲解基于fish-redux 项目test代码,fish-redux的组件测试会和其他有比较大的差别。首先我们需要三个dart工具代码。Instrument.dart , test_base.dart, track.dart。具体我们在代码中再做介绍。这次我们分俩个测试用例来讲解。

  • 组件UI测试
  1. testWidgets('login page build', (WidgetTester tester) async {
  2. //足迹类,用于最后的验证。最后会通过程序运行真实的足迹和我们设定的足迹是否相等来判断测试是否通过
  3. final Track track = Track();
  4. //flutter中widget测试是模拟一个widget的生成,如果不清楚的同学可以查看 https://flutter.dev/docs/cookbook/testing/widget/introduction
  5. //TestPage是之前引入的test_base.dart工具类中一个page实现和page一样
  6. //只是initState和view为必填参数,因为是单page测试,不可能从appstate进行state初始化
  7. await tester.pumpWidget(TestPage<demoState, Map<String, dynamic>>(
  8. //instrumentInitState是Instrument.dart中的函数,
  9. //作用是对真实的initState做一次封装,目的是在initstate方法执行后,增加一段记录代码
  10. initState: instrumentInitState<demoState, Map<String, dynamic>>(
  11. initState, pre: (map) {
  12. track.append('initState', map);
  13. }),
  14. //与前面的instrumentInitState类似,封装buildview函数,添加一段记录代码
  15. view: instrumentView<demoState>(buildView,
  16. (demoState state, Dispatch dispatch, ViewService viewService) {
  17. track.append('build', state.clone());
  18. })).buildPage(null));
  19. //页面测试 目前只能测试是否含有这些元素
  20. // findsNothing
  21. // 验证没有找到Widgets
  22. // findsWidgets
  23. // 验证是否找到一个或多个小部件
  24. // findsNWidgets
  25. // 验证是否找到特定数量的小部件
  26. expect(find.byKey(ValueKey('emailEdit')), findsOneWidget);
  27. expect(find.byKey(ValueKey('passwordEdit')), findsOneWidget);
  28. expect(find.byKey(ValueKey('loginBtn')), findsOneWidget);
  29. //验证代码是否按照预想的执行
  30. expect(track,
  31. Track.pins([Pin('initState', null), Pin('build', initState(null))]));
  32. });
  • 组件逻辑测试

这里我们测试登陆的俩种情况代码比较长,请耐心阅读。

  1. testWidgets('login page test', (WidgetTester tester) async {
  2. final Track track = Track();
  3. //生成testpage
  4. await tester.pumpWidget(TestPage<demoState, Map<String, dynamic>>(
  5. initState: instrumentInitState<demoState, Map<String, dynamic>>(
  6. initState, pre: (map) {
  7. track.append('initState', map);
  8. }),
  9. view: instrumentView<demoState>(buildView,
  10. (demoState state, Dispatch dispatch, ViewService viewService) {
  11. track.append('build', state.clone());
  12. }),
  13. //封装effect 函数,执行记录代码。这类可以拿到action和当时的状态
  14. effect: instrumentEffect(buildEffect(),
  15. (Action action, Get<demoState> getState) {
  16. if (action.type == demoAction.login) {
  17. //这个状态是改变之前的
  18. track.append('on effect login', getState().clone());
  19. }
  20. }),
  21. reducer: instrumentReducer<demoState>(buildReducer(),
  22. suf: (demoState state, Action action) {
  23. track.append('onReduce', state.clone());
  24. }),
  25. ).buildPage(null));
  26. ///输入不合法email,点击登陆,返回email不合法
  27. await tester.enterText(find.byKey(ValueKey('emailEdit')), "123");
  28. await tester.tap(find.byKey(ValueKey('loginBtn')));
  29. await tester.pump();
  30. demoState state = initState(null);
  31. expect(
  32. track,
  33. Track.pins([
  34. Pin('initState', null),
  35. Pin('build', state.clone()),
  36. Pin('on effect login', state.clone()),
  37. //注意如果要验证state是否相等,一定要重写相等函数
  38. Pin('onReduce', () {
  39. state = state.clone()
  40. ..loginResult = demoState.LoginResult_EmailFail;
  41. return state;
  42. }),
  43. ///刷新后会执行build并传入最新的state
  44. Pin('build', state.clone())
  45. ]));
  46. ///输入正确数据,点击登陆
  47. //如果要多次测试记得重制路径
  48. track.reset();
  49. //模拟请求
  50. MockRequest mockRequest = MockRequest();
  51. API.request = mockRequest;
  52. String loginSuccessReslut =
  53. '{"session": {"token": "5cd961ec5db81","expire": 300,"rong": "*****","user": {"user_id": "5cd961ebb2fea9226c8b4568","display_name": "devcie9poh","gender": 1,"age": 38}';
  54. when(mockRequest.post(any, any))
  55. .thenAnswer((_) => Future.value(json.decode(loginSuccessReslut)));
  56. await tester.enterText(
  57. find.byKey(ValueKey('emailEdit')), "123@gmail.com");
  58. await tester.enterText(find.byKey(ValueKey('passwordEdit')), "123456");
  59. await tester.tap(find.byKey(ValueKey('loginBtn')));
  60. await tester.pump(Duration(seconds: 5));
  61. expect(
  62. track,
  63. Track.pins([
  64. Pin('on effect login', state.clone()),
  65. Pin('onReduce', () {
  66. state = state.clone()
  67. ..loginResult = demoState.LoginResult_LoginSuccess
  68. ..userName = "devcie9poh"
  69. ..age = 38;
  70. return state;
  71. }),
  72. ///刷新后会执行build并传入最新的state
  73. Pin('build', state.clone())
  74. ]));
  75. });

其他

就像之前所说的到这里已经基本完成了对fish-redux的基本使用。至于adapter,生命周期这些使用,目前我也还没有在实际中使用到。所以对其了解不深,就不在这里介绍了。如果之后有使用,可能会在后面做更新。其中adapter虽然也很常用,不过我真的有些部分看不懂,讲出来也是最基本的使用,原谅水平有限。如果之后实际项目中用到可能还会去深入研究,所以就到这里了。去敲业务代码写单元测试了 -.-