前言 之前在学会 React-Native 后写了一个 cnodejs社区的客户端 CNodeRN ,前阵子了解了下 flutter, 感觉是移动应用开发的未来趋势,便有了迁移至 flutter 技术栈的想法, 然后就有了 CNoder 这个项目, 也算是对数周 flutter 的一个学习实践吧
安装和初始化 跟着官方的安装说明 一步一步往下走,还是挺顺利的,唯一不同的就是增加了镜像设置这一步, 打开 ~/.zhsrc
, 末尾增加
1 2 3 4 125 export PUB_HOSTED_URL=https://pub.flutter-io.cn 126 export FLUTTER_STORAGE_BASE_URL=https://storage.flutter-io.cn 127 export PATH=$HOME /flutter/bin:$PATH
然后执行 flutter doctor
检查环境是否正常,一切顺利的话就可以初始化项目了,我使用的编辑器是 vscode
, 通过命令窗口运行命令 Flutter: New Project
即可
项目目录结构 源码都位于 lib
目录下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |-- config/ |-- api.dart // http api 调用接口地址配置 |-- common/ |-- helper.dart // 工具函数 |-- route/ |-- handler.dart // 路由配置文件 |-- store/ |-- action/ // redux action 目录 |-- epic/ // redux_epic 配置目录 |-- reducer/ // redux reducer 目录 |-- model/ // 模型目录 |-- view_model/ // store 映射模型目录 |-- root_state.dart // 全局 state |-- index.dart // store 初始入口 |-- container/ // 连接 store 的容器目录 |-- widget/ // 视图 widget 目录 main.dart // 入口文件 app.dart // 入口widget
功能模块
入口文件: main.dart, 逻辑很简单就不描述了
入口widget: app.dart文件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 class App extends StatelessWidget { final Router router = new Router(); App() { persistor.load(store); router.notFoundHandler = notFoundHandler; handlers.forEach((String path,Handler handler) { router.define(path, handler: handler); }); } @override Widget build(BuildContext context) { final app = new MaterialApp( title: 'CNoder' , debugShowCheckedModeBanner: false , theme: new ThemeData( primarySwatch: Colors.lightGreen, iconTheme: new IconThemeData( color: Color(0xFF666666 ) ), textTheme: new TextTheme( body1: new TextStyle(color: Color(0xFF333333 ), fontSize: 14.0 ) ) ), onGenerateRoute: router.generator ); return new StoreProvider<RootState>(store: store, child: app); } }
这里有个坑,如果按照 fluro 提供的文档将应用路由映射至fluro的路由表,使用的方式是 onGenerateRoute: router.generator
, 但是这样的话在路由跳转时就无法指定过渡动效了,因此需要改成这样
1 2 3 4 5 onGenerateRoute: (RouteSettings routeSettings) { RouteMatch match = this .router.matchRoute(null , routeSettings.name, routeSettings: routeSettings, transitionType: TransitionType.inFromRight); return match.route; },
使用 StoreProvider 容器包裹整个应用入口widget,这样才能在子节点的widget上使用StoreConnector连接store来获取数据状态和派发action
1 2 3 4 5 6 7 8 9 10 11 12 import "dart:core" ;import "package:fluro/fluro.dart" ;import "package:flutter/material.dart" ;import "package:cnoder/container/index.dart" ;Map <String , Handler> handlers = { '/' : new Handler( handlerFunc: (BuildContext context, Map <String , dynamic > params) { return new IndexContainer(); }), ... };
container/index.dart
类似于 react 里面的 HOC,将 store 连接至子widget
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 import "package:flutter/material.dart" ;import "package:redux/redux.dart" ;import "package:flutter_redux/flutter_redux.dart" ;import "../store/root_state.dart" ;import "../store/view_model/index.dart" ;import "../widget/index.dart" ;class IndexContainer extends StatelessWidget { @override Widget build(BuildContext context) { return new StoreConnector<RootState, IndexViewModel>( converter: (Store<RootState> store) => IndexViewModel.fromStore(store), builder: (BuildContext context, IndexViewModel vm) { return new IndexScene(vm: vm); }, ); } }
converter 参数相当于在使用 react+redux 技术栈里面的使用 connect 函数包裹组件时的 mapAction 和 mapState 参数,将返回值作为 builder 参数对应的回调函数第二个入参 vm.
widget/index.dart
为首页的视图widget,通过底部的标签栏切换四个容器widget的显示
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 class IndexState extends State <IndexScene > { List _renderScenes(bool isLogined) { final bool isLogined = widget.vm.auth["isLogined" ]; return <Widget>[ new TopicsContainer(vm: widget.vm), isLogined ? new CollectContainer(vm: widget.vm) : new LoginScene(), isLogined ? new MessageContainer(vm: widget.vm,) : new LoginScene(), isLogined ? new MeContainer(vm: widget.vm,) : new LoginScene() ]; } @override Widget build(BuildContext context) { final bool isLogined = widget.vm.auth["isLogined" ]; final List scenes = _renderScenes(isLogined); final int tabIndex = widget.vm.tabIndex; final Function setTab = widget.vm.selectTab; final currentScene = scenes[0 ]; if (currentScene is InitializeContainer) { if (currentScene.getInitialized() == false ) { currentScene.initialize(); currentScene.setInitialized(); } } return new Scaffold( bottomNavigationBar: new CupertinoTabBar( activeColor: Colors.green, backgroundColor: const Color(0xFFF7F7F7 ), currentIndex: tabIndex, onTap: (int i) { final currentScene = scenes[i]; if (isLogined) { if (currentScene is InitializeContainer) { if (currentScene.getInitialized() == false ) { currentScene.initialize(); currentScene.setInitialized(); } } } setTab(i); }, items: <BottomNavigationBarItem>[ new BottomNavigationBarItem( icon: new Icon(Icons.home), title: new Text('主题' ), ), new BottomNavigationBarItem( icon: new Icon(Icons.favorite), title: new Text('收藏' ) ), new BottomNavigationBarItem( icon: new Icon(Icons.message), title: new Text('消息' ) ), new BottomNavigationBarItem( icon: new Icon(Icons.person), title: new Text('我的' ) ) ], ), body: new IndexedStack( children: scenes, index: tabIndex, ) ); } }
很多同学会有疑问,tabIndex 这个应该只是首页widget的内部数据状态,为何要放到 redux 里去维护?因为我们在子widget里面会去切换页签的选中状态,比如登陆完成以后切换至’我的’这个页签
主题视图容器widget,在容器组件里面触发服务调用获取主题数据
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 bool initialized = false ;class TopicsContainer extends StatelessWidget implements InitializeContainer { final IndexViewModel vm; TopicsContainer({Key key, @required this .vm}):super (key: key); void setInitialized() { initialized = true ; } bool getInitialized() { return initialized; } void initialize() { vm.fetchTopics(); } @override Widget build(BuildContext context) { return new StoreConnector<RootState, TopicsViewModel>( converter: (Store<RootState> store) => TopicsViewModel.fromStore(store), builder: (BuildContext context, TopicsViewModel vm) { return new TopicsScene(vm: vm); }, ); } }
主题视图widget,顶部四个页签用来切换显示四个主题分类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 class TopicsState extends State <TopicsScene > with TickerProviderStateMixin { @override void initState() { super .initState(); final topicsOfCategory = widget.vm.topicsOfCategory; _tabs = <Tab>[]; topicsOfCategory.forEach((k, v) { _tabs.add(new Tab( text: v["label" ] )); }); _tabController = new TabController( length: _tabs.length, vsync: this ); _onTabChange = () { ... }; _tabController.addListener(_onTabChange); } @override void dispose() { super .dispose(); _tabController.removeListener(_onTabChange); _tabController.dispose(); } @override Widget build(BuildContext context) { bool isLoading = widget.vm.isLoading; Map topicsOfCategory = widget.vm.topicsOfCategory; FetchTopics fetchTopics = widget.vm.fetchTopics; ResetTopics resetTopics = widget.vm.resetTopics; ... List <Widget> _renderTabView() { final _tabViews = <Widget>[]; topicsOfCategory.forEach((k, category) { bool isFetched = topicsOfCategory[k]["isFetched" ]; _tabViews.add(!isFetched ? _renderLoading(context) : new SmartRefresher( enablePullDown: true , enablePullUp: true , onRefresh: _onRefresh(k), controller: _controller, child: new ListView.builder( physics: const NeverScrollableScrollPhysics(), shrinkWrap: true , itemCount: topicsOfCategory[k]["list" ].length, itemBuilder: (BuildContext context, int i) => _renderRow(context, topicsOfCategory[k]["list" ][i]), ), )); }); return _tabViews; } Widget _renderRow(BuildContext context, Topic topic) { ListTile title = new ListTile( leading: new SizedBox( width: 30.0 , height: 30.0 , child: new CachedNetworkImage( imageUrl: topic.authorAvatar.startsWith('//' ) ? 'http:${topic.authorAvatar} ' : topic.authorAvatar, placeholder: new Image.asset('asset/image/cnoder_avatar.png' ), errorWidget: new Icon(Icons.error), ) ), title: new Text(topic.authorName), subtitle: new Row( children: <Widget>[ new Text(topic.lastReplyAt) ], ), trailing: new Text('${topic.replyCount} /${topic.visitCount} ' ), ); return new InkWell( onTap: () => Navigator.of(context).pushNamed('/topic/${topic.id} ' ), child: new Column( children: <Widget>[ title, new Container( padding: const EdgeInsets.all(10.0 ), alignment: Alignment.centerLeft, child: new Text(topic.title), ) ], ), ); } return new Scaffold( appBar: new AppBar( brightness: Brightness.dark, elevation: 0.0 , titleSpacing: 0.0 , bottom: null , title: new Align( alignment: Alignment.bottomCenter, child: new TabBar( labelColor: Colors.white, tabs: _tabs, controller: _tabController, ) ) ), body: new TabBarView( controller: _tabController, children: _renderTabView(), ) ); } }
数据状态
store/view_model/topics.dart
视图映射模型定义
通过视图映射模型将 store 里面的 state 和 action 传递给视图widget, 在上面的主题容器widget里面我们通过 vm.fetchTopics
方法获取主题数据, 这个方法是在 TopicsViewModel 这个 store 映射模型里定义的
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 class TopicsViewModel { final Map topicsOfCategory; final bool isLoading; final FetchTopics fetchTopics; final ResetTopics resetTopics; TopicsViewModel({ @required this .topicsOfCategory, @required this .isLoading, @required this .fetchTopics, @required this .resetTopics }); static TopicsViewModel fromStore(Store<RootState> store) { return new TopicsViewModel( topicsOfCategory: store.state.topicsOfCategory, isLoading: store.state.isLoading, fetchTopics: ({int currentPage = 1 , String category = '' , Function afterFetched = _noop}) { store.dispatch(new ToggleLoading(true )); store.dispatch(new RequestTopics(currentPage: currentPage, category: category, afterFetched: afterFetched)); }, resetTopics: ({@required String category, @required Function afterFetched}) { store.dispatch(new RequestTopics(currentPage: 1 , category: category, afterFetched: afterFetched)); } ); } }
这里增加了一个调用成功的回调函数给 action,是因为需要在 http 服务调用完成以后控制主题视图widget里面 SmartRefresher 这个widget 状态的切换(重置加载指示等等)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 final _onRefresh = (String category) { return (bool up) { if (!up) { if (isLoading) { _controller.sendBack(false , RefreshStatus.idle); return ; } fetchTopics( currentPage: topicsOfCategory[category]["currentPage" ] + 1 , category: category, afterFetched: () { _controller.sendBack(false , RefreshStatus.idle); } ); } else { resetTopics( category: category, afterFetched: () { _controller.sendBack(true , RefreshStatus.completed); } ); } }; };
store/action/topic.dart
action 定义
在 flutter 中以类的方式来定义 action 的,这一点与我们在 react 中使用 redux 有点不同
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 class RequestTopics { final int currentPage; final String category; final VoidCallback afterFetched; RequestTopics({this .currentPage = 1 , this .category = "" , @required this .afterFetched}); } class ResponseTopics { final List <Topic> topics; final int currentPage; final String category; ResponseTopics(this .currentPage, this .category, this .topics); ResponseTopics.failed() : this (1 , "" , []); }
epic 定义,redux epic 可以看成是 action 的一个调度器,虽然 flutter 里的redux 也有 redux_thunk 中间件,但是 epic 这种基于流的调度中间件使得业务逻辑更加优雅
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 Stream<dynamic > fetchTopicsEpic( Stream<dynamic > actions, EpicStore<RootState> store) { return new Observable(actions) .ofType(new TypeToken<RequestTopics>()) .flatMap((action) { return new Observable(() async * { try { final ret = await http.get ("${apis['topics' ]} ?page=${action.currentPage} &limit=6&tab=${action.category} &mdrender=false" ); Map <String , dynamic > result = json.decode(ret.body); List <Topic> topics = []; result['data' ].forEach((v) { topics.add(new Topic.fromJson(v)); }); action.afterFetched(); yield new ResponseTopics(action.currentPage, action.category, topics); } catch (err) { print (err); yield new ResponseTopicsFailed(err); } yield new ToggleLoading(false ); } ()); }); }
在接收到请求响应后,通过 Topic.fromJson
这个指定类构造器来创建主题列表,这个方法定义在 store/model/topic.dart
里面
1 2 3 4 5 6 7 8 9 10 11 12 13 14 Topic.fromJson(final Map map): this .id = map["id" ], this .authorName = map["author" ]["loginname" ], this .authorAvatar = map["author" ]["avatar_url" ], this .title = map["title" ], this .tag = map["tab" ], this .content = map["content" ], this .createdAt = fromNow(map["create_at" ]), this .lastReplyAt = fromNow(map["last_reply_at" ]), this .replyCount = map["reply_count" ], this .visitCount = map["visit_count" ], this .top = map["top" ], this .isCollect = map["is_collect" ], this .replies = formatedReplies(map['replies' ]);
store/reducer/topic.dart
, 通过主题列表的 reducer 来变更 store 里面的数据状态
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 final Reducer<Map > topicsReducer = combineReducers([ new TypedReducer<Map , ClearTopic>(_clearTopic), new TypedReducer<Map , RequestTopics>(_requestTopics), new TypedReducer<Map , ResponseTopics>(_responseTopics) ]); Map _clearTopic(Map state, ClearTopic action) { return {}; } Map _requestTopics(Map state, RequestTopics action) { Map topicsOfTopics = {}; state.forEach((k, v) { final _v = new Map .from(v); if (action.category == k) { _v["isFetched" ] = false ; } topicsOfTopics[k] = _v; }); return topicsOfTopics; } Map _responseTopics(Map state, ResponseTopics action) { Map topicsOfCategory = {}; state.forEach((k, v) { Map _v = {}; _v.addAll(v); if (k == action.category) { List _list = []; if (_v['currentPage' ] < action.currentPage) { _list.addAll(_v["list" ]); _list.addAll(action.topics); } if (action.currentPage == 1 ) { _list.addAll(action.topics); } _v["isFetched" ] = true ; _v["list" ] = _list; _v["currentPage" ] = action.currentPage; } topicsOfCategory[k] = _v; }); return topicsOfCategory; }
然后在 store/reducer/root.dart
的 rootReducer 里进行合并
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 RootState rootReducer(RootState state, action) { if (action is PersistLoadedAction<RootState>) { return action.state ?? state; } return new RootState( tabIndex: tabReducer(state.tabIndex, action), auth: loginReducer(state.auth, action), isLoading: loadingReducer(state.isLoading, action), topicsOfCategory: topicsReducer(state.topicsOfCategory, action), topic: topicReducer(state.topic, action), me: meReducer(state.me, action), collects: collectsReducer(state.collects, action), messages: messagesReducer(state.messages, action) ); }
store/index.dart
store 的初始化入口,在我们上面的入口widget里面使用 StoreProvider
容器包裹的时候传递
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 final epic = combineEpics([ doLoginEpic, fetchTopicsEpic, fetchTopicEpic, fetchMeEpic, fetchCollectsEpic, fetchMessagesEpic, fetchMessageCountEpic, markAllAsReadEpic, markAsReadEpic, createReplyEpic, saveTopicEpic, createTopicEpic, toggleCollectEpic, likeReplyEpic, ]); final persistor = Persistor<RootState>( storage: FlutterStorage('cnoder' ), decoder: RootState.fromJson, debug: true ); final store = new Store<RootState>(rootReducer, initialState: new RootState(), middleware: [ new LoggingMiddleware.printer(), new EpicMiddleware(epic), persistor.createMiddleware() ]);
这里有个小坑,持久化存储中间件 redux_persist 的文档上加载中间件的方式为
1 2 3 4 5 var store = new Store<AppState>( reducer, initialState: new AppState(), middleware: [persistor.createMiddleware()], );
但是这样处理的话,在每个业务 action 触发的时候,都会触发持久化的操作,而这在很多场景下是不必要的,比如在我们的应用中只需要保存的用户身份令牌,所以只需要在触发登陆和登出 action 的时候执行持久化的操作,因此加载中间件的方式需要做如下改动
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 void persistMiddleware(Store store, dynamic action, NextDispatcher next) { next(action); if (action is FinishLogin || action is Logout) { try { persistor.save(store); } catch (_) {} } } final store = new Store<RootState>(rootReducer, initialState: new RootState(), middleware: [ new LoggingMiddleware.printer(), new EpicMiddleware(epic), persistMiddleware ]);
更多 应用的视图层和数据状态处理还是跟使用 React-Native 开发中使用 redux 技术栈的方式差不多,虽然整体目录结构有点繁琐,但是业务逻辑清晰明了,在后续功能扩展和维护的时候还是带来不少的方便,唯一遗憾的是因为 flutter 系统架构的问题,还没有一个针对 flutter 的 redux devtools,这一点还是蛮影响开发效率的
完整的项目源码请关注github仓库: cnoder ,欢迎 star 和 PR,对 flutter 理解的不深,还望各位对本文中的不足之处批评指正
前言 之前在学会 React-Native 后写了一个 cnodejs社区的客户端 CNodeRN ,前阵子了解了下 flutter, 感觉是移动应用开发的未来趋势,便有了迁移至 flutter 技术栈的想法, 然后就有了 CNoder 这个项目, 也算是对数周 flutter 的一个学习实践吧
安装和初始化 跟着官方的安装说明 一步一步往下走,还是挺顺利的,唯一不同的就是增加了镜像设置这一步, 打开 ~/.zhsrc
, 末尾增加
1 2 3 4 125 export PUB_HOSTED_URL=https://pub.flutter-io.cn 126 export FLUTTER_STORAGE_BASE_URL=https://storage.flutter-io.cn 127 export PATH=$HOME /flutter/bin:$PATH
然后执行 flutter doctor
检查环境是否正常,一切顺利的话就可以初始化项目了,我使用的编辑器是 vscode
, 通过命令窗口运行命令 Flutter: New Project
即可
项目目录结构 源码都位于 lib
目录下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |-- config/ |-- api.dart // http api 调用接口地址配置 |-- common/ |-- helper.dart // 工具函数 |-- route/ |-- handler.dart // 路由配置文件 |-- store/ |-- action/ // redux action 目录 |-- epic/ // redux_epic 配置目录 |-- reducer/ // redux reducer 目录 |-- model/ // 模型目录 |-- view_model/ // store 映射模型目录 |-- root_state.dart // 全局 state |-- index.dart // store 初始入口 |-- container/ // 连接 store 的容器目录 |-- widget/ // 视图 widget 目录 main.dart // 入口文件 app.dart // 入口widget
功能模块
入口文件: main.dart, 逻辑很简单就不描述了
入口widget: app.dart文件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 class App extends StatelessWidget { final Router router = new Router(); App() { persistor.load(store); router.notFoundHandler = notFoundHandler; handlers.forEach((String path,Handler handler) { router.define(path, handler: handler); }); } @override Widget build(BuildContext context) { final app = new MaterialApp( title: 'CNoder' , debugShowCheckedModeBanner: false , theme: new ThemeData( primarySwatch: Colors.lightGreen, iconTheme: new IconThemeData( color: Color(0xFF666666 ) ), textTheme: new TextTheme( body1: new TextStyle(color: Color(0xFF333333 ), fontSize: 14.0 ) ) ), onGenerateRoute: router.generator ); return new StoreProvider<RootState>(store: store, child: app); } }
这里有个坑,如果按照 fluro 提供的文档将应用路由映射至fluro的路由表,使用的方式是 onGenerateRoute: router.generator
, 但是这样的话在路由跳转时就无法指定过渡动效了,因此需要改成这样
1 2 3 4 5 onGenerateRoute: (RouteSettings routeSettings) { RouteMatch match = this .router.matchRoute(null , routeSettings.name, routeSettings: routeSettings, transitionType: TransitionType.inFromRight); return match.route; },
使用 StoreProvider 容器包裹整个应用入口widget,这样才能在子节点的widget上使用StoreConnector连接store来获取数据状态和派发action
1 2 3 4 5 6 7 8 9 10 11 12 import "dart:core" ;import "package:fluro/fluro.dart" ;import "package:flutter/material.dart" ;import "package:cnoder/container/index.dart" ;Map <String , Handler> handlers = { '/' : new Handler( handlerFunc: (BuildContext context, Map <String , dynamic > params) { return new IndexContainer(); }), ... };
container/index.dart
类似于 react 里面的 HOC,将 store 连接至子widget
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 import "package:flutter/material.dart" ;import "package:redux/redux.dart" ;import "package:flutter_redux/flutter_redux.dart" ;import "../store/root_state.dart" ;import "../store/view_model/index.dart" ;import "../widget/index.dart" ;class IndexContainer extends StatelessWidget { @override Widget build(BuildContext context) { return new StoreConnector<RootState, IndexViewModel>( converter: (Store<RootState> store) => IndexViewModel.fromStore(store), builder: (BuildContext context, IndexViewModel vm) { return new IndexScene(vm: vm); }, ); } }
converter 参数相当于在使用 react+redux 技术栈里面的使用 connect 函数包裹组件时的 mapAction 和 mapState 参数,将返回值作为 builder 参数对应的回调函数第二个入参 vm.
widget/index.dart
为首页的视图widget,通过底部的标签栏切换四个容器widget的显示
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 class IndexState extends State <IndexScene > { List _renderScenes(bool isLogined) { final bool isLogined = widget.vm.auth["isLogined" ]; return <Widget>[ new TopicsContainer(vm: widget.vm), isLogined ? new CollectContainer(vm: widget.vm) : new LoginScene(), isLogined ? new MessageContainer(vm: widget.vm,) : new LoginScene(), isLogined ? new MeContainer(vm: widget.vm,) : new LoginScene() ]; } @override Widget build(BuildContext context) { final bool isLogined = widget.vm.auth["isLogined" ]; final List scenes = _renderScenes(isLogined); final int tabIndex = widget.vm.tabIndex; final Function setTab = widget.vm.selectTab; final currentScene = scenes[0 ]; if (currentScene is InitializeContainer) { if (currentScene.getInitialized() == false ) { currentScene.initialize(); currentScene.setInitialized(); } } return new Scaffold( bottomNavigationBar: new CupertinoTabBar( activeColor: Colors.green, backgroundColor: const Color(0xFFF7F7F7 ), currentIndex: tabIndex, onTap: (int i) { final currentScene = scenes[i]; if (isLogined) { if (currentScene is InitializeContainer) { if (currentScene.getInitialized() == false ) { currentScene.initialize(); currentScene.setInitialized(); } } } setTab(i); }, items: <BottomNavigationBarItem>[ new BottomNavigationBarItem( icon: new Icon(Icons.home), title: new Text('主题' ), ), new BottomNavigationBarItem( icon: new Icon(Icons.favorite), title: new Text('收藏' ) ), new BottomNavigationBarItem( icon: new Icon(Icons.message), title: new Text('消息' ) ), new BottomNavigationBarItem( icon: new Icon(Icons.person), title: new Text('我的' ) ) ], ), body: new IndexedStack( children: scenes, index: tabIndex, ) ); } }
很多同学会有疑问,tabIndex 这个应该只是首页widget的内部数据状态,为何要放到 redux 里去维护?因为我们在子widget里面会去切换页签的选中状态,比如登陆完成以后切换至’我的’这个页签
主题视图容器widget,在容器组件里面触发服务调用获取主题数据
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 bool initialized = false ;class TopicsContainer extends StatelessWidget implements InitializeContainer { final IndexViewModel vm; TopicsContainer({Key key, @required this .vm}):super (key: key); void setInitialized() { initialized = true ; } bool getInitialized() { return initialized; } void initialize() { vm.fetchTopics(); } @override Widget build(BuildContext context) { return new StoreConnector<RootState, TopicsViewModel>( converter: (Store<RootState> store) => TopicsViewModel.fromStore(store), builder: (BuildContext context, TopicsViewModel vm) { return new TopicsScene(vm: vm); }, ); } }
主题视图widget,顶部四个页签用来切换显示四个主题分类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 class TopicsState extends State <TopicsScene > with TickerProviderStateMixin { @override void initState() { super .initState(); final topicsOfCategory = widget.vm.topicsOfCategory; _tabs = <Tab>[]; topicsOfCategory.forEach((k, v) { _tabs.add(new Tab( text: v["label" ] )); }); _tabController = new TabController( length: _tabs.length, vsync: this ); _onTabChange = () { ... }; _tabController.addListener(_onTabChange); } @override void dispose() { super .dispose(); _tabController.removeListener(_onTabChange); _tabController.dispose(); } @override Widget build(BuildContext context) { bool isLoading = widget.vm.isLoading; Map topicsOfCategory = widget.vm.topicsOfCategory; FetchTopics fetchTopics = widget.vm.fetchTopics; ResetTopics resetTopics = widget.vm.resetTopics; ... List <Widget> _renderTabView() { final _tabViews = <Widget>[]; topicsOfCategory.forEach((k, category) { bool isFetched = topicsOfCategory[k]["isFetched" ]; _tabViews.add(!isFetched ? _renderLoading(context) : new SmartRefresher( enablePullDown: true , enablePullUp: true , onRefresh: _onRefresh(k), controller: _controller, child: new ListView.builder( physics: const NeverScrollableScrollPhysics(), shrinkWrap: true , itemCount: topicsOfCategory[k]["list" ].length, itemBuilder: (BuildContext context, int i) => _renderRow(context, topicsOfCategory[k]["list" ][i]), ), )); }); return _tabViews; } Widget _renderRow(BuildContext context, Topic topic) { ListTile title = new ListTile( leading: new SizedBox( width: 30.0 , height: 30.0 , child: new CachedNetworkImage( imageUrl: topic.authorAvatar.startsWith('//' ) ? 'http:${topic.authorAvatar} ' : topic.authorAvatar, placeholder: new Image.asset('asset/image/cnoder_avatar.png' ), errorWidget: new Icon(Icons.error), ) ), title: new Text(topic.authorName), subtitle: new Row( children: <Widget>[ new Text(topic.lastReplyAt) ], ), trailing: new Text('${topic.replyCount} /${topic.visitCount} ' ), ); return new InkWell( onTap: () => Navigator.of(context).pushNamed('/topic/${topic.id} ' ), child: new Column( children: <Widget>[ title, new Container( padding: const EdgeInsets.all(10.0 ), alignment: Alignment.centerLeft, child: new Text(topic.title), ) ], ), ); } return new Scaffold( appBar: new AppBar( brightness: Brightness.dark, elevation: 0.0 , titleSpacing: 0.0 , bottom: null , title: new Align( alignment: Alignment.bottomCenter, child: new TabBar( labelColor: Colors.white, tabs: _tabs, controller: _tabController, ) ) ), body: new TabBarView( controller: _tabController, children: _renderTabView(), ) ); } }
数据状态
store/view_model/topics.dart
视图映射模型定义
通过视图映射模型将 store 里面的 state 和 action 传递给视图widget, 在上面的主题容器widget里面我们通过 vm.fetchTopics
方法获取主题数据, 这个方法是在 TopicsViewModel 这个 store 映射模型里定义的
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 class TopicsViewModel { final Map topicsOfCategory; final bool isLoading; final FetchTopics fetchTopics; final ResetTopics resetTopics; TopicsViewModel({ @required this .topicsOfCategory, @required this .isLoading, @required this .fetchTopics, @required this .resetTopics }); static TopicsViewModel fromStore(Store<RootState> store) { return new TopicsViewModel( topicsOfCategory: store.state.topicsOfCategory, isLoading: store.state.isLoading, fetchTopics: ({int currentPage = 1 , String category = '' , Function afterFetched = _noop}) { store.dispatch(new ToggleLoading(true )); store.dispatch(new RequestTopics(currentPage: currentPage, category: category, afterFetched: afterFetched)); }, resetTopics: ({@required String category, @required Function afterFetched}) { store.dispatch(new RequestTopics(currentPage: 1 , category: category, afterFetched: afterFetched)); } ); } }
这里增加了一个调用成功的回调函数给 action,是因为需要在 http 服务调用完成以后控制主题视图widget里面 SmartRefresher 这个widget 状态的切换(重置加载指示等等)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 final _onRefresh = (String category) { return (bool up) { if (!up) { if (isLoading) { _controller.sendBack(false , RefreshStatus.idle); return ; } fetchTopics( currentPage: topicsOfCategory[category]["currentPage" ] + 1 , category: category, afterFetched: () { _controller.sendBack(false , RefreshStatus.idle); } ); } else { resetTopics( category: category, afterFetched: () { _controller.sendBack(true , RefreshStatus.completed); } ); } }; };
store/action/topic.dart
action 定义
在 flutter 中以类的方式来定义 action 的,这一点与我们在 react 中使用 redux 有点不同
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 class RequestTopics { final int currentPage; final String category; final VoidCallback afterFetched; RequestTopics({this .currentPage = 1 , this .category = "" , @required this .afterFetched}); } class ResponseTopics { final List <Topic> topics; final int currentPage; final String category; ResponseTopics(this .currentPage, this .category, this .topics); ResponseTopics.failed() : this (1 , "" , []); }
epic 定义,redux epic 可以看成是 action 的一个调度器,虽然 flutter 里的redux 也有 redux_thunk 中间件,但是 epic 这种基于流的调度中间件使得业务逻辑更加优雅
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 Stream<dynamic > fetchTopicsEpic( Stream<dynamic > actions, EpicStore<RootState> store) { return new Observable(actions) .ofType(new TypeToken<RequestTopics>()) .flatMap((action) { return new Observable(() async * { try { final ret = await http.get ("${apis['topics' ]} ?page=${action.currentPage} &limit=6&tab=${action.category} &mdrender=false" ); Map <String , dynamic > result = json.decode(ret.body); List <Topic> topics = []; result['data' ].forEach((v) { topics.add(new Topic.fromJson(v)); }); action.afterFetched(); yield new ResponseTopics(action.currentPage, action.category, topics); } catch (err) { print (err); yield new ResponseTopicsFailed(err); } yield new ToggleLoading(false ); } ()); }); }
在接收到请求响应后,通过 Topic.fromJson
这个指定类构造器来创建主题列表,这个方法定义在 store/model/topic.dart
里面
1 2 3 4 5 6 7 8 9 10 11 12 13 14 Topic.fromJson(final Map map): this .id = map["id" ], this .authorName = map["author" ]["loginname" ], this .authorAvatar = map["author" ]["avatar_url" ], this .title = map["title" ], this .tag = map["tab" ], this .content = map["content" ], this .createdAt = fromNow(map["create_at" ]), this .lastReplyAt = fromNow(map["last_reply_at" ]), this .replyCount = map["reply_count" ], this .visitCount = map["visit_count" ], this .top = map["top" ], this .isCollect = map["is_collect" ], this .replies = formatedReplies(map['replies' ]);
store/reducer/topic.dart
, 通过主题列表的 reducer 来变更 store 里面的数据状态
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 final Reducer<Map > topicsReducer = combineReducers([ new TypedReducer<Map , ClearTopic>(_clearTopic), new TypedReducer<Map , RequestTopics>(_requestTopics), new TypedReducer<Map , ResponseTopics>(_responseTopics) ]); Map _clearTopic(Map state, ClearTopic action) { return {}; } Map _requestTopics(Map state, RequestTopics action) { Map topicsOfTopics = {}; state.forEach((k, v) { final _v = new Map .from(v); if (action.category == k) { _v["isFetched" ] = false ; } topicsOfTopics[k] = _v; }); return topicsOfTopics; } Map _responseTopics(Map state, ResponseTopics action) { Map topicsOfCategory = {}; state.forEach((k, v) { Map _v = {}; _v.addAll(v); if (k == action.category) { List _list = []; if (_v['currentPage' ] < action.currentPage) { _list.addAll(_v["list" ]); _list.addAll(action.topics); } if (action.currentPage == 1 ) { _list.addAll(action.topics); } _v["isFetched" ] = true ; _v["list" ] = _list; _v["currentPage" ] = action.currentPage; } topicsOfCategory[k] = _v; }); return topicsOfCategory; }
然后在 store/reducer/root.dart
的 rootReducer 里进行合并
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 RootState rootReducer(RootState state, action) { if (action is PersistLoadedAction<RootState>) { return action.state ?? state; } return new RootState( tabIndex: tabReducer(state.tabIndex, action), auth: loginReducer(state.auth, action), isLoading: loadingReducer(state.isLoading, action), topicsOfCategory: topicsReducer(state.topicsOfCategory, action), topic: topicReducer(state.topic, action), me: meReducer(state.me, action), collects: collectsReducer(state.collects, action), messages: messagesReducer(state.messages, action) ); }
store/index.dart
store 的初始化入口,在我们上面的入口widget里面使用 StoreProvider
容器包裹的时候传递
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 final epic = combineEpics([ doLoginEpic, fetchTopicsEpic, fetchTopicEpic, fetchMeEpic, fetchCollectsEpic, fetchMessagesEpic, fetchMessageCountEpic, markAllAsReadEpic, markAsReadEpic, createReplyEpic, saveTopicEpic, createTopicEpic, toggleCollectEpic, likeReplyEpic, ]); final persistor = Persistor<RootState>( storage: FlutterStorage('cnoder' ), decoder: RootState.fromJson, debug: true ); final store = new Store<RootState>(rootReducer, initialState: new RootState(), middleware: [ new LoggingMiddleware.printer(), new EpicMiddleware(epic), persistor.createMiddleware() ]);
这里有个小坑,持久化存储中间件 redux_persist 的文档上加载中间件的方式为
1 2 3 4 5 var store = new Store<AppState>( reducer, initialState: new AppState(), middleware: [persistor.createMiddleware()], );
但是这样处理的话,在每个业务 action 触发的时候,都会触发持久化的操作,而这在很多场景下是不必要的,比如在我们的应用中只需要保存的用户身份令牌,所以只需要在触发登陆和登出 action 的时候执行持久化的操作,因此加载中间件的方式需要做如下改动
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 void persistMiddleware(Store store, dynamic action, NextDispatcher next) { next(action); if (action is FinishLogin || action is Logout) { try { persistor.save(store); } catch (_) {} } } final store = new Store<RootState>(rootReducer, initialState: new RootState(), middleware: [ new LoggingMiddleware.printer(), new EpicMiddleware(epic), persistMiddleware ]);
更多 应用的视图层和数据状态处理还是跟使用 React-Native 开发中使用 redux 技术栈的方式差不多,虽然整体目录结构有点繁琐,但是业务逻辑清晰明了,在后续功能扩展和维护的时候还是带来不少的方便,唯一遗憾的是因为 flutter 系统架构的问题,还没有一个针对 flutter 的 redux devtools,这一点还是蛮影响开发效率的
完整的项目源码请关注github仓库: cnoder ,欢迎 star 和 PR,对 flutter 理解的不深,还望各位对本文中的不足之处批评指正