Flutter 入門與實戰(五十九):手把手帶你使用 Redux 的中介軟體實現非同步狀態管理

島上碼農發表於2021-08-22

這是我參與8月更文挑戰的第22天,活動詳情檢視:8月更文挑戰

前言

上一篇我們介紹了 ReduxFlutter 中的基本概念和簡單示例,看起來好像也沒有那麼複雜。但是這些操作都是同步的,點選按鈕、發起 Action 排程、然後更新狀態、最後更新介面是連貫的。那如果有一個非同步請求怎麼辦,也就是我們可能是點選一個按鈕發起的並不是 Action,而是非同步網路請求,這個時候又如何通知更新狀態?通常來說,在這種場景裡我們需要發起三個 Action

  • 網路載入提示:介面通知使用者當前正在請求資料,請等待,通常是一個Loading 提示。
  • 網路請求成功:接收到後端資料,然後通知介面以最新狀態的資料重新整理介面。
  • 網路請求失敗:介面通知使用者請求失敗,通常是一個錯誤提示。

這個時候通過按鈕點選回撥肯定沒法完成這樣的操作,這個時候就需要利用到 Redux 的中介軟體了。本篇我們以聯絡人列表為例,來講述如何使用中介軟體完成非同步網路請求。

準備工作

  1. 首先請更新最新的後端程式碼:後端程式碼(基於 Express.js),更新後在目錄下執行 node seedContactor.js 產生資料庫 Mock 資料,注意圖片這裡使用的是本機的附件地址,在後端專案的public/upload/image 下面,如果需要展示聯絡人頭像的自己可以找一張圖片,然後修改一下seedContactor.js中的 avatar 欄位為對應圖片檔名。聯絡人的介面地址為:http://localhost:3900/api/contactor
  2. 更新依賴檔案,包括如下外掛:
  • redux: ^5.0.0:最新版的 Redux 外掛
  • flutter_redux: ^0.8.2:Flutter 適配 Redux 的外掛
  • dio: ^4.0.0:網路請求外掛,使用可以檢視本專欄的文章:Dio 篇章總結
  • flutter_easyrefresh: ^2.2.1:上拉、下拉重新整理元件。
  • cached_network_image: ^3.1.0:支援快取的網路圖片載入元件。
  • flutter_easyloading: ^3.0.0:全域性的彈窗提醒元件。
  • shared_preferences: ^2.0.6:本地離線簡單鍵值對儲存外掛。
  1. 拷貝和初始化:從之前的程式碼中拷貝網路請求的工具類到本工程,完成如 CookieManagerEasyLoading 的初始化。當然,你可以直接從這裡下載本專欄關於Redux 篇章的程式碼:基於 Redux 的狀態管理

完成上述工作後,我們就可以開始擼本篇的程式碼了。

Redux 三板斧

上篇也說過,Redux 的好處之一就是狀態管理的形式是統一的,三個元素 ActionStoreReducer 缺一不可,因此,我們先來梳理聯絡人列表業務中對應的這三個元素的內容。 image.png 首先來定義 Action,列表頁面互動上會涉及2個Action,重新整理和載入更多。但邏輯上還有另外兩個動作:獲取資料成功和獲取資料失敗,因此一共有4個Action

  • 重新整理:獲取第一頁的資料,定義為 RefreshAction,在互動時使用下來重新整理時排程該 Action
  • 載入更多:獲取下一頁的資料,定義為 LoadAction,在互動時使用上拉載入時呼叫該 Action
  • 載入成功:網路請求成功,定義為SuccessAction
  • 載入失敗:網路請求異常或錯誤,定義為 FailedAction

成功和失敗這兩個是非同步操作,沒有使用者互動主動 排程的可能,這裡留給本篇的主角中介軟體來處理,稍後再單獨介紹。Action 的程式碼如下,成功和失敗因為要攜帶資料更新狀態,因此他們有自己的成員屬性:

class RefreshAction {}

class LoadAction {}

class SuccessAction {
  final List<dynamic> jsonItems;
  final int currentPage;

  SuccessAction(this.jsonItems, this.currentPage);
}

class FailedAction {
  final String errorMessage;

  FailedAction(this.errorMessage);
}
複製程式碼

