Flutter 狀態管理之BLoC
在正式介紹 BLoC
之前, 為什麼我們需要狀態管理。如果你已經對此十分清楚,那麼建議直接跳過這一節。
如果我們的應用足夠簡單,Flutter
作為一個宣告式框架,你或許只需要將 資料 對映成 檢視 就可以了。你可能並不需要狀態管理,就像下面這樣。
但是隨著功能的增加,你的應用程式將會有幾十個甚至上百個狀態。這個時候你的應用應該會是這樣。
我們很難再清楚的測試維護我們的狀態,因為它看上去實在是太複雜了!而且還會有多個頁面共享同一個狀態,例如當你進入一個文章點贊,退出到外部縮略展示的時候,外部也需要顯示點贊數,這時候就需要同步這兩個狀態。Flutter
實際上在一開始就為我們提供了一種狀態管理方式,那就是 StatefulWidget
在
State
屬於某一個特定的 Widget
,在多個 Widget
之間進行交流的時候,雖然你可以使用 callback
解決,但是當巢狀足夠深的話,我們增加非常多可怕的垃圾程式碼。這時候,我們便迫切的需要一個架構來幫助我們理清這些關係,狀態管理框架應運而生。
BLoC 是什麼
旨在使用Widget更加加單,更加快捷,方便不同開發者都能使用,可以記錄元件的各種狀態,方便測試,讓許多開發者遵循相同的模式和規則在一個程式碼庫中無縫工作。
如何使用
簡單例子
老規矩,我們寫一個增加和減小的數字的例子,首先定義一個儲存資料的Model
Equtable
來方便與操作符==
的判斷,Equtable
實現了使用props
是否相等來判斷兩個物件是否相等,當然我們也可以自己重寫操作符==
來實現判斷兩個物件是否相等。
自己實現操作符如下:
@override bool operator ==(Object other) { if (other is Model) return this.count == other.count && age == other.count && name == other.name; return false; }
使用Equtable
操作符==
關鍵程式碼如下:
// ignore: must_be_immutable class Model extends Equatable { int count; int age; String name; List<String> list; Model({this.count = 0, this.name, this.list, this.age = 0}); @override List<Object> get props => [count, name, list, age]; Model addCount(int value) { return clone()..count = count + value; } Model addAge(int value) { return clone()..age = age + value; } Model clone() { return Model(count: count, name: name, list: list, age: age); } }
構造一個裝載Model
資料的Cubit
:
class CounterCubit extends Cubit<Model> { CounterCubit() : super(Model(count: 0, name: '老王')); void increment() { print('CounterCubit +1'); emit(state.addCount(1)); } void decrement() { print('CounterCubit -1'); emit(state.clone()); } void addAge(int v) { emit(state.addAge(v)); } void addCount(int v) { emit(state.addCount(v)); } }
資料準備好之後準備展示了,首先在需要展示資料小部件上層包裹一層BlocProvider
,關鍵程式碼:
BlocProvider( create: (_) => CounterCubit(), child: BaseBLoCRoute(), )
要是多個model
的話和Provider
寫法基本一致。
MultiBlocProvider( providers: [ BlocProvider( create: (_) => CounterCubit(), ), BlocProvider( create: (_) => CounterCubit2(), ), ], child: BaseBLoCRoute(), )
然後在展示數字的widget
上開始展示資料了,BlocBuilder<CounterCubit, Model>
中CounterCubit
是載體,Model
是資料,使用builder
回撥來重新整理UI
,重新整理UI
的條件是buildWhen: (m1, m2) => m1.count != m2.count
,當條件滿足時進行回撥builder
.
BlocBuilder<CounterCubit, Model>( builder: (_, count) { print('CounterCubit1 '); return Row( mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[ Padding( child: Text( 'count: ${count.count}', ), padding: EdgeInsets.all(20), ), OutlineButton( child: Icon(Icons.arrow_drop_up), onPressed: () { context.bloc<CounterCubit>().addCount(1); }, ), OutlineButton( child: Icon(Icons.arrow_drop_down), onPressed: () { context.bloc<CounterCubit>().addCount(-1); }, ) ], ); }, buildWhen: (m1, m2) => m1.count != m2.count, ) 監聽狀態變更 /// 監聽狀態變更 void initState() { Bloc.observer = SimpleBlocObserver(); super.initState(); } /// 觀察者來觀察 事件的變化 可以使用預設的 [BlocObserver] class SimpleBlocObserver extends BlocObserver { @override void onEvent(Bloc bloc, Object event) { print(event); super.onEvent(bloc, event); } @override void onChange(Cubit cubit, Change change) { print(change); super.onChange(cubit, change); } @override void onTransition(Bloc bloc, Transition transition) { print(transition); super.onTransition(bloc, transition); } @override void onError(Cubit cubit, Object error, StackTrace stackTrace) { print(error); super.onError(cubit, error, stackTrace); } }
區域性重新整理
佈局重新整理是使用BlocBuilder
來實現的,BlocBuilder<CounterCubit, Model>
中CounterCubit
是載體,Model
是資料,使用builder
回撥來重新整理UI
,重新整理UI
的條件是buildWhen: (m1, m2) => m1.count != m2.count
,當條件滿足時進行回撥builder
.
本例子是多個model
,多個區域性UI
重新整理
Widget _body() { return Center( child: CustomScrollView( slivers: <Widget>[ SliverToBoxAdapter( child: BlocBuilder<CounterCubit, Model>( builder: (_, count) { print('CounterCubit1 '); return Row( mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[ Padding( child: Text( 'count: ${count.count}', ), padding: EdgeInsets.all(20), ), OutlineButton( child: Icon(Icons.arrow_drop_up), onPressed: () { context.bloc<CounterCubit>().addCount(1); }, ), OutlineButton( child: Icon(Icons.arrow_drop_down), onPressed: () { context.bloc<CounterCubit>().addCount(-1); }, ) ], ); }, buildWhen: (m1, m2) => m1.count != m2.count, ), ), SliverToBoxAdapter( child: SizedBox( height: 50, ), ), SliverToBoxAdapter( child: BlocBuilder<CounterCubit, Model>( builder: (_, count) { print('CounterCubit age build '); return Row( mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[ Padding( child: Text( 'age:${count.age}', ), padding: EdgeInsets.all(20), ), OutlineButton( child: Icon(Icons.arrow_drop_up), onPressed: () { context.bloc<CounterCubit>().addAge(1); }, ), OutlineButton( child: Icon(Icons.arrow_drop_down), onPressed: () { context.bloc<CounterCubit>().addAge(-1); }, ) ], ); }, buildWhen: (m1, m2) => m1.age != m2.age, ), ), SliverToBoxAdapter( child: BlocBuilder<CounterCubit2, Model>( builder: (_, count) { print('CounterCubit2 '); return Column( children: <Widget>[ Text('CounterCubit2: ${count.age}'), OutlineButton( child: Icon(Icons.add), onPressed: () { context.bloc<CounterCubit2>().addAge(1); }, ) ], ); }, ), ) ], ), ); }
當我們點選加好或者減號已經被SimpleBlocObserver
監聽到,看下列印資訊,每次model
變更都會通知監聽者。
flutter: Change { currentState: Model, nextState: Model } flutter: CounterCubit2 flutter: Change { currentState: Model, nextState: Model } flutter: CounterCubit2
複雜狀態變更,監聽和重新整理UI
一個加減例子,每次加減我們在當前元件中監聽,當狀態變更的時候如何實現重新整理UI
,而且當age+count == 10
的話返回上一頁。
要滿足此功能的話,同一個部件至少要listener
和builder
,正好官方提供的BlocConsumer
可以實現,如果只需要監聽則需要使用BlocListener
,簡單來說是BlocConsumer=BlocListener+BlocBuilder
.
看關鍵程式碼:
BlocConsumer<CounterCubit, Model>(builder: (ctx, state) { return Column( children: <Widget>[ Text( 'age:${context.bloc<CounterCubit>().state.age} count:${context.bloc<CounterCubit>().state.count}'), OutlineButton( child: Text('age+1'), onPressed: () { context.bloc<CounterCubit>().addAge(1); }, ), OutlineButton( child: Text('age-1'), onPressed: () { context.bloc<CounterCubit>().addAge(-1); }, ), OutlineButton( child: Text('count+1'), onPressed: () { context.bloc<CounterCubit>().addCount(1); }, ), OutlineButton( child: Text('count-1'), onPressed: () { context.bloc<CounterCubit>().addCount(-1); }, ) ], ); }, listener: (ctx, state) { if (state.age + state.count == 10) Navigator.maybePop(context); })
效果如下:
複雜情況(Cubit)
登陸功能(繼承 Cubit)
我們再編寫一個完整登陸功能,分別用到BlocListener
用來監聽是否可以提交資料,用到BlocBuilder
用來重新整理UI
,名字輸入框和密碼輸入框分別用BlocBuilder
包裹,實現區域性重新整理,提交按鈕用BlocBuilder
包裹用來展示可用和不可用狀態。
此為
bloc_login
的官方例子的簡單版本,想要了解更多請檢視官方版本
觀察者
觀察者其實一個APP
只需要寫一次即可,一般在APP
初始化配置即可。
我們這裡只提供列印狀態變更資訊。
class DefaultBlocObserver extends BlocObserver { @override void onChange(Cubit cubit, Change change) { if (kDebugMode) print( '${cubit.toString()} new:${change.toString()} old:${cubit.state.toString()}'); super.onChange(cubit, change); } }
在初始化指定觀察者
@override void initState() { Bloc.observer=DefaultBlocObserver(); super.initState(); }
或者使用預設觀察者
Bloc.observer = BlocObserver();
State(Model)
儲存資料的state(Model)
,這裡我們需要賬戶資訊,密碼資訊,是否可以點選登入按鈕,是否正在登入這些資訊。
enum LoginState { success, faild, isLoading, } enum BtnState { available, unAvailable } class LoginModel extends Equatable { final String name; final String password; final LoginState state; LoginModel({this.name, this.password, this.state}); @override List<Object> get props => [name, password, state, btnVisiable]; LoginModel copyWith({String name, String pwd, LoginState loginState}) { return LoginModel( name: name ?? this.name, password: pwd ?? this.password, state: loginState ?? this.state); } bool get btnVisiable => (password?.isNotEmpty ?? false) && (name?.isNotEmpty ?? false); @override String toString() { return '$props'; } }
Cubit
裝載state
的類,當state
變更需要呼叫emit(state)
,state
的變更條件是==
,所以我們上邊的state(Model)
繼承了Equatable
,Equatable
內部實現了操作符==
函式,我們只需要將它所需props
重寫即可。
class LoginCubit extends Cubit<LoginModel> { LoginCubit(state) : super(state); void login() async { emit(state.copyWith(loginState: LoginState.isLoading)); await Future.delayed(Duration(seconds: 2)); if (state.btnVisiable == true) emit(state.copyWith(loginState: LoginState.success)); emit(state.copyWith(loginState: LoginState.faild)); } void logOut() async { emit(state.copyWith( name: null, pwd: null, )); } void changeName({String name}) { emit(state.copyWith( name: name, pwd: state.password, loginState: state.state)); } void changePassword({String pwd}) { emit(state.copyWith(name: state.name, pwd: pwd, loginState: state.state)); } }
構造view
關鍵還是得看如何構造UI
,首先輸入框分別使用BlocBuilder
包裹實現區域性重新整理,區域性重新整理的關鍵還是buildWhen
得寫的漂亮,密碼輸入框的話只需要判斷密碼是否改變即可,賬號的話只需要判斷賬號是否發生改變即可,
按鈕也是如此,在UI
外層使用listener
來監聽狀態變更,取所需要的狀態跳轉新的頁面或者彈窗。
首先看下輸入框關鍵程式碼:
class TextFiledNameRoute extends StatelessWidget { @override Widget build(BuildContext context) { return BlocBuilder<LoginCubit, LoginModel>( builder: (BuildContext context, LoginModel state) { return TextField( onChanged: (v) { context.bloc<LoginCubit>().changeName(name: v); }, decoration: InputDecoration( labelText: 'name', errorText: state.name?.isEmpty ?? false ? 'name不可用' : null), ); }, buildWhen: (previos, current) => previos.name != current.name); } } class TextFiledPasswordRoute extends StatelessWidget { @override Widget build(BuildContext context) { return BlocBuilder<LoginCubit, LoginModel>( builder: (BuildContext context, LoginModel state) { return TextField( onChanged: (v) { context.bloc<LoginCubit>().changePassword(pwd: v); }, decoration: InputDecoration( labelText: 'password', errorText: state.password?.isEmpty ?? false ? 'password不可用' : null), ); }, buildWhen: (previos, current) => previos.password != current.password); } }
按鈕根據不同的狀態來顯示可用或不可用或正在提交的動畫效果。
class LoginButton extends StatelessWidget { @override Widget build(BuildContext context) { return BlocBuilder<LoginCubit, LoginModel>( builder: (BuildContext context, LoginModel state) { switch (state.state) { case LoginState.isLoading: return const CircularProgressIndicator(); default: return RaisedButton( child: const Text('login'), onPressed: state.btnVisiable ? () { context.bloc<LoginCubit>().login(); } : null, ); } }, buildWhen: (previos, current) => previos.btnVisiable != current.btnVisiable || (current.state != previos.state)); } }
小部件寫好了,那麼我們將他們組合起來
class BaseLoginPageRoute extends StatelessWidget { @override Widget build(BuildContext context) { return BlocProvider( create: (_) => LoginCubit(LoginModel()), child: BaseLoginPage(), ); } static String routeName = '/BaseLoginPageRoute'; MaterialPageRoute get route => MaterialPageRoute(builder: (_) => BaseLoginPageRoute()); } class BaseLoginPage extends StatefulWidget { BaseLoginPage({Key key}) : super(key: key); @override _BaseLoginPageState createState() => _BaseLoginPageState(); } class _BaseLoginPageState extends State<BaseLoginPage> { @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text('loginBLoC Cubit'), ), body: _body(), ); } Widget _body() { return BlocListener<LoginCubit, LoginModel>( listener: (context, state) { if (state.state == LoginState.success) { Scaffold.of(context) ..hideCurrentSnackBar() ..showSnackBar(const SnackBar(content: Text('登陸成功'))); } }, child: Center( child: Column( children: <Widget>[ TextFiledNameRoute(), TextFiledPasswordRoute(), const SizedBox( height: 20, ), LoginButton() ], ), ), ); } @override void initState() { Bloc.observer = BlocObserver(); super.initState(); } }
這裡我們實現了登陸成功彈出snackBar
.
看下效果圖哦:
複雜情況(Bloc)
情況1都我們手動emit(state)
,那麼有沒有使用流技術來直接監聽的呢?答案是有,那麼我們再實現一遍使用bloc
的登陸功能。
state(資料載體)
首先我們使用 一個抽象類來定義事件,然後各種小的事件都繼承它,比如:NameEvent
裝載了姓名資訊,PasswordEvent
裝載了密碼資訊,SubmittedEvent
裝載了提交資訊,簡單來講,event
就是每一個按鈕點選事件或者valueChange
事件觸發的動作,最好下載程式碼之後自己對比下,然後自己從簡單例子寫,此為稍微複雜情況,看下關鍵程式碼:
/// 登陸相關的事件 abstract class LoginEvent extends Equatable { const LoginEvent(); @override List<Object> get props => []; } /// 修改密碼 class LoginChagnePassword extends LoginEvent { final String password; const LoginChagnePassword({this.password}); @override List<Object> get props => [password]; } /// 修改賬戶 class LoginChagneName extends LoginEvent { final String name; const LoginChagneName({this.name}); @override List<Object> get props => [name]; } /// 提交事件 class LoginSubmitted extends LoginEvent { const LoginSubmitted(); @override List<Object> get props => []; }
儲存資料的state
,在LoginBloc
中將event
轉換成state
,那麼state
需要儲存什麼資料呢?需要儲存賬戶資訊、密碼、登陸狀態等資訊。
/// 事件變更狀態[正在請求,報錯,登陸成功,初始化] enum Login2Progress { isRequesting, error, success, init } /// 儲存資料的model 在[bloc]中稱作[state] class LoginState2 extends Equatable { final String name; final String password; final Login2Progress progress; LoginState2({this.name, this.password, this.progress = Login2Progress.init}); @override List<Object> get props => [name, password, btnVisiable, progress]; LoginState2 copyWith( {String name, String pwd, Login2Progress login2progress}) { return LoginState2( name: name ?? this.name, password: pwd ?? this.password, progress: login2progress ?? this.progress); } /// 使用 [UserName] &&[UserPassword]來校驗規則 bool get btnVisiable => nameVisiable && passwordVisiable; bool get nameVisiable => UserName(name).visiable; bool get passwordVisiable => UserPassword(password).visiable; /// 是否展示名字錯誤資訊 bool get showNameErrorText { if (name?.isEmpty ?? true) return false; return nameVisiable == false; } /// 是否展示密碼錯誤資訊 bool get showPasswordErrorText { if (password?.isEmpty ?? true) return false; return passwordVisiable == false; } @override String toString() { return '$props'; } }
event
和state
寫好了,怎麼將event
轉換成state
呢?首先新建一個類繼承Bloc
,覆蓋函式mapEventToState
,利用這個函式引數event
來對state
,進行轉換,中間因為用到了虛擬的網路登陸,耗時操作和狀態變更,所以使用了yield*
返回了另外一個流函式。
class LoginBloc extends Bloc<LoginEvent, LoginState2> { LoginBloc(initialState) : super(initialState); @override Stream<LoginState2> mapEventToState(event) async* { if (event is LoginChagneName) { yield _mapChangeUserNameToState(event, state); } else if (event is LoginChagnePassword) { yield _mapChangePasswordToState(event, state); } else if (event is LoginSubmitted) { yield* _mapSubmittedToState(event, state); } } /// 改變密碼 LoginState2 _mapChangePasswordToState( LoginChagnePassword event, LoginState2 state2) { return state2.copyWith(pwd: event.password ?? ''); } /// 改變名字 LoginState2 _mapChangeUserNameToState( LoginChagneName event, LoginState2 state2) { return state2.copyWith(name: event.name ?? ''); } /// 提交 Stream<LoginState2> _mapSubmittedToState( LoginSubmitted event, LoginState2 state2) async* { try { if (state2.name.isNotEmpty && state2.password.isNotEmpty) { yield state2.copyWith(login2progress: Login2Progress.isRequesting); await Future.delayed(Duration(seconds: 2)); yield state2.copyWith(login2progress: Login2Progress.success); yield state2.copyWith(login2progress: Login2Progress.init); } } on Exception catch (e) { yield state2.copyWith(login2progress: Login2Progress.error); } } }
state
和event
事件整理成圖方便理解一下:
構造view
樣式我們還是使用上邊的 ,但是傳送事件卻不一樣,原因是繼承bloc
其實是實現了EventSink
的介面,使用add()
觸發監聽。
class TextFiledNameRoute extends StatelessWidget { @override Widget build(BuildContext context) { return BlocBuilder<LoginBloc, LoginState2>( builder: (BuildContext context, LoginState2 state) { return TextField( onChanged: (v) { context.bloc<LoginBloc>().add(LoginChagneName(name: v)); }, textAlign: TextAlign.center, decoration: InputDecoration( labelText: 'name', errorText: (state.showNameErrorText == true) ? 'name不可用' : null), ); }, buildWhen: (previos, current) => previos.name != current.name); } }
完整的效果是:
BLoC 流程
首先view部件持有Cubit
,Cubit
持有狀態(Model)
,當狀態(Model)
發生變更時通知Cubit
,Cubit
依次通知listener
、BlocBulder.builder
進行重新整理UI
,每次狀態變更都會通知BlocObserver
,可以做到全域性的狀態監聽。
千言萬語不如一張圖:
參考
- BLoC官方
- 文章例子code 倉庫