Flutter之Fish_Redux实战训练(一)

本文在简书首发,如有转载,请注明出处
https://www.jianshu.com/p/591088f3bc73

前言

flutter的优点我就不多说了,现在搞flutter的人越来越多,但是网上查阅的资料不多,大部分的都只能靠自己尝试。今天的Demo主要讲的是fish_redux的实战训练,通过简单的demo完成一个登陆的过程。不涉及fish_redux的高级应用,仅仅是fish_redux的入门实战。

flutter状态管理

原本在Android开发中是不需要状态管理的,但是接触到了flutter以后,刚开始的时候单一界面,没有抽取Widget以前,使用flutter自带的setState已经足够满足简单APP的要求了。
随着项目越来越大,单一界面间如果不拆分widget进行管理,一个页面会非常庞大,几千行代码都很正常,这样不仅仅耦合度高,而且修改起来非常麻烦,容易造成因小失大,各种Bug层出不穷。
在拆分Widget这个大环境下,发现setState这个刷新页面的方法不管用了,因为StatefulWidget的setState方法只对自己的Widget树里的空间可以起刷新作用。
这个时候就要用到状态管理了,目前主流状态管理方式有四种

  • scoped_model(或者provide)
  • bloc
  • redux
  • fish_redux

使用状态管理,然后通过状态切换,去刷新页面。这个是非常好的,而且原生Android开发会感觉非常熟悉,又可以开心的开发Android了。

fish_redux是什么?

fish_redux作为阿里的闲鱼团队开源的一套比较成熟的框架。这里不再详述,下面参考几个地址,有想了解具体的可以看一下:

为什么选fish_redux?

作者结合自身原生Android开发的时候使用的MVP模式,尝试了主流的四种状态管理后,简单分析一下四种状态管理的优缺点:

  • scoped_model(或者provide)

作为Google原生的状态管理,通过封装InheritedWidget实现了状态管理,而且一并提现Google的设计思想,单一原则,这个Package仅仅作为状态管理来用,几乎没有学习成本,如果是小型项目使用,只用Scoped_model来做状态管理,无疑是非常好的选择,但是越大的项目,使用scoped_model来做状态管理,会有点力不从心。

  • bloc

在没有scoped_model、redux这些状态管理Package之前,bloc是业界最推崇的flutter状态管理工具。而且很好的支持了Stream方式。学习成本较高,大型项目也可以轻松解决。

  • redux

flutter的redux简直是前端开发者的福音,跟React Redux的使用基本保持一致,切换起来非常舒适,方便,非常适合全局状态管理,但是对于没有React经验的人,以及没有使用过Redux的人来说,学习成本非常高。

  • fish_redux

fish_redux的学习成本其实是这几个状态管理里面最高的,但是带来的收益和效果也是最明显,fish_redux是基于redux封装,不仅仅能够满足状态管理,更是集成了配置式的组装项目,Page组装,Component实现,非常干净,易于维护,易于协作没,将集中、分治、复用、隔离做的更进一步,缺点就是代码量的急剧增大。

fish_redux是基于Redux数据管理的组装式flutter应用框架,不仅仅完成了状态管理的功能,还让我们的代码拥有统一的编码模型和规范,使用fish_redux让flutter项目更加容易分组开发和维护。

撸码开始

flutter版本和fish_redux版本

flutter版本为:1.7.8
fish_redux版本为:0.2.4

开发工具

VSCode
fish-redux-template插件//用于生成部分重复代码

创建项目

创建一个包名为com.lxq.fish_redux_demo且支持AndroidX的flutter项目。

flutter create --org com.lxq.fish_redux_demo --androidx fish_redux_demo

来写主函数

void main() => runApp(createApp());

/**
 * 这里照搬了fish_redux官方demo,使用PageRoutes注册管理整个页面
 * 可以实现全局切换状态,例如:更换主题颜色
 * 本Demo没有全局切换状态,如果想参考,可以查阅fish_redux官方demo。
 */
