Flutter 狀態管理之BLoC

ifgyong發表於2020-08-20

在正式介紹 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的話返回上一頁。

要滿足此功能的話,同一個部件至少要listenerbuilder,正好官方提供的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';
  }
}

 

 

eventstate寫好了,怎麼將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);
    }
  }
}

 

 

stateevent事件整理成圖方便理解一下:

構造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部件持有CubitCubit持有狀態(Model),當狀態(Model)發生變更時通知Cubit,Cubit依次通知listenerBlocBulder.builder進行重新整理UI,每次狀態變更都會通知BlocObserver,可以做到全域性的狀態監聽。

千言萬語不如一張圖:

參考

相關文章