接下來是 Store的狀態物件,我們要明確需要哪些資料。首先肯定的是,需要有網路請求成功後的聯絡人列表資料;其次是當前請求的頁碼,我們在載入更多的時候需要根據該頁面請求下一頁資料;之後是 Loading 狀態標記和錯誤資訊,Loading 狀態標記在某些場合可以用於提示,而錯誤資訊則用於錯誤提醒。因此,Store 對應的狀態資料有:

  • contactors:聯絡人列表資料,為 List<dynamic>型別(要與 Dio 接收的資料匹配,只能是該型別)。
  • isLoading:載入標識,預設是 false,當排程 RefreshActionLoadAction 的時候,標記為 true,當請求成功或失敗為 true,標記為 false
  • errorMessage:錯誤資訊,允許為空,因此定義為 String?
  • currentPage:當前請求頁碼,預設為1。

Store 的狀態物件類的程式碼如下:

class ContactorState {
  final List<dynamic> contactors;
  final isLoading;
  final String? errorMessage;
  final int currentPage;

  ContactorState(this.contactors,
      {this.isLoading = false, this.errorMessage, this.currentPage = 1});

  factory ContactorState.initial() => ContactorState(List.unmodifiable([]));
}

複製程式碼

最後是 Reducer 了,Reducer 定義是一個函式,根據舊的狀態物件和當前的 Action 來返回新的狀態物件。這裡的業務邏輯如下:

  • RefreshAction處理:標記請求狀態isLoadingtrue,當前頁碼currentPage1,其他保留和原先的狀態一致。
  • LoadAction處理:標記請求狀態isLoadingtrue,當前頁碼currentPage為舊狀態的頁面加1,其他保留和原先的狀態一致。
  • FailedAction 處理:更新狀態的錯誤訊息,標記請求狀態isLoadingfalse
  • SuccessAction 處理:需要根據最新的請求頁碼來決定如何更新列表資料。如果當前頁碼是1,則使用最新的的資料替換掉原有列表;如果當前頁面大於1,則將新的資料拼接到舊的資料後面。之後則是更新狀態的isLoadingfalse,頁碼為當前 action 的頁碼,以及清空 errorMessage

Reducer 的程式碼如下所示:


ContactorState contactorReducer(ContactorState state, dynamic action) {
  if (action is RefreshAction) {
    ContactorState contactorState = ContactorState(state.contactors,
        isLoading: true, errorMessage: null, currentPage: 1);
    return contactorState;
  }
  if (action is LoadAction) {
    ContactorState contactorState = ContactorState(state.contactors,
        isLoading: true,
        errorMessage: null,
        currentPage: state.currentPage + 1);
    return contactorState;
  }

  if (action is SuccessAction) {
    int currentPage = action.currentPage;
    List<dynamic> contactors = state.contactors;
    if (currentPage > 1) {
      contactors += action.jsonItems;
    } else {
      contactors = action.jsonItems;
    }
    ContactorState contactorState = ContactorState(contactors,
        isLoading: false, errorMessage: null, currentPage: currentPage);
    return contactorState;
  }

  if (action is FailedAction) {
    ContactorState contactorState = ContactorState(
      state.contactors,
      isLoading: false,
      errorMessage: action.errorMessage,
    );
    return contactorState;
  }

  return state;
}
複製程式碼

中介軟體

所謂的中介軟體,其實就和我們之前的 Dio 的攔截器(見:Dio 之攔截器)類似,也就是在排程 Action 前會先執行中介軟體方法,處理完之後再交給下一個中介軟體處理。Redux 的攔截器定義在 Store 構造方法中,形式為:

void (Store<T> store, action, NextDispatcher next)
複製程式碼

在這裡,我們定義的中介軟體方法名為:fetchContactorMiddleware,需要在構建 Store 物件時加入到 middleware 引數中。middleware本身是一個陣列,因此我們可以新增多種中介軟體,以便進行不同的處理。

final Store<ContactorState> store = Store(
  contactorReducer,
  initialState: ContactorState.initial(),
  middleware: [
    fetchContactorMiddleware,
  ],
);
複製程式碼

在中介軟體中我們可以獲取到當前的 Action和狀態,因此可以根據 Action 做不同的業務。在這裡我們只需要處理重新整理和載入更多:

  • 重新整理時,將頁碼置為1,請求資料,請求成功後發起 SuccessAction 排程,通知狀態更新。
  • 載入更多時,將頁碼加1後再請求資料,請求成功後發起 SuccessAction 排程,通知狀態更新。
  • 請求失敗都發起 FailedAction 排程,通知狀態請求失敗。

處理完之後,記得呼叫 next 方法,將當前action傳遞過去,一般完成正常的排程過程。中介軟體的程式碼如下:

