FutureBuilder and StreamBuilder 優雅的構建高質量專案

子木發表於2020-06-30

image

本篇文章將介紹從 setState 開始,到 futureBuilderstreamBuilder 來優雅的構建你的高質量專案,而不引發 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(),
            ],
          );
        },
      ),
    );
  }

最後,我們就可以看到介面了 ? ,如圖:

no-shadow

當然,你也可以將 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);
              },
            ),
    );
  }

效果如圖:

no-shadow

接著,我們來處理 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();
  }

效果如圖( 當然這裡可以使用一個錯誤頁面來展示 ):

no-shadow

接著,我們來處理 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();
  }

效果如圖:

no-shadow

這就是通過 setState() 來更新資料,是不是很簡單,通常情況下我們這麼使用是沒什麼問題,但是,如果我們的頁面足夠複雜,要處理的狀態足夠多,我們需要使用更多的 setState() ,意味著我們要更多的程式碼來更新資料,而且,我們每次 setState() 的時候 build() 方法就會重新執行一次( 這就是上文提到的副作用 )。

其實,Flutter 已經提供了更優雅的方式來更新我們的資料及處理狀態,它就是我們接下來要介紹的 futureBuilder

FutureBuilder

FutureBuilder 通過 future: 引數可以接收一個 Future ,並且通過 builder: 引數來構建 UIbuilder: 引數是一個函式,它提供了一個 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 已經給我處理好了一些基本狀態,如圖


snapshot

我們使用 _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地址:

部落格地址:https://h.lishaoy.net/futruebuilder-streambuilder
GitHub地址:https://github.com/persilee/flutter_pro

相關文章