flutter_bloc使用解析---騷年,你還在手搭bloc嗎!

xdd666發表於2020-10-22

前言

  • 首先,有很多的文章在說flutter bloc模式的應用,但是百分之八九十的文章都是在說,使用StreamController+StreamBuilder搭建bloc,提升效能的會加上InheritedWidget,這些文章看了很多,真正寫使用bloc作者開發的flutter_bloc卻少之又少。沒辦法,只能去bloc的github上去找使用方式,最後去bloc官網翻文件。

  • 蛋痛,各位叼毛,就不能好好說說flutter_bloc的使用嗎?非要各種抄bloc模式提出作者的那倆篇文章。現在,搞的雜家這個伸手黨要自己去翻文件總結(手動滑稽)。

表情1

專案效果(建議PC瀏覽器開啟)

下面是Flutter_Bloc歷程的一系列連結

  • Flutter_Bloc起源

  • Flutter_Bloc模式優化

  • Flutter_Bloc誕生

  • Flutter_Bloc官網文件

    前面三個,是bloc作者寫的bloc模式文件,典型的觀察者模式的應用,最原始的就是java中CallBack形式。前倆篇文章就是我們們這些大抄子的主要“參考”的資料來源,這三篇文章在掘金上有翻譯版,搜下bloc就能找到。最後一篇文章就是我主要總結歸納的源泉,作者在官網上寫了好幾個demo:計時器,登入,Todos,天氣等等,大家可以自己去看看。

問題

初次使用flutter_bloc框架,可能會有幾個疑問

  • state裡面定義了太多變數,某個事件只需要更新其中一個變數,其它的變數賦相同值麻煩
  • 進入某個模組,進行初始化操作:複雜的邏輯運算,網路請求等,入口在哪定義

效果

  • 好了,嗶嗶了一堆,看下我們們要用flutter_bloc實現的效果。

bloc演示

  • 直接開Chrome演示,大家在虛擬機器上跑也一樣。

引用

  • 先說明下,bloc給的api很多,不同的api針對與解決場景不同,我要是把官網那些api全抄過也沒啥意義;不,也有可能可以裝幣,我要是不說明,大家說不定以為是我自己總結的呢!哈哈。

  • OK,大家要是想知道全場景的使用,可以去官網翻翻文件,我覺得學習一個模式或者框架的時候,最主要的是把主流程跑通,起碼可以符合標準的堆頁面,這樣的話,就可以把這玩意用起來,再遇到想要的什麼細節,就可以自己去翻文件,畢竟大體上已經懂了,寫過了幾個頁面,也有些體會,再去翻文件就很快能理解了。

flutter_bloc: ^6.0.6 #狀態管理框架
equatable: ^1.2.3 #增強元件相等性判斷
  • 看看flutter_bloc都推到6.0了,別再用StreamController手搭Bloc了!

外掛

在Android Studio設定的Plugins裡,搜尋:Bloc

外掛搜尋

安裝重啟下,就OK了

  • 右擊相應的資料夾,選擇“Bloc Class”,我在main資料夾新建的,填入的名字:main,就自動生成下面三個檔案;:main_bloc,main_event,main_state;main_view是我自己新建,用來寫頁面的。

新建bloc檔案

目錄結構新建bloc檔案

  • 是不是覺得,還在手動新建這些bloc檔案low爆了;就好像fish_redux,不用外掛,讓我手動去建立那六個檔案,寫那些模板程式碼,真的要原地爆炸。

Bloc範例

初始化程式碼

來看下這三個生成的bloc檔案:main_bloc,main_event,main_state

  • main_bloc:這裡就是我們們主要寫邏輯的頁面了
    • mapEventToState方法只有一個引數,後面自動帶了一個逗號,格式化程式碼就分三行了,建議刪掉逗號,格式化程式碼。
class MainBloc extends Bloc<MainEvent, MainState> {
  MainBloc() : super(MainInitial());

  @override
  Stream<MainState> mapEventToState(
    MainEvent event,
  ) async* {
    // TODO: implement mapEventToState
  }
}
  • main_event:這裡是執行的各類事件,有點類似fish_redux的action層
@immutable
abstract class MainEvent {}
  • main_state:狀態資料放在這裡儲存,中轉
@immutable
abstract class MainState {}

class MainInitial extends MainState {}

