Flutter 的 BLoC Pattern

zonble
zonble
May 5 · 13 min read

從二月開始,我和公司幾位同事一起在公司內部做了一個新產品,應該最近很快就會跟大家見面,這次也用上了 Flutter 技術。

從開始接觸 Flutter 到現在,也大概有十個月左右的時間,這算是第二次使用 Flutter 打造產品等級的專案,這次除了是再一次在產品商業模式方面的嘗試外,就一個技術人員的立場來說,這次還有一項很大的意義,是好好的使用 BLoC Pattern 架構軟體。

Bloc

BLoC 這個詞如果從字典裡頭的意思來看,是一個英文中從法文借用過來的詞,意思是若干政黨或是國家因為共同利益所形成的陣營,不過在 Flutter 裡頭則是另外一種意義,就是 Business Logic Component 的縮寫,中文或許可以稱為「商業邏輯元件」。

網路上其實已經有不少跟 BLoC 相關的文章,裡頭對於 BLoC 的實作方式的說明也都略有不同;甚在在講到 BLoC 這個名詞的時候,BLoC 又是一套 Pattern,一種軟體架構方式,但另一方面也有人寫好了相關的 package/library,所以同一個名詞可能指的不是同一個東西。

我們這次使用了 Dart Pub 上的 BLoC package 以及 Flutter Bloc package,以下的說明,也是我對於這套 package 的理解,以及我怎麼使用為主—像我看過一些文章與影片,則是用 Stream Controller/Stream Builder 來實作 BLoC。

在BLoC package 中,每個 BLoC 是一個保存狀態(State)的有限狀態機 — 請注意,雖然在 Flutter 當中,每個 Stateful Widget 也都有各自的 State,不過跟 BLoC 裡頭講的 State 不是同一個東西 — 外部可以分派(dispatch)事件(events)到 BLoC 上,BLoC 會因為對應的事件改變狀態,接著透過一個 Stream 通知外部 — 像是 UI — 狀態發生了怎樣的改變。

比方說,這年頭每個 App 當中,大概都會有從網路上下載資料,然後在頁面中呈現這類的行為。那麼,我們可以將「下載資料」這件事情拆出去變成一個 BLoC,可以傳入的事件就可能包括「載入」或「刷新」這幾種,隨著載入進度的改變,BLoC 就會進入到「初始狀態」、「載入中」、「載入成功」、「載入失敗」…各類不同的狀態,外部再根據這樣的狀態更新 UI。

或是,很多 App 都會有登入會員帳號的需求,那麼,我們也可以把登入有關的邏輯拆出去變成一個 BLoC,像是如果要登入,就對 BLoC 分派「登入」事件,反之就分派「登出」事件,當 BLoC 收到「登入」事件後,就開始與後端的服務溝通,確定無誤之後,就將狀態變更成「已登入」狀態,反之,就進入到「未登入」狀態。

事件與狀態

既然 BLoC 是種有限狀態機,我們可能會直覺想到要用 enum 來表現 BLoC 的事件與狀態。不過,由於 Dart 語言中 enum 的能力不怎麼強,而某些狀態還需要很多相關資料,像是上面提到的「已登入」狀態,在已經完成登入後,我們往往還需要用戶的代號、自我介紹等等…在 Flutter 中,則往往用 OO 中的多形來描述事件與狀態。

比方說,我們把登入狀態設計成一個 Class,叫做 AuthenticationState 好了,那麼,我們可以把「已登入」與「未登入」設計成兩個 Class,分別叫做 AuthenticatedState 以及 UnauthenticatedState,在 AuthenticatedState 中,就可以增加我們所需要的屬性。

class AuthenticationState {}class UnauthenticatedState extends AuthenticationState {}class AuthenticatingState extends AuthenticationState {}class AuthenticatedState extends AuthenticationState {
final String accessToken;
final int id;
final String email;
final String description;
AuthenticatedState({this.accessToken,
this.id,
this.email,
this.description,
});
}