Widget createApp() {
  final AbstractRoutes routes = PageRoutes(
    pages: <String, Page<Object, dynamic>>{
      /// 注册登陆主页面
      "login": LoginPage(),

      /// 注册用户注册页面
      "register":RegisterPage(),

      /// 注册首页
      "home": HomePage(),
    },
    visitor: (String path, Page<Object, dynamic> page) {
      /// AOP
      /// 页面可以有一些私有的 AOP 的增强, 但往往会有一些 AOP 是整个应用下,所有页面都会有的。
      /// 这些公共的通用 AOP ,通过遍历路由页面的形式统一加入。
      page.enhancer.append(
        /// View AOP
        viewMiddleware: <ViewMiddleware<dynamic>>[
          safetyView<dynamic>(),
        ],

        /// Adapter AOP
        adapterMiddleware: <AdapterMiddleware<dynamic>>[safetyAdapter<dynamic>()],

        /// Effect AOP
        effectMiddleware: [
          _pageAnalyticsMiddleware<dynamic>(),
        ],

        /// Store AOP
        middleware: <Middleware<dynamic>>[
          //这块主要用到middleware的打印功能,监听Action在页面间的调整过程
          logMiddleware<dynamic>(tag: page.runtimeType.toString()), //这块主要用到middleware的打印功能,监听Action在页面间的调整过程
        ],
      );
    },
  );

  return MaterialApp(
    title: 'fish_redux_demo',
    debugShowCheckedModeBanner: false,
    theme: ThemeData(
      primarySwatch: Colors.blue,
    ),
    home: routes.buildPage('login',null),
    onGenerateRoute: (RouteSettings settings) {
      return MaterialPageRoute<Object>(builder: (BuildContext context) {
        return routes.buildPage(settings.name, settings.arguments);
      });
    },
  );
}

/// 简单的 Effect AOP
/// 只针对页面的生命周期进行打印
EffectMiddleware<T> _pageAnalyticsMiddleware<T>({String tag = 'redux'}) {
  return (AbstractLogic<dynamic> logic, Store<T> store) {
    return (Effect<dynamic> effect) {
      return (Action action, Context<dynamic> ctx) {
        if (logic is Page<dynamic, dynamic> && action.type is Lifecycle) {
          print('${logic.runtimeType} ${action.type.toString()} ');
        }
        return effect?.call(action, ctx);
      };
    };
  };
}

LoginPage页面编写

首先通过fish-redux-template生成Page页面模板,会自动生成以下几个Dart文件

  • action

用来定义在这个页面中发生的动作,例如:登录,清理输入框,更换验证码框等。
同时可以通过payload参数传值,传递一些不能通过state传递的值。

  • effect

这个dart文件在fish_redux中是定义来处理副作用操作的,比如显示弹窗,网络请求,数据库查询等操作。

  • page

这个dart文件在用来在路由注册,同时完成注册effect,reducer,component,adapter的功能。

  • reducer

这个dart文件是用来更新View,即直接操作View状态。

  • state

state用来定义页面中的数据,用来保存页面状态和数据。

  • view

view很明显,就是flutter里面当中展示给用户看到的页面。

action

enum LoginAction {
  login,
  clearUsername,
  clearPassword,
}

class LoginActionCreator {
  static Action onLogin() {
    return const Action(LoginAction.login);
  }

  static Action onClearUsername() {
    return const Action(LoginAction.clearUsername);
  }
  static Action onClearPassword() {
    return const Action(LoginAction.clearPassword);
  }
}

effect

Effect<LoginState> buildEffect() {
  return combineEffects(<Object, Effect<LoginState>>{
    LoginAction.login: _onLogin,
  });
}

void _onLogin(Action action, Context<LoginState> ctx) async {
  //这里没有对网络请求层继续封装,可以根据自己的习惯进行封装网络请求,简化这里的代码
  Map<String, dynamic> params = {
    'name': ctx.state.usernameController.text,
    'passwd': ctx.state.passwordController.text,
  };
  Response response = await Dio().post(
    "https://api.apiopen.top/developerLogin",
    queryParameters: params,
  );
  int code = response.data['code'];
  String msg = response.data['message'];
  if (code == 200) {
    Fluttertoast.showToast(msg: '登录成功,跳转HomePage');
    Navigator.of(ctx.context).pushNamed('home', arguments: null);
  } else {
    Fluttertoast.showToast(msg: "登录失败:$msg");
  }
}

page

class LoginPage extends Page<LoginState, Map<String, dynamic>> {
  LoginPage()
      : super(
          initState: initState,
          effect: buildEffect(),
          reducer: buildReducer(),
          view: buildView,
        );
}

reducer

Reducer<LoginState> buildReducer() {
  return asReducer(
    <Object, Reducer<LoginState>>{
      LoginAction.clearUsername: _clearUsername,
      LoginAction.clearPassword: _clearPasswrod,
    },
  );
}

LoginState _clearUsername(LoginState state, Action action) {
  final LoginState newState = state.clone();
  newState.usernameController.clear(); //在这里设置username输入框为空
  return newState;
}

