前言
之前在學會 React-Native 後寫了一個 cnodejs社群的客戶端 CNodeRN,前陣子瞭解了下 flutter, 感覺是移動應用開發的未來趨勢,便有了遷移至 flutter 技術棧的想法, 然後就有了 CNoder 這個專案, 也算是對數週 flutter 的一個學習實踐吧
安裝和初始化
跟著官方的安裝說明一步一步往下走,還是挺順利的,唯一不同的就是增加了映象設定這一步, 開啟 ~/.zhsrc
, 末尾增加
## 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
即可
專案目錄結構
原始碼都位於 lib
目錄下
|-- 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檔案
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
, 但是這樣的話在路由跳轉時就無法指定過渡動效了,因此需要改成這樣
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來獲取資料狀態和派發action
- 接下來應用會進入路由機制,下面是部分路由配置資訊
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
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的顯示
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裡面會去切換頁籤的選中狀態,比如登陸完成以後切換至`我的`這個頁籤
- 主題檢視容器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);
},
);
}
}
複製程式碼
- 主題檢視widget,頂部四個頁籤用來切換顯示四個主題分類
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 對映模型裡定義的
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, "", []);
}
複製程式碼
- epic 定義,redux epic 可以看成是 action 的一個排程器,雖然 flutter 裡的redux 也有 redux_thunk 中介軟體,但是 epic 這種基於流的排程中介軟體使得業務邏輯更加優雅
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 理解的不深,還望各位對本文中的不足之處批評指正