事件方面也可以比照辦理:

class AuthenticationEvent {}class LogoutEvent extends AuthenticationEvent {}class LoginEvent extends AuthenticationEvent {
final String username;
final String password;
LoginEvent({@required this.username,
@required this.password})
}

…當然,這年頭應該不太會有人直接傳遞帳號密碼登入,純粹示意而已。

在 BLoC package 中,每個 BLoC 都是繼承自 Bloc 這個 Class 的 Subclass,我們最主要要實作的是 mapEventToState 這個 method,將傳入的事件轉換成狀態。比方說:

class AuthenticationBloC extends Bloc<AuthenticationEvent, AuthenticationState> {@override
AuthenticationState get initialState => UnauthenticatedState();
// 初始狀態是未登入
@override
Stream<AuthenticationState> mapEventToState(
AuthenticationState currentState,
AuthenticationEvent event,
) async* {
if (event is LoginEvent) {
try {
yield AuthenticatingState();
final result = login(event.username, event.password);
yield AuthenticatedState(accessToken: result.accessToken, id: result.id, email: result.email, description: result.description);
}
} catch(error) {
yield UnauthenticatedState();
}
if (event is LogoutEvent) {
yield UnauthenticatedState();
}
}

也就是說,如果收到了登入事件,我們會先將狀態切換成「登入中」,如果成功,就從「登入中」變成「已登入」,失敗則變成「未登入」—實際上還應該會有一些跟登入失敗有關的狀態,但是這邊從簡。若是登出,就切換成「未登入」。

BLoC 與 Widget 之間的關係

BLoC 某方面來說跟所謂的 View Model 很像,都是要把邏輯從 UI 當中抽離出來,抽離出來的好處包括:相關的商業邏輯可以變得更容易測試,而我們想要測試 UI 時,也可以更容易 mock 相關的邏輯。

在 BLoC package中,Widget 本身並不會直接擁有、或是直接參照 BLoC,而是透過 BlocProvider 將 BLoC、以及用到這個 BLoC 的 Widget 綁起來,Widget 要在 Widget Tree 當中往上找到 BlocProvider 之後,再跟 BlocProvider 詢問 BloC。

建立 BlocProvider 的方式像這樣。我們有一個上層的 Widget,裡頭包含了一個與登入相關的 AuthenticationBloC,而我們 App 中的主要畫面都在 PageWidget 裡頭的話:

var _bloc = AuthenticationBloC();Widget build(BuildContext context) {
return BlocProvider(
bloc: _bloc,
child: PageWidget()),
);
}

在 PageWidget,以及 PageWidget 以下任何一層的所有 children,都可以往上、在 build context 中找到 AuthenticationBloC:

final authenticationBloc = BlocProvider.of<Bloc<AuthenticationEvent, AuthenticationState>>(context);

我們不是直接去找 AuthenticationBloC,而是去找符合 AuthenticationBloC 的事件與狀態的 Bloc<AuthenticationEvent, AuthenticationState>,因為對 Widget 這部份來說,到底是不是 AuthenticationBloC 這套實作並不重要,重要的只有可以分派 AuthenticationEvent,以及得到 AuthenticationState 而已。這樣,我們可以輕易抽換成另外一套實作,在測試的時候,想要 Mock 從 BLoC 丟出來的狀態,只要在 BlocProvider 那一層換掉要使用哪個實作即可。

我們可以用 currentState 這個 method 得到 BLoC 目前的狀態—至於還有一個叫做 state 的method,拿到的就是一個會一直送出狀態的 Stream,而不是狀態本身。

final state = authenticationBloc.currentState;if (state is AuthenticatedState) {
return Text('已登入 ${state.email}');
} else {
return Text('尚未登入');
}

BlocBuilder

我們會希望 BLoC 在狀態更新之後,自動更新相關的 UI,而不是我們自己再去呼叫 setState(),就像 Flutter 裡頭有 StreamBuilderFutureBuilder 一樣,Bloc package 提供了 BlocBuilder,只要是這個 BLoC 發生變動,就會執行我們所指定的 WidgetBuilder。

