Flutter — 為什麼你需要狀態管理工具?以 BLoC 為例

Andy Lu
12 min readMay 3, 2024

Flutter 是一種使用聲明式 UI (declarative UI)開發的跨平台框架,而聲明式的寫法讓頁面內容與程式碼能夠以對稱的方式呈現;而在使用物件(在 Flutter 稱為 Widget)時,不需要先取得物件實體,才能夠使用該物件,如 Android 需要先使用 findViewById() 取得物件實體後,才能夠控制該物件。

使用聲明式 UI 開發的方式,優點是開發時可以用更直覺的方式開發,搭配 Flutter 提供的 hot-reload,更是如魚得水:當我們修改的同時,改動就會立刻更新到頁面上。不過,這種開發方式最大缺點是:當頁面較為複雜時(頁面有很多物件或是頁面上的物件需要根據狀態動態調整),這種寫法就有可能會讓單一 class 變得臃腫,變得更難以維護。

當然,如果只是頁面內包含很多小物件,只要將頁面上小物件拆出去,就能夠快速地幫頁面瘦身。將小物件拆出去的方法,可以拆成函式或是類別兩種方法,都可以滿足需求。取決於頁面要求。

另外一種情況是:頁面需要依據不同狀態來顯示,頁面若有而這種行為,也會讓頁面的程式碼增加,複雜度也跟著提高。 在這邊提到的不同狀態,可能是在頁面上數值的變動,也有可能是在呼叫 API 後,非同步的取得請求的結果;換句話說,狀態並沒有特別的形式,完全取決於其頁面需求。面對不同形式狀態,我們可以使用不同的方式來更新頁面。

在這邊,我們大致上可以分為兩種解決方式。一、使用 setState() 更新 StatefulWidget ,二、將頁面使用 Builder 包起來,當狀態更新時,就會呼叫 Builder 重新繪製頁面。

首先先聊聊第一種方法:使用 setState() 來更新頁面。假設頁面中有一個變數,當這個變數被修改之後,我們需要主動呼叫 setState() 來通知 Widget 數更新該 Widget。常見的範例為:計數器(Counter),頁面上有一個按鈕(+1),當使用者點擊按鈕,就會將變數(_counter)的值加 1,同時會使用 setState() 更新頁面,如此就能夠在按下按鈕之後,立刻更新頁面上的值。 範例程式碼:

如果沒有呼叫 setState() ,縱使變數的值有改變也不會在畫面上看到更新。換句話說,當變數的內容改變時,需要主動呼叫 setState() 驅動刷新頁面。

ElevatedButton(  
onPressed: () {
// setState(() {
_counter++
// });
},
child: const Text('+1'),
),

這種方式缺點是:

  1. 沒有呼叫 setState()時,畫面就不會更新,
  2. 必須要在 StatefulWidget 才能夠使用。

另外一種使頁面複雜的情境是「非同步更新」,常見於與後端取得資料。例如,當使用者進入頁面後,會呼叫 API 取得最新的資料,當結果回傳後,才能依照結果更新畫面。 呼叫 API 不見得每次都會成功,有時候可能因為沒有網路,或是資料錯誤,而導致取回的是不正確的結果,所以除了正確的內容以外,還必須要處理錯誤的結果。試想一下,如果所有的程式碼都寫在一個 Widget 中,那該如何處理?

假設有一個非同步函式(Async Function) 如下, 先使用 Future.delayed 暫停三秒,並在暫停結束後返回結果(Async work done)。

Future<String> asyncWork() async {  
await Future.delayed(const Duration(seconds: 3));
return 'Async work done';
}

那麼,我們該如何處理這個函式呢? 在 Flutter 中,提供了 FutureBuilder 可以處理回傳值為 Future 的函式。我們在需要處理這個函式的地方使用 FutureBuilder 包起來,並在其 future 內填入需要執行的非同步函式,在 builder 內填入當收到結果時,該怎麼處理頁面。 如下面範例:

在 builder 內,我們利用 if (snapshot.connectionState == ConnectionState.done) 來判斷結果是否以正確取得,若是,則顯示出收到的文字,否則顯示 CircularProgressIndicator

天有不測風雲,人有旦夕禍福,函式有可能會拋出例外。

假設三秒後,函式拋出例外,將函式改一下:

Future<String> asyncWorkWithException() async {  
await Future.delayed(const Duration(seconds: 3));
throw Exception('Error');
return 'Async work done';
}

重新執行後,結果如下,畫面上顯示的文字變成 null:

如果我們希望出現錯誤的時候,能夠顯示一個錯誤的符號,該怎麼處理呢? 當 snapshot.connectionState == ConnectionState.done 時,我們需要判斷收到的資料是否有錯誤, FutureBuilder 提供了 snapshot.hasError 讓我們能夠判斷。 利用 snapshot.hasError 就能夠判斷出函式有錯誤,並且顯示出錯誤的符號。

上面的範例中,為了顯示非同步函式的回傳值,我們需要處理的除了函式本身會回傳正確的值這件事以外,我們還多了兩件事情需要去考慮:1. 取得結果之前的畫面該如何呈現,2. 錯誤處理。

不知道你們有沒有發現,在 builder 裡面,我們使用了兩個 if 來判斷不同的狀態,而這使得程式碼越來越難維護。想想看,如果畫面更加複雜,甚至有更多不同狀態需要考慮,那我們該如何妥善的處理呢?

這種類型的問題,視為狀態管理的問題。

Flutter BLoC

在 Flutter 中,有許多的工具能夠解決狀態管理問題,如:Provider, Riverpod…。這邊要介紹的是「BLoC(Business Logic Components)」。

概念

UI 層根據需求發出不同的事件(或函式)給 BLoC 層,而在 BLoC 收到事件後就會去執行任務,並同時更新狀態給 UI 層。 換句話說,UI 層與 BLoC 層的互動就是發送事件以及接收狀態。UI 層不管狀態如何產生,BLoC 層也不管狀態如何處理,如此便可以拆分 UI 與商業邏輯。

在筆者寫這篇文章的同時,BLoC 目前的版本為 8.1.5,在 BLoC 裡面包含兩個類別,一個是 bloc,另一個則是 cubit。它們之間的差異在於,bloc 需要額外定義事件(event)類別,才能夠將事件傳給 bloc 執行,而 cubit 則不需要,它允許直接呼叫由 cubit 所提供的函式,執行其商業邏輯。這兩個到底有什麼差異呢?一言以蔽之,cubit 是簡化版的 bloc。

ref: https://bloclibrary.dev/bloc-concepts/

由於 bloc 與 cubit 的概念是相同的,所以底下將以 bloc 繼續介紹。 使用 bloc 需要定義狀態以及事件。我們以上面示範為例,將其修改為 bloc 的寫法。 實作 bloc 可以分為三個步驟,若是 cubit,則是將第二以及第三步驟合併。

  1. 定義狀態
  2. 定義事件
  3. 處理商業邏輯

定義狀態

定義狀態之前,首先要先了解什麼是「狀態」。狀態可以看作是「結果」,也就是說在執行完某個動作後,會有什麼結果。若以登入畫面為例,當按下登入按鈕之後,會出現登入成功或是登入失敗。 不過,狀態不見得只包含最終結果,完成動作中的過程,也可以被定義成狀態,例如,按下登入按鈕之後,在結果回傳之前,我們可以稱此狀態為登入中。當然一進入頁面什麼都沒做,也是一種狀態,初始狀態。

因此一個登入畫面的狀態就可以分為初始(initial)、登入中(logging)、登入成功(success)以及登入失敗(fail)…等。 狀態的多寡、粗細度可以依照需求決定,可對每個動作的結果都定義狀態,也可只設定必要的狀態。

當需要建立狀態時,該怎麼決定該建立什麼狀態呢?最重要的是,要了解完整的動作流程,會怎麼切分。在這邊可以使用流程圖將整個頁面的動作畫出來,如此一來,就能根據流程圖清楚的知道該建立什麼狀態。

定義事件

前面介紹了狀態,接著要介紹事件,狀態與事件可以看作是輸入與輸出的關係,事件是輸入,而狀態是輸出,也就是說,當我們需要執行某個動作,也就是將事件發送給 BLoC 來執行,而當 BLoC 層執行完成之後,便會將狀態更新。

狀態與事件的定義順序,沒有一定的順序,只要能夠清楚的知道前因後果即可。 若以「定義狀態」小節介紹的範例來繼續,針對登入畫面,我們只需要一個「登入」事件。

定義商業邏輯

當我們定義好狀態以及事件後,該怎麼使用呢? BLoC 層又是怎麼呼叫呢? 在 BLoC 層中,我們的類別將會繼承 Bloc<LoginEvent, LoginState> ,這表示這個 BLoC 只會收到實作 LoginEvent 的事件,並且回傳 LoginState 的狀態。

將收到事件所需要的動作加入:

從上方的程式碼,我們得知,BLoC 層是透過 emit 將狀態發送至 UI,而在 emit() 裡面所包含的都是在 login_state.dart 所定義的狀態。而因為有指定型別的關係,這邊不可以使用其他 BLoC 所定義的狀態。

如何在 UI 串接 BLoC

既然我們已經完成了 BLoC 的設計,接下來,要套用在 UI 層使用,該怎麼使用呢?

  • BlocProvider

首先,類似 Provider,BLoC 也設計了 BlocProvider 用來將 BLoC 注入至該Widget 內,而使用 BlocBuilder 來監聽狀態的改變,進而更新畫面。 我們先將 BlocProvider 在需要使用 BLoC 的 Widget 外部加上,如下例:

在 Scaffold 的外層包上 BlocProvider,並且實作兩個參數:create 以及 childcreate 是用來建立 BLoC 的方式,範例中,建立 LoginBloc 的實例時同時帶入 FakeLoginService,這可以讓我們的登入流程可以與 Bloc 層拆開,並且可以帶入假的登入測試程式碼。child 則是將原本頁面的部分搬過來即可。

  • BlocBuilder

那麼,在頁面中,要如何根據不同 Bloc 的狀態來產生不同的畫面呢? 只需要將 BlocBuilder 包在需要使用 Bloc 的地方,當狀態更新時,就會呼叫其 builder 建立畫面。 如下例,在 BlocBuilder 內中,依照不同的狀態分別回傳不同的 Widget:

使用 BlocBuilder 有一個需要注意的點 — 在 builder 中,必須要回傳 Widget ,如果像是 Toast 這種以 Dialog 出現的畫面,就沒有辦法使用。若需要彈出 Toast 使用 BlocBuilder 可能會不適當,這時候我們可以改用 BlocListener。

  • BlocListener

與 BlocBuilder 不同,在其 listener 中,當收到指定的狀態時,我們無法 return 一個 Widget,取而代之的,我們只能作事,譬如彈出 Toast。

  • BlocConsumer

前面介紹了 BlocBuilder 以及 BlocListener,前者是只能回傳 Widget,後者則是只能作事,有沒有支援兩種動作的? BlocConsumer 能夠支援兩種動作,也就是說,他包含了 builder 以及 listener,若以登入頁面的需求來說,若登入成功與失敗需要顯示 Toast,我們可以將狀態寫在 listener 內,若登入中需要顯示一個 CircularProgressIndicator,則可以將其寫在 builder 內。 最後,我們可以將程式碼改成這樣:

小結

聲明式的寫法易學,不易維護,但是只要適當的將 UI 與邏輯分離,就會使得程式碼變少、變得更好維護。

面對需求複雜的頁面,使用狀態管理工具,會使得頁面變得更好維護,讓 UI 只針對狀態的變動而變化,就不需考慮背後的實作。若之後需要新增新的動作、狀態,在 UI 的程式碼改變幅度就會相對來的小,因畫面不與邏輯綁在一起,所以邏輯的實作、測試就會變得更加簡單。

而使用 BLoC 分離架構,掌握要領之後其實也不難,只要將執行的動作切分為事件與狀態,依照需求設計,就可以在把邏輯搬到 BLoC 層,而讓狀態發送到 UI 層更新畫面。在 UI 層只需要針對不同的使用情境選擇 BlocBuilder、BlocListener 或是 BlocConsumer 即可。

當然,不是只有使用 BLoC 層就可以讓程式跑的更順,若在錯誤的地方使用 BLoC ,也是會造成效能不佳的情況,這個部分我們之後再討論。

你的鼓勵是我持續創作的動力

Github: https://github.com/andyludeveloper/flutter_bloc_demo

--

--

Andy Lu

Android/Flutter developer, Kotlin Expert, like to learn and share.