實現

  • 說明
    • 這裡對於簡單的頁面,state的使用抽象狀態繼承實現的方式,未免有點麻煩,這裡我進行一點小改動,state的實現類別有很多,官網寫demo也有不用抽象類,直接class,類似實體類的方式開搞的。
    • 老夫在程式碼關鍵點寫上"///"型別註釋,大家仔細看看,拷進Android Studio裡面,這些地方會變綠!大家好好體會下綠色程式碼!
  • main_bloc
    • state變數是框架內部定義的,會預設儲存上一次同步的MainSate物件的值
class MainBloc extends Bloc<MainEvent, MainState> {
  MainBloc() : super(MainState(selectedIndex: 0, isExtended: false));

  @override
  Stream<MainState> mapEventToState(MainEvent event) async* {
    ///main_view中新增的事件,會在此處回撥,此處處理完資料,將資料yield,BlocBuilder就會重新整理元件
    if (event is SwitchTabEvent) {
      ///獲取到event事件傳遞過來的值,我們們拿到這值塞進MainState中
      ///直接在state上改變內部的值,然後yield,只能觸發一次BlocBuilder,它內部會比較上次MainState物件,如果相同,就不build
      yield MainState()
        ..selectedIndex = event.selectedIndex
        ..isExtended = state.isExtended;
    } else if (event is IsExtendEvent) {
      yield MainState()
        ..selectedIndex = state.selectedIndex
        ..isExtended = !state.isExtended;
    }
  }
}
  • main_event:在這裡就能看見,view觸發了那些事件了;維護起來也很爽,看看這裡,也很快能懂頁面在幹嘛了
@immutable
abstract class MainEvent extends Equatable{
  const MainEvent();
}
///切換NavigationRail的tab
class SwitchTabEvent extends MainEvent{
  final int selectedIndex;

  const SwitchTabEvent({@required this.selectedIndex});

  @override
  List<Object> get props => [selectedIndex];
}
///展開NavigationRail,這個邏輯比較簡單,就不用傳引數了
class IsExtendEvent extends MainEvent{
  const IsExtendEvent();

  @override
  List<Object> get props => [];
}
  • main_state:state有很多種寫法,在bloc官方文件上,不同專案state的寫法也很多
    • 這邊變數名可以設定為私用,用get和set可選擇性的設定讀寫許可權,因為我這邊設定的倆個變數全是必用的,讀寫均要,就設定公有型別,不用下劃線“_”去標記私有了。
class MainState{
   int selectedIndex;
   bool isExtended;
  
   MainState({this.selectedIndex, this.isExtended});
}
  • 對於生成的模板程式碼,我們在這:去掉@immutable註解,去掉abstract;
    • 這裡說下加上@immutable和abstract的作用,這邊是為了標定不同狀態,拿很典型的列表資料載入說明,列表載入的時候一般有三種狀態
      • 獲取資料前,列表的佈局展示空樣式:LoadingBeforeState
      • 獲取資料失敗,顯示出載入失敗的佈局,或提升重新載入的樣式提升:LoadingFailureState
      • 獲取資料成功,顯示出列表資料:LoadingSuccessState
    • 針對上面三種狀態,需要展示不同的佈局,這樣我們就可以繼承抽象的狀態類:LoadingState,針對不同狀態實現上面三種不同的狀態類,不同的狀態可以定義不同的引數,然後在view中去判斷呼叫
    • 這種實現不同狀態,對不同狀態進行管理,有點設計模式中-狀態模式的味道
  • 下面程式碼是對上述描述的一種程式碼展示,可以瞧瞧;跑demo的時候,這下面的程式碼就不用抄了,僅做演示
@immutable
abstract class LoadingState extends Equatable {}
class LoadingInitial extends LoadingState {
  @override
  List<Object> get props => [];
}
class LoadingBeforeSate extends LoadingState{
  ///實現相應的欄位資訊
  @override
  List<Object> get props => [];
}
class LoadingFailureState extends LoadingState{
  ///實現相應的欄位資訊
  @override
  List<Object> get props => [];
}
class LoadingSuccessState extends LoadingState{
  ///實現相應的欄位資訊
  @override
  List<Object> get props => [];
}

///在View中使用,虛擬碼
BlocBuilder<MainBloc, MainState>(builder: (context, state) {
  if(state is LoadingBeforeSate){
	return Beforewidget(state.XX,..);
  } else if(state is LoadingFailureState){
    return FailureWidget(state.XX,state.XX,...);
  } else if(state is LoadingSuccessState){
    return SuccessWidget(state.XX);
  } else {
    return ErrorWidget(...);
  }
})
  • main_view
    • 這邊就是我們們的介面層了,很簡單,將需要重新整理的元件,用BlocBuilder包裹起來,使用BlocBuilder:提供的state去賦值就ok了,context去新增執行的事件,context用StatelessWidget中提供的或者BlocBuilder提供的都行
void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: MainPage(),
    );
  }
}

class MainPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    ///建立BlocProvider的,表明該Page,我們是用MainBloc,MainBloc是屬於該頁面的Bloc了
    return BlocProvider(
      create: (BuildContext context) => MainBloc(),
      child: BodyPage(),
    );
  }
}

class BodyPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Bloc')),
      body: totalPage(),
    );
  }
}

Widget totalPage() {
  return Row(
    children: [
      navigationRailSide(),
      Expanded(child: Center(
        child: BlocBuilder<MainBloc, MainState>(builder: (context, state) {
          ///看這看這:重新整理元件!
          return Text("selectedIndex:" + state.selectedIndex.toString());
        }),
      ))
    ],
  );
}

//增加NavigationRail元件為側邊欄
Widget navigationRailSide() {
  //頂部widget
  Widget topWidget = Center(
    child: Padding(
      padding: const EdgeInsets.all(8.0),
      child: Container(
          width: 80,
          height: 80,
          decoration: BoxDecoration(
            shape: BoxShape.circle,
            image: DecorationImage(
                image: NetworkImage("https://ss2.bdstatic.com/70cFvnSh_Q1YnxGkpoWK1HF6hhy/it/u=3383029432,2292503864&fm=26&gp=0.jpg"),
                fit: BoxFit.fill),
          )),
    ),
  );

  //底部widget
  Widget bottomWidget = Container(
    child: BlocBuilder<MainBloc, MainState>(
      builder: (context, state) {
        return FloatingActionButton(
          onPressed: () {
            ///新增NavigationRail展開,收縮事件
            context.bloc<MainBloc>().add(IsExtendEvent());
          },
          ///看這看這:重新整理元件!
          child: Icon(state.isExtended ? Icons.send : Icons.navigation),
        );
      },
    ),
  );

  return BlocBuilder<MainBloc, MainState>(builder: (context, state) {
    return NavigationRail(
      backgroundColor: Colors.white12,
      elevation: 3,
      ///看這看這:重新整理元件!
      extended: state.isExtended,
      labelType: state.isExtended ? NavigationRailLabelType.none : NavigationRailLabelType.selected,
      //側邊欄中的item
      destinations: [
        NavigationRailDestination(
            icon: Icon(Icons.add_to_queue),
            selectedIcon: Icon(Icons.add_to_photos),
            label: Text("測試一")),
        NavigationRailDestination(
            icon: Icon(Icons.add_circle_outline),
            selectedIcon: Icon(Icons.add_circle),
            label: Text("測試二")),
        NavigationRailDestination(
            icon: Icon(Icons.bubble_chart),
            selectedIcon: Icon(Icons.broken_image),
            label: Text("測試三")),
      ],
      //頂部widget
      leading: topWidget,
      //底部widget
      trailing: bottomWidget,
      selectedIndex: state.selectedIndex,
      onDestinationSelected: (int index) {
        ///新增切換tab事件
        context.bloc<MainBloc>().add(SwitchTabEvent(selectedIndex: index));
      },
    );
  });
}

Bloc範例優化

反思

從上面的程式碼來看,實際存在幾個隱式問題,這些問題,剛開始使用時候,沒異常的感覺,但是使用bloc久了後,感覺肯定越來越強烈

  • state問題
    • 初始化問題:這邊初始化是在bloc裡,直接在構造方法裡面賦初值的,state中一旦變數多了,還是這麼寫,會感覺極其難受,不好管理。需要優化
    • 可以看見這邊我們只改動selectedIndex或者isExtended;另一個變數不需要變動,需要保持上一次的資料,進行了此類:state.selectedIndex或者state.isExtended賦值,一旦變數達到十幾個乃至幾十個,還是如此寫,是讓人極其崩潰的。需要優化
  • bloc問題
    • 如果進行一個頁面,需要進行復雜的運算或者請求介面後,才能知曉資料,進行賦值,這裡肯定需要一個初始化入口,初始化入口需要怎樣去定義呢?

優化實現

這邊完整走一下流程,讓大家能有個完整的思路

  • state:首先來看看我們對state中的優化,這邊進行了倆個很重要優化,增加倆個方法:init()和clone()
    • init():這裡初始化統一用init()方法去管理
    • clone():這邊克隆方法,是非常重要的,一旦變數達到倆位數以上,就能深刻體會該方法是多麼的重要
class MainState {
  int selectedIndex;
  bool isExtended;

  ///初始化方法,基礎變數也需要賦初值,不然會報空異常
  MainState init() {
    return MainState()
      ..selectedIndex = 0
      ..isExtended = false;
  }

  ///clone方法,此方法實現參考fish_redux的clone方法
  ///也是對官方Flutter Login Tutorial這個demo中copyWith方法的一個優化
  ///Flutter Login Tutorial(https://bloclibrary.dev/#/flutterlogintutorial)
  MainState clone() {
    return MainState()
      ..selectedIndex = selectedIndex
      ..isExtended = isExtended;
  }
}
  • event
    • 這邊定義一個MainInit()初始化方法,同時去掉Equatable繼承,在我目前的使用中,感覺它用處不大。。。
@immutable
abstract class MainEvent {}

///初始化事件,這邊目前不需要傳什麼值
class MainInitEvent extends MainEvent {}

///切換NavigationRail的tab
class SwitchTabEvent extends MainEvent {
  final int selectedIndex;

  SwitchTabEvent({@required this.selectedIndex});
}

///展開NavigationRail,這個邏輯比較簡單,就不用傳引數了
class IsExtendEvent extends MainEvent {}
  • bloc
    • 這增加了初始化方法,請注意,如果需要進行非同步請求,同時需要將相關邏輯提煉一個方法,我們們在這裡配套Future和await就能解決在非同步場景下同步資料問題
    • 這裡使用了克隆方法,可以發現,我們只要關注自己需要改變的變數就行了,其它的變數都在內部賦值好了,我們不需要去關注;這就大大的便捷了頁面中有很多變數,只需要變動一倆個變數的場景
    • 注意:如果變數的資料未改變,介面相關的widget是不會重繪的;只會重繪變數被改變的widget
class MainBloc extends Bloc<MainEvent, MainState> {
  MainBloc() : super(MainState().init());

  @override
  Stream<MainState> mapEventToState(MainEvent event) async* {
    ///main_view中新增的事件,會在此處回撥,此處處理完資料,將資料yield,BlocBuilder就會重新整理元件
    if (event is MainInitEvent) {
      yield await init();
    } else if (event is SwitchTabEvent) {
      ///獲取到event事件傳遞過來的值,我們們拿到這值塞進MainState中
      ///直接在state上改變內部的值,然後yield,只能觸發一次BlocBuilder,它內部會比較上次MainState物件,如果相同,就不build
      yield switchTap(event);
    } else if (event is IsExtendEvent) {
      yield isExtend();
    }
  }

  ///初始化操作,在網路請求的情況下,需要使用如此方法同步資料
  Future<MainState> init() async {
    return state.clone();
  }

  ///切換tab
  MainState switchTap(SwitchTabEvent event) {
    return state.clone()..selectedIndex = event.selectedIndex;
  }

  ///是否展開
  MainState isExtend() {
    return state.clone()..isExtended = !state.isExtended;
  }
}
  • view
    • view層程式碼太多,這邊只增加了個初始化事件,就不重新把全部程式碼貼出來了,初始化操作直接在建立的時候,在XxxBloc上使用add()方法就行了,就能起到進入頁面,初始化一次的效果;add()方法也是Bloc類中提供的,遍歷事件的時候,就特地檢查了add()這個方法是否新增了事件;說明,這是框架特地提供了一個初始化的方法
    • 這個初始化方式是在官方示例找到的
class MainPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return BlocProvider(
      ///在MainBloc上使用add方法,新增初始化事件
      create: (BuildContext context) => MainBloc()..add(MainInitEvent()),
      child: BodyPage(),
    );
  }
}
///下方其餘程式碼省略...........

搞定

  • OK,經過這樣的優化,解決了幾個痛點。實際在view中反覆是要用BlocBuilder去更新view,寫起來有點麻煩,這裡我們可以寫一個,將其中state和context變數,往提出來的Widget方法傳值,也是蠻不錯的
  • 大家保持觀察者模式的思想就行了;觀察者(回撥重新整理控制元件)和被觀察者(產生相應事件,新增事件,去通知觀察者),bloc層是處於觀察者和被觀察者中間的一層,我們可以在bloc裡面搞業務,搞邏輯,搞網路請求,不能搞基;拿到Event事件傳遞過來的資料,把處理好的、符合要求的資料返回給view層的觀察者就行了。
  • 使用框架,不拘泥框架,在觀察者模式的思想上,靈活的去使用flutter_bloc提供Api,這樣可以大大的縮短我們的開發時間!

Cubit範例

  • Cubit是Bloc模式的一種簡化版,去掉了event這一層,對於簡單的頁面,用Cubit來實現,開發體驗是大大的好啊,下面介紹下該種模式的寫法

建立

  • 首先建立Cubit一組檔案,選擇“Cubit Class”,點選,新建名稱填寫:Counter

image-20201010155420462

新建好後,他會生成倆個檔案:counter_cubit,counter_state,來看下生成的程式碼

原始生成程式碼

  • counter_cubit
class CounterCubit extends Cubit<CounterState> {
  CounterCubit() : super(CounterInitial());
}
  • counter_state
@immutable
abstract class CounterState {}

class CounterInitial extends CounterState {}

按照生成的這種state方式去寫,比較麻煩,這邊調整下

調整後程式碼

  • counter_cubit
class CounterCubit extends Cubit<CounterState> {
  CounterCubit() : super(CounterState().init());
}
  • counter_state
class CounterState {
  ///初始化方法
  CounterState init() {
    return CounterState();
  }

  ///克隆方法,針對於重新整理介面資料
  CounterState clone() {
    return CounterState();
  }
}

OK,這樣調整了下,下面寫起來就會舒服很多,也會很省事

實現計時器

  • 來實現下一個灰常簡單的計數器

效果

  • 來看下實現效果吧,這邊不上圖了,大家點選下面的連結,可以直接體驗Cubit模式寫的計時器
  • 實現效果:點我體驗實際效果

實現

實現很簡單,三個檔案就搞定,看下流程:state -> cubit -> view

  • state:這個很簡單,加個計時變數
class CounterState {
  int count;

  CounterState init() {
    return CounterState()..count = 0;
  }

  CounterState clone() {
    return CounterState()..count = count;
  }
}
  • cubit
    • 這邊加了個自增方法:increase()
    • event層實際是所有行為的一種整合,方便對邏輯過於複雜的頁面,所有行為的一種維護;但是過於簡單的頁面,就那麼幾個事件,還單獨維護,就沒什麼必要了
    • 在cubit層寫的公共方法,在view裡面能直接呼叫,更新資料使用:emit()
    • cubit層應該可以算是:bloc層和event層一種結合後的簡寫
class CounterCubit extends Cubit<CounterState> {
  CounterCubit() : super(CounterState().init());

  ///自增
  void increase() => emit(state.clone()..count = ++state.count);
}
  • view
    • view層的程式碼就非常簡單了,點選方法裡面呼叫cubit層的自增方法就ok了
class CounterPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return BlocProvider(
      create: (BuildContext context) => CounterCubit(),
      child: BlocBuilder<CounterCubit, CounterState>(builder: _counter),
    );
  }

  Widget _counter(BuildContext context, CounterState state) {
    return Scaffold(
      appBar: AppBar(title: const Text('Cubit範例')),
      body: Center(
        child: Text('點選了 ${state.count} 次', style: TextStyle(fontSize: 30.0)),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => context.bloc<CounterCubit>().increase(),
        child: const Icon(Icons.add),
      ),
    );
  }
}

總結

在Bloc模式裡面,如果頁面不是過於複雜,使用Cubit去寫,基本完全夠用了;但是如果業務過於複雜,還是需要用Bloc去寫,需要將所有的事件行為管理起來,便於後期維護

OK,Bloc的簡化模組,Cubit模式就這樣講完了,對於自己業務寫的小專案,我就經常用這個Cubit去寫

最後

相關文章