本篇文章將介紹從 setState
開始,到 futureBuilder
、 streamBuilder
來優雅的構建你的高質量專案,而不引發 setState
帶來的副作用,如對文章感興趣,請 點選檢視原始碼。
基礎的setState更新資料
首先,我們使用基礎的 StatefulWidget
來建立頁面,如下:
class BaseStatefulDemo extends StatefulWidget {
@override
_BaseStatefulDemoState createState() => _BaseStatefulDemoState();
}
class _BaseStatefulDemoState extends State<BaseStatefulDemo> {
@override
Widget build(BuildContext context) {
return Container();
}
}
複製程式碼
然後,我們使用 Future
來建立一些資料,來模擬網路請求,如下:
Future<List<String>> _getListData() async {
await Future.delayed(Duration(seconds: 1)); // 1秒之後返回資料
return List<String>.generate(10, (index) => '$index content');
}
複製程式碼
在 initState()
方法中呼叫 _getListData()
來初始化資料,如下:
List<String> _pageData = List<String>();
@override
void initState() {
_getListData().then((data) => setState(() {
_pageData = data;
}));
super.initState();
}
複製程式碼
使用 ListView.builder
來處理這些資料構建UI,如下:
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Base Stateful Demo'),
),
body: ListView.builder(
itemCount: _pageData.length,
itemBuilder: (buildContext, index) {
return Column(
children: <Widget>[
ListTile(
title: Text(_pageData[index]),
),
Divider(),
],
);
},
),
);
}
複製程式碼
最後,我們就可以看到介面了 ? ,如圖:
當然,你也可以將 UI 顯示單獨提取成一個方法,方便後期維護,使程式碼層次更清晰,如下:
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Base Stateful Demo'),
),
body: ListView.builder(
itemCount: _pageData.length,
itemBuilder: (buildContext, index) {
return getListDataUi(int index);
},
),
);
}
Widget getListDataUi(int index) {
return Column(
children: <Widget>[
ListTile(
title: Text(_pageData[index]),
),
Divider(),
],
);
}
複製程式碼
繼續,我們來完善它,正常從後端獲取資料,後端應該會給我們返回不同資訊,根據這些資訊需要處理不同的狀態,如:
- BusyState(載入中):我們在介面上顯示一個載入指示器
- DataFetchedState(資料載入完成):我們延遲2秒,來模擬資料載入完成
- ErrorState(錯誤):顯示錯誤提示
- NoData(沒有資料):請求成功,但沒有資料,顯示提示
先來處理 BusyState 載入指示器,如下:
bool get _fetchingData => _pageData == null; // 判斷資料是否為空
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Base Stateful Demo'),
),
body: _fetchingData
? Center(
child: CircularProgressIndicator( // 載入指示器
valueColor: AlwaysStoppedAnimation<Color>(Colors.yellow), // 設定指示器顏色
backgroundColor: Colors.yellow[100], // 設定背景色
),
)
: ListView.builder(
itemCount: _pageData.length,
itemBuilder: (buildContext, index) {
return getListDataUi(index);
},
),
);
}
複製程式碼
效果如圖:
接著,我們來處理 ErrorState ,我給 _getListData()
新增 hasError
引數來模擬後端返回的錯誤,如下
Future<List<String>> _getListData({bool hasError = false}) async {
await Future.delayed(Duration(seconds: 1)); // 1秒之後返回資料
if (hasError) {
return Future.error('獲取資料出現問題,請再試一次');
}
return List<String>.generate(10, (index) => '$index content');
}
複製程式碼
然後,在 initState()
方法中捕獲異常更新資料,如下:
@override
void initState() {
_getListData(hasError: true)
.then((data) => setState(() {
_pageData = data;
}))
.catchError((error) => setState(() {
_pageData = [error];
}));
super.initState();
}
複製程式碼
效果如圖( 當然這裡可以使用一個錯誤頁面來展示 ):
接著,我們來處理 NoData ,我給 _getListData()
新增 hasData
引數來模擬後端返回空資料,如下:
Future<List<String>> _getListData(
{bool hasError = false, bool hasData = true}) async {
await Future.delayed(Duration(seconds: 1));
if (hasError) {
return Future.error('獲取資料出現問題,請再試一次');
}
if (!hasData) {
return List<String>();
}
return List<String>.generate(10, (index) => '$index content');
}
複製程式碼
然後,在 initState()
方法更新資料,如下:
@override
void initState() {
_getListData(hasError: false, hasData: false)
.then((data) => setState(() {
if (data.length == 0) {
data.add('No data fount');
}
_pageData = data;
}))
.catchError((error) => setState(() {
_pageData = [error];
}));
super.initState();
}
複製程式碼
效果如圖:
這就是通過 setState()
來更新資料,是不是很簡單,通常情況下我們這麼使用是沒什麼問題,但是,如果我們的頁面足夠複雜,要處理的狀態足夠多,我們需要使用更多的 setState()
,意味著我們要更多的程式碼來更新資料,而且,我們每次 setState()
的時候 build()
方法就會重新執行一次( 這就是上文提到的副作用 )。
其實,Flutter 已經提供了更優雅的方式來更新我們的資料及處理狀態,它就是我們接下來要介紹的 futureBuilder
。
FutureBuilder
FutureBuilder
通過 future: 引數可以接收一個 Future
,並且通過 builder: 引數來構建 UI ,builder: 引數是一個函式,它提供了一個 snapshot
引數裡面帶著我們需要的狀態和資料。
接下來,我們將上面的 StatefulWidget
改成 StatelessWidget
,並使用 FutureBuilder
替換,如下:
class FutureBuilderDemo extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Future Builder Demo'),
),
body: FutureBuilder(
future: _getListData(),
builder: (buildContext, snapshot) {
if (snapshot.hasError) { // FutureBuilder 已經給我們提供好了 error 狀態
return _getInfoMessage(snapshot.error);
}
if (!snapshot.hasData) { // FutureBuilder 已經給我們提供好了空資料狀態
return Center(
child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(Colors.yellow),
backgroundColor: Colors.yellow[100],
),
);
}
var listData = snapshot.data;
if (listData.length == 0) {
return _getInfoMessage('No data found');
}
return ListView.builder(
itemCount: listData.length,
itemBuilder: (buildContext, index) {
return Column(
children: <Widget>[
ListTile(
title: Text(listData[index]),
),
Divider(),
],
);
},
);
},
),
);
}
...
複製程式碼
通過檢視原始碼,我們可以瞭解的 FutureBuilder
已經給我處理好了一些基本狀態,如圖
我們使用 _getInfoMessage()
方法來處理狀態提示,如下:
Widget _getInfoMessage(String msg) {
return Center(
child: Text(msg),
);
}
複製程式碼
就這樣我們不使用任何一個 setState()
就能完成和上面一樣的效果,並且不會產生副作用,是不是很給力 ?。
但是,它並不是完美的,比如,我們想重新整理資料,我們需要重新呼叫 _getListData()
方法,結果它並沒有重新整理。
StreamBuilder
StreamBuilder
通過 stream: 引數可以接收一個 stream
,同樣,通過 builder: 引數來構建 UI ,和 futureBuilder
用法類似,唯一的好處就是,我們可以隨意控制 stream
的輸入輸出,新增任何的狀態來更新指定狀態下的 UI 。
首先,我們使用 enum
來表示我們的狀態,在檔案的頭部新增它,如下:
enum StreamViewState { Busy, DataRetrieved, NoData }
複製程式碼
接著,使用 StreamController
建立一個流控制器,把 FutureBuilder
替換成 StreamBuilder
,把 future: 引數 改成 stream: 引數,如下:
final StreamController<StreamDemoState> _stateController = StreamController<StreamDemoState>();
@override
Widget build(BuildContext context) {
return Scaffold(
...
body: StreamBuilder(
stream: model.homeState,
builder: (buildContext, snapshot) {
if (snapshot.hasError) {
return _getInfoMessage(snapshot.error);
}
// 使用 列舉的 Busy 來更新資料
if (!snapshot.hasData || StreamViewState.Busy) {
return Center(
child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(Colors.yellow),
backgroundColor: Colors.yellow[100],
),
);
}
//使用 列舉的 NoData 來更新資料
if (listItems.length == StreamViewState.NoData) {
return _getInfoMessage('No data found');
}
return ListView.builder(
itemCount: listItems.length,
itemBuilder: (buildContext, index) {
return Column(
children: <Widget>[
ListTile(
title: Text(listItems[index]),
),
Divider(),
],
);
},
);
},
),
);
}
複製程式碼
只是新增了列舉值來判斷是否需要更新資料,其他基本保持不變。
接下來,我需要修改 _getListData()
方法,使用流控制器新增狀態及資料,如下:
Future _getListData({bool hasError = false, bool hasData = true}) async {
_stateController.add(StreamViewState.Busy);
await Future.delayed(Duration(seconds: 2));
if (hasError) {
return _stateController.addError('error'); // 往 stream 裡新增 error 資料
}
if (!hasData) {
return _stateController.add(StreamViewState.NoData); // 往 stream 裡新增無資料狀態
}
_listItems = List<String>.generate(10, (index) => '$index content');
_stateController.add(StreamViewState.DataRetrieved); // 往 stream 裡新增資料獲取完成狀態
}
複製程式碼
此時我們並沒有返回資料,所以我們需要建立 listItems
儲存資料,然後把 StatelessWidget
改成 StatefulWidget
,以便我們根據 stream
的輸出來更新資料,這個轉換非常方便,VS Code 編輯器可以使用 Option + Shift + R
(Mac)或者 Ctrl + Shift + R
(Win)快捷鍵 ,Android Studio 使用Option + Enter
快捷鍵,之後在 initState()
方法中初始化資料,如下:
List<String> listItems;
@override
void initState() {
_getListData();
super.initState();
}
複製程式碼
到這裡我們已經解決了 FutureBuilder
的侷限性問題,我們可以新增一個 FloatingActionButton
來重新整理資料,如下:
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Stream Builder Demo'),
),
floatingActionButton: FloatingActionButton(
backgroundColor: Colors.yellow,
child: Icon(
Icons.cached,
color: Colors.black87,
),
onPressed: () {
model.dispatch(FetchData());
},
),
body: StreamBuilder(
...
),
);
}
複製程式碼
現在,點選 FloatingActionButton
載入指示器已經顯示,但是,我們的 listItems
資料並沒真正的更新,點選 FloatingActionButton
只是更新的載入狀態而已,而且我們的業務邏輯程式碼和 UI 程式碼還在同一個檔案中,很顯然,他們已經解耦,所以,我們可以繼續完善它,將業務邏輯程式碼和 UI 程式碼分離出來。
分離業務邏輯程式碼和 UI 程式碼
我們可以把處理 stream
的程式碼抽離成一個類,如下:
import 'dart:async';
import 'dart:math';
import 'package:pro_flutter/demo/stream_demo/stream_demo_event.dart';
import 'package:pro_flutter/demo/stream_demo/stream_demo_state.dart';
enum StreamViewState { Busy, DataRetrieved, NoData }
class StreamDemoModel {
final StreamController<StreamDemoState> _stateController = StreamController<StreamDemoState>();
List<String> _listItems;
Stream<StreamDemoState> get streamState => _stateController.stream;
void dispatch(StreamDemoEvent event){
print('Event dispatched: $event');
if(event is FetchData) {
_getListData(hasData: event.hasData, hasError: event.hasError);
}
}
Future _getListData({bool hasError = false, bool hasData = true}) async {
_stateController.add(BusyState());
await Future.delayed(Duration(seconds: 2));
if (hasError) {
return _stateController.addError('error');
}
if (!hasData) {
return _stateController.add(DataFetchedState(data: List<String>()));
}
_listItems = List<String>.generate(10, (index) => '$index content');
_stateController.add(DataFetchedState(data: _listItems));
}
}
複製程式碼
然後,把狀態也封裝成一個檔案且將資料和狀態關聯,如下:
class StreamDemoState{}
class InitializedState extends StreamDemoState {}
class DataFetchedState extends StreamDemoState {
final List<String> data;
DataFetchedState({this.data});
bool get hasData => data.length > 0;
}
class ErrorState extends StreamDemoState{}
class BusyState extends StreamDemoState{}
複製程式碼
再封裝一個事件檔案,如下:
class StreamDemoEvent{}
class FetchData extends StreamDemoEvent{
final bool hasError;
final bool hasData;
FetchData({this.hasError = false, this.hasData = true});
@override
String toString() {
return 'FetchData { hasError: $hasError, hasData: $hasData }';
}
}
複製程式碼
最後,我們 UI 部分的程式碼如下:
class _StreamBuilderDemoState extends State<StreamBuilderDemo> {
final model = StreamDemoModel(); // 建立 model
@override
void initState() {
model.dispatch(FetchData(hasData: true)); // 獲取 model 裡的資料
super.initState();
}
@override
Widget build(BuildContext context) {
return Scaffold(
...
body: StreamBuilder(
stream: model.streamState,
builder: (buildContext, snapshot) {
if (snapshot.hasError) {
return _getInformationMessage(snapshot.error);
}
var streamState = snapshot.data;
if (!snapshot.hasData || streamState is BusyState) { // 通過封裝的狀態類來判斷是否更新UI
return Center(
child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(Colors.yellow),
backgroundColor: Colors.yellow[100],
),
);
}
if (streamState is DataFetchedState) { // 通過封裝的狀態類來判斷是否更新UI
if (!homeState.hasData) {
return _getInformationMessage('not found data');
}
}
return ListView.builder(
itemCount: streamState.data.length, // 此時,資料不再是本地資料,而是從 stream 中輸出的資料
itemBuilder: (buildContext, index) =>
_getListItem(index, streamState.data),
);
},
),
);
}
...
}
複製程式碼
此時,業務邏輯程式碼和 UI 程式碼已完全分離,且可擴充套件性和維護增強,且我們的資料和狀態已關聯起來,此時,點選 FloatingActionButton
效果和上面一樣,且資料已更新。
最後附上我的部落格和GitHub地址,如下:
部落格地址:h.lishaoy.net/futruebuild…
GitHub地址:github.com/persilee/fl…