void fetchContactorMiddleware(
    Store<ContactorState> store, action, NextDispatcher next) {
  const int pageSize = 10;
  if (action is RefreshAction) {
    // 重新整理取第一頁資料
    ContactorService.list(1, pageSize).then((response) {
      if (response != null && response.statusCode == 200) {
        store.dispatch(SuccessAction(response.data, 1));
      } else {
        store.dispatch(FailedAction('請求失敗'));
      }
    }).catchError((error, trace) {
      store.dispatch(FailedAction(error.toString()));
    });
  }

  if (action is LoadAction) {
    // 載入更多時頁碼+1
    int currentPage = store.state.currentPage + 1;
    ContactorService.list(currentPage, pageSize).then((response) {
      if (response != null && response.statusCode == 200) {
        store.dispatch(SuccessAction(response.data, currentPage));
      } else {
        store.dispatch(FailedAction('請求失敗'));
      }
    }).catchError((error, trace) {
      store.dispatch(FailedAction(error.toString()));
    });
  }

  next(action);
}
複製程式碼

頁面程式碼

頁面程式碼和上一篇的結構類似,但是本篇構建了一個 ViewModel 類,使用了 StoreConnectorconverter 方法將狀態的中的列表資料轉換為頁面展示所需要的物件。

class _ViewModel {
  final List<_ContactorViewModel> contactors;

  _ViewModel(this.contactors);

  factory _ViewModel.create(Store<ContactorState> store) {
    List<_ContactorViewModel> items = store.state.contactors
        .map((dynamic item) => _ContactorViewModel.fromJson(item))
        .toList();

    return _ViewModel(items);
  }
}

class _ContactorViewModel {
  final String followedUserId;
  final String nickname;
  final String avatar;
  final String description;

  _ContactorViewModel({
    required this.followedUserId,
    required this.nickname,
    required this.avatar,
    required this.description,
  });

  static _ContactorViewModel fromJson(Map<String, dynamic> json) {
    return _ContactorViewModel(
        followedUserId: json['followedUserId'],
        nickname: json['nickname'],
        avatar: UploadService.uploadBaseUrl + 'image/' + json['avatar'],
        description: json['description']);
  }
}
複製程式碼

頁面的build方法如下,可以看到頁面中沒有體現中介軟體部分的程式碼,而是在 dispatch 過程中自動完成了。

@override
Widget build(BuildContext context) {
  return StoreProvider<ContactorState>(
    store: store,
    child: Scaffold(
      //省略 appBar
      body: StoreConnector<ContactorState, _ViewModel>(
        converter: (Store<ContactorState> store) => _ViewModel.create(store),
        builder: (BuildContext context, _ViewModel viewModel) {
          return EasyRefresh(
            child: ListView.builder(
              itemBuilder: (context, index) {
                return ListTile(
                  leading:
                      _getRoundImage(viewModel.contactors[index].avatar, 50),
                  title: Text(viewModel.contactors[index].nickname),
                  subtitle: Text(
                    viewModel.contactors[index].description,
                    style: TextStyle(fontSize: 14.0, color: Colors.grey),
                  ),
                );
              },
              itemCount: viewModel.contactors.length,
            ),
            onRefresh: () async {
              store.dispatch(RefreshAction());
            },
            onLoad: () async {
              store.dispatch(LoadAction());
            },
            firstRefresh: true,
          );
        },
      ),
      // 省略其他程式碼
    ),
  );
複製程式碼

這裡需要注意,EasyRefresh 元件要放置在 StoreConnector 的下一級,否則會因為在重新整理的時候找不到下級ScrollView,報null錯誤。

執行結果

執行結果如下圖所示,整個執行和之前我們使用Provider沒有太大區別,但是從封裝性來看,使用 Redux 的封裝性會更好,比如網路請求部分的業務放在了中介軟體,對於元件層面來說只需要關心要發起什麼動作,而不需要關心具體動作後要怎麼處理。程式碼已提交至:Redux 相關程式碼

螢幕錄製2021-08-21 下午9.28.26.gif

總結

先來梳理一下 Redux 加了中介軟體的整個流程,如下圖所示。

Redux狀態管理.png

加入了中介軟體後,在非同步完成後可以觸發非同步操作相關的 Action,以便將非同步結果通過Reducer處理後更新狀態。引入中介軟體後,可以使得非同步操作與介面互動分離,進一步降低的耦合性和提高了程式碼的可維護性。


我是島上碼農,微信公眾號同名,這是Flutter 入門與實戰的專欄文章,對應原始碼請看這裡:Flutter 入門與實戰專欄原始碼

??:覺得有收穫請點個贊鼓勵一下!

?:收藏文章,方便回看哦!

?:評論交流,互相進步!

相關文章