以前在学会 React-Native 后写了一个 cnodejs社区的客户端 CNodeRN,前阵子了解了下 flutter, 感受是移动应用开发的将来趋势,便有了迁移至 flutter 技术栈的想法, 而后就有了 CNoder 这个项目, 也算是对数周 flutter 的一个学习实践吧node
跟着官方的安装说明一步一步往下走,仍是挺顺利的,惟一不一样的就是增长了镜像设置这一步, 打开 ~/.zhsrc
, 末尾增长react
## flutter
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
便可git
源码都位于 lib
目录下github
|-- 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
复制代码
class App extends StatelessWidget {
// 初始化路由插件
final Router router = new Router();
App() {
// 从持久化存储里加载数据状态,这里用来存储用户的身份令牌信息
persistor.load(store);
// 404处理
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',
// 禁用右上角的 debug 标志
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)
)
),
// 将 应用的路由映射至 fluro 的路由表里面去
onGenerateRoute: router.generator
);
return new StoreProvider<RootState>(store: store, child: app);
}
}
复制代码
这里有个坑,若是按照 fluro 提供的文档将应用路由映射至fluro的路由表,使用的方式是 onGenerateRoute: router.generator
, 可是这样的话在路由跳转时就没法指定过渡动效了,所以须要改为这样macos
onGenerateRoute: (RouteSettings routeSettings) {
// 这个方法能够在 router.generator 源码里找到,返回匹配的路由
RouteMatch match = this.router.matchRoute(null, routeSettings.name, routeSettings: routeSettings, transitionType: TransitionType.inFromRight);
return match.route;
},
复制代码
使用 StoreProvider 容器包裹整个应用入口widget,这样才能在子节点的widget上使用StoreConnector链接store来获取数据状态和派发actionjson
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 链接至子widgetredux
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.api
widget/index.dart
为首页的视图widget,经过底部的标签栏切换四个容器widget的显示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];
// 这里保证了初始化widget的服务调用
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) {
// 这里保证了widget的服务调用在切换时只进行一次
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('个人')
)
],
),
// 使用层叠widget来包裹视图,同一时间仅一个视图widget可见
body: new IndexedStack(
children: scenes,
index: tabIndex,
)
);
}
}
复制代码
不少同窗会有疑问,tabIndex 这个应该只是首页widget的内部数据状态,为什么要放到 redux 里去维护?由于咱们在子widget里面会去切换页签的选中状态,好比登录完成之后切换至'个人'这个页签缓存
// 初始化标志位
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;
}
// 初始化的操做是调用 redux action 获取主题数据
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);
},
);
}
}
复制代码
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"]
));
});
// 初始化 TabBar 和 TabBarView 的控制器
_tabController = new TabController(
length: _tabs.length,
vsync: this // _tabController 做为属性的类必须经过 TickerProviderStateMixin 扩展
);
// 页签切换事件监听
_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) :
// 使用 pull_to_refresh 包提供的下拉刷新和上来加载功能
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;
}
// 使用 ListTile 渲染列表中的每一行
Widget _renderRow(BuildContext context, Topic topic) {
ListTile title = new ListTile(
leading: new SizedBox(
width: 30.0,
height: 30.0,
// 使用 cached_network_image 提供支持缓存和占位图的功能显示头像
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 映射模型里定义的bash
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,
// 获取主题数据 action 的包装方法
fetchTopics: ({int currentPage = 1, String category = '', Function afterFetched = _noop}) {
// 经过 isLoading 数据状态的变动来切换widget的加载指示器的显示
store.dispatch(new ToggleLoading(true));
// 触发获取主题数据的action,将当前页,分类名,以及调用成功的回调函数传递给action
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 状态的切换(重置加载指示等等)
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 有点不一样
// 发送主题列表请求的 action
class RequestTopics {
// 当前页
final int currentPage;
// 分类
final String category;
// 请求完成的回调
final VoidCallback afterFetched;
RequestTopics({this.currentPage = 1, this.category = "", @required this.afterFetched});
}
// 响应主题列表请求的 action
class ResponseTopics {
final List<Topic> topics;
final int currentPage;
final String category;
ResponseTopics(this.currentPage, this.category, this.topics);
ResponseTopics.failed() : this(1, "", []);
}
复制代码
Stream<dynamic> fetchTopicsEpic(
Stream<dynamic> actions, EpicStore<RootState> store) {
return new Observable(actions)
// 过滤特定请求
.ofType(new TypeToken<RequestTopics>())
.flatMap((action) {
// 经过异步生成器来构建一个流
return new Observable(() async* {
try {
// 发送获取主题列表的 http 请求
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));
});
// 触发请求完成的回调,就是咱们上面提到的 SmartRefresher widget 的复位
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
里面
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 里面的数据状态final Reducer<Map> topicsReducer = combineReducers([
// 经过指定 action 类型来拆分
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) {
// 经过 isFetched 标志位来防止分类页面切换时重复请求
_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);
}
// 经过 isFetched 标志位来防止分类页面切换时重复请求
_v["isFetched"] = true;
_v["list"] = _list;
_v["currentPage"] = action.currentPage;
}
topicsOfCategory[k] = _v;
});
return topicsOfCategory;
}
复制代码
而后在 store/reducer/root.dart
的 rootReducer 里进行合并
RootState rootReducer(RootState state, action) {
// 处理从持久化存储里加载数据状态
if (action is PersistLoadedAction<RootState>) {
return action.state ?? state;
}
// 将 state 里的数据状态对应到子 reducer
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
容器包裹的时候传递// 合并 epic 得到根 epic 提供给 epic 中间件调用
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
);
// 初始化 store
final store = new Store<RootState>(rootReducer,
initialState: new RootState(), middleware: [
new LoggingMiddleware.printer(),
new EpicMiddleware(epic),
persistor.createMiddleware()
]);
复制代码
这里有个小坑,持久化存储中间件 redux_persist 的文档上加载中间件的方式为
var store = new Store<AppState>(
reducer,
initialState: new AppState(),
middleware: [persistor.createMiddleware()],
);
复制代码
可是这样处理的话,在每一个业务 action 触发的时候,都会触发持久化的操做,而这在不少场景下是没必要要的,好比在咱们的应用中只须要保存的用户身份令牌,因此只须要在触发登录和登出 action 的时候执行持久化的操做,所以加载中间件的方式须要作以下改动
void persistMiddleware(Store store, dynamic action, NextDispatcher next) {
next(action);
// 仅处理登录和登出操做
if (action is FinishLogin || action is Logout) {
try {
persistor.save(store);
} catch (_) {}
}
}
// 初始化 store
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 理解的不深,还望各位对本文中的不足之处批评指正