以前在學會 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 理解的不深,還望各位對本文中的不足之處批評指正