LoginState _clearPasswrod(LoginState state, Action action) {
  final LoginState newState = state.clone();
  newState.passwordController.clear(); //在这里设置清空password输入框为空
  return newState;
}

state

class LoginState implements Cloneable<LoginState> {
  TextEditingController usernameController;
  TextEditingController passwordController;

  @override
  LoginState clone() {
    return LoginState()
      ..passwordController = passwordController
      ..usernameController = usernameController;
  }
}

LoginState initState(Map<String, dynamic> args) {
  LoginState state = new LoginState();
  state.usernameController = new TextEditingController();
  state.passwordController = new TextEditingController();
  return state;
}

view

Widget buildView(LoginState state, Dispatch dispatch, ViewService viewService) {
  return Scaffold(
    appBar: PreferredSize(
      //通过PreferredSize设置appbar的高度
      preferredSize: Size.fromHeight(50.0),
      child: AppBar(
        automaticallyImplyLeading: false,
        //不显示返回键
        centerTitle: true,
        //是否居中,false靠左,true居中
        // brightness: Brightness.dark,//状态栏字体颜色
        elevation: 0,
        //高度
        title: Text('登录'),
      ),
    ),
    body: GestureDetector(
      onTap: () {
        //隐藏输入法
        FocusScope.of(viewService.context).requestFocus(new FocusNode());
      },
      child: Container(
        color: Colors.white,
        padding: EdgeInsets.fromLTRB(15, 50, 15, 10),
        child: ListView(
          children: <Widget>[
            Row(
              children: <Widget>[
                Expanded(
                  child: TextField(
                    controller: state.usernameController,
                    decoration: InputDecoration(
                      hintText: '请输入账号',
                    ),
                  ),
                ),
                IconButton(
                  icon: Icon(Icons.clear),
                  onPressed: () {
                    dispatch(LoginActionCreator.onClearUsername());
                  },
                ),
              ],
            ),
            Row(
              children: <Widget>[
                Expanded(
                  child: TextField(
                    controller: state.passwordController,
                    obscureText: true, //设置显示密码
                    decoration: InputDecoration(
                      hintText: '请输入密码',
                    ),
                  ),
                ),
                IconButton(
                  icon: Icon(Icons.clear),
                  onPressed: () {
                    dispatch(LoginActionCreator.onClearPassword());
                  },
                ),
              ],
            ),
            RaisedButton(
              color: Colors.blue,
              child: Text(
                '登录',
                style: TextStyle(
                  color: Colors.white,
                ),
              ),
              onPressed: () {
                if (state.usernameController.text.isNotEmpty) {
                  if (state.passwordController.text.isNotEmpty) {
                    //用户名和密码都不为空的时候进行登录操作
                    dispatch(LoginActionCreator.onLogin());
                  } else {
                    Fluttertoast.showToast(msg: '密码不能为空');
                  }
                } else {
                  Fluttertoast.showToast(msg: '账户不能为空');
                }
              },
            ),
            RaisedButton(
              child: Text('注册'),
              onPressed: (){
                Navigator.of(viewService.context).pushNamed('register', arguments: null);
              },
            ),
            Text('OpenApi地址:'),
            Text('https://www.apiopen.top/api.html'),
            Text('登录接口地址:'),
            Text('https://api.apiopen.top/developerLogin'),
            Text('测试账号:1'),
            Text('测试账号密码:1'),
          ],
        ),
      ),
    ),
  );
}

疑问解答

1.所有Widget全部改用StatelessWidget?

答:这个得需要看情况,但是虽然使用fish_redux可以很好的管理状态,但是很明显的问题也存在,就是代码量增大的不是一点点,而是好多好多,所以有的时候要根据实际情况,满足要求即可,另外fish_redux的view分为了page和component,正常情况下,你根本看不到StatelessWidget。

2.非fish_redux页面,该怎么与fish_redux页面通讯、跳转?

答:这个点是跟正常跳转是一样一样的,没有什么特殊的。

3.使用fish_redux是否需要一次性全部改造页面

答:没有必要,fish_redux不需要一次性全部改造,可以慢慢改造页面,同时页面之间跳转、传值都不受fish_redux影响。

4.Action类找不到

答:fish_redux命名和官方有冲突了,这种情况下需要对官方的命名进行隐藏,示例如下:

import 'package:flutter/material.dart' hide Action;

代码地址:

https://github.com/wjbd/fish_redux_demo

非常感谢

推荐阅读更多精彩内容