從二月開始,我和公司幾位同事一起在公司內部做了一個新產品,應該最近很快就會跟大家見面,這次也用上了 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 裡頭有 StreamBuilder 與 FutureBuilder 一樣,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 最大的挑戰,就是怎樣強迫自己先把所有的狀態想清楚。