像前面那段就可以用 BlocBuilder 包起來。

final authenticationBloc = BlocProvider.of<Bloc<AuthenticationEvent, AuthenticationState>>(context);return BlocBuilder<AuthenticationEvent, AuthenticationState>(
bloc: authenticationBloc,
builder: (context, state) {
if (state is AuthenticatedState) {
return Text('已登入 ${state.email}');
} else {
return Text('尚未登入');
}
});

測試 BLoC

我們在寫好一個 BLoC 之後,要驗證行為是否正確,最好的方法還是為這個 BLoC 寫一個單元測試。BLoC 的單元測試其實不怎麼好寫,因為我們要驗證的是某個 Stream 是否按照正確順序丟出正確的狀態出來,我們得用到 expectLater 以及 custom matcher。

比方說,我們想要驗證,在分派「登入」事件之後,是否會按照順序得到「未登入」、「登入中」與「已登入」三種狀態,我們就得先寫好這三種狀態的 custom matcher:

class UnauthenticatedStateMatcher extends CustomMatcher {
UnauthenticatedStateMatcher(matcher)
: super("a bloc yields an unauthenticated state", "is_unauthenticated_state", matcher);

featureValueOf(actual) => actual is UnauthenticatedState;
}
class AuthenticatingStateMatcher extends CustomMatcher {
AuthenticatingStateMatcher(matcher)
: super("a bloc yields an authenticating state", "is_authenticating_state", matcher);

featureValueOf(actual) => actual is AuthenticatingState;
}
class AuthenticatedStateMatcher extends CustomMatcher {
AuthenticatedStateMatcher(matcher)
: super("a bloc yields an authenticated state", "is_authenticated_state", matcher);

featureValueOf(actual) => actual is AuthenticatedState;
}

接著,就用 expectLater,確認這個 BLoC 丟出來的是否是「登入中」與「已登入」。

void main() {
test('Test KKBOX Service Bloc', () {
var bloc = AuthenticationBloC();

bloc.dispatch(LoginEvent(username:'YOUR_USERNAME', password:'YOUR_PASSWORD'));
expectLater(
bloc.state,
emitsInOrder([
UnauthenticatedStateMatcher(isTrue),
AuthenticatingStateMatcher(isTrue),
AuthenticatedStateMatcher(isTrue),
]));
});
}

使用 BLoC 的挑戰

BLoC Pattern 將 App 中的各種邏輯定義成事件與狀態,讓邏輯變得更加清楚,但實際在開發過程中,你會發現,其實要把狀態定義好,並不是那麼容易。是不是只需要一層,就可以描述所有的狀態?還是說,某些狀態下其實應該要有子狀態?這種問題就讓人傷透了腦筋。

以登入來說,成功登入與未登入的狀態很容易定義,但是登入失敗就可能有很多原因,像是:

  • 網路連線有問題
  • 用戶帳號密碼輸入錯誤
  • 用戶目前不在我們所服務的地區
  • 因為用戶曾經有違規行為,所以被停權…

而因為某些原因登入失敗時,用戶用相同的帳號密碼重試,是有機會可以順利完成登入的,有些則反之,那麼,我們應該把所有的不同錯誤狀態都變成同一層,還是說,在統一的錯誤狀態下,應該還會有錯誤的子狀態?

在使用 BLoC Pattern 之後,在打開電腦寫 code 之前,就得把 App 中有哪些狀態先想清楚。但很多商業邏輯又偏偏是邊做邊改,隨著產品演進又突然跑出新的狀態,原本定義好的狀態中,也不見得可以輕鬆加入新定義的狀態。

也就是說,使用 BLoC 最大的挑戰,就是怎樣強迫自己先把所有的狀態想清楚。

zonble

Written by

zonble

XDDDD - eXtreme Due Date Driven Development

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade