Flutter - BLoC模式入門

小紅星閃啊閃發表於2020-06-08

原文地址在這裡, 作者是Brian Kayfitz。

這裡提一點關於IDE的問題,很多人是移動轉過來的,所以用Android Studio的人很多。其實Flutter也可以用VS Code來開發。筆者,兩個都用過,他們各有好處。Android Studio在專案初期,目錄、檔案處理多的時候方便。重構的時候關於檔案的修改,都會在其他檔案引用裡一起修改,刪除也會有提示。在VS Code裡這些沒有,改檔名要手動去把import也一起改了。但是,VS Code除錯方便很多。但是,在真機除錯的時候還是要記得先Select Device

正文

設計app的架構經常會引起爭論。每個人都有自己喜歡的一套炫酷的架構和一大堆名詞。

iOS和Android開發者都對MVC非常瞭解,並且在開發的時候把這個模式作為預設的架構。Model和View是分開的,Controller來作為他們溝通的橋樑。

然而,Flutter帶來的一套響應式設計並不能很好的相容MVC。一個脫胎於這個經典模式的新的架構就出現在了Flutter社群--BLoC

BLoC是Business Logic Components的縮寫。BLoC的哲學就是app裡的所有東西都應該被認為是事件流:一部分元件訂閱事件,另一部分元件則響應事件。BLoC居中管理這些會話。Dart甚至把流(Stream)內建到了語言本身裡。

這個模式最好的地方就是你不需要引入任何的外掛,也不需要學習其他的語法。所有需要的內容Flutter都有提供。

在本文裡,我們要新建一個查詢餐廳的app。API是有Zomato提供。最後你會學到以下內容:

  • 在BLoC模式裡包裝API呼叫
  • 查詢並非同步顯示結果
  • 維護一個可以從多個頁面訪問到的最愛餐廳列表

開始

這裡下載開始專案程式碼,使用你最喜歡的IDE開啟。記得開始的時候執行flutter pub get,在IDE裡也好,在命令列裡也可以。在所有依賴都下載完成後就可以開始編碼了。

在開始專案裡包含了基本的model檔案和網路請求檔案。看起來是這樣的:

獲取API的Key

在開始開發應用之前,首先要獲得一個我們要用的API的key。在Zomato的開發者站點https://developers.zomato.com/api,註冊並生成一個key。

DataLayer目錄下,開啟zomato_client.dart檔案。修改這個常量值:

class ZomatoClient {
  final _apiKey = "Your api key here";
}
實際的開發中把key放進原始碼或者夾雜到版本控制工具裡可不是什麼明智之舉。這裡只是為了方便,可不要用在實際的開發裡。

執行起來,你會看到這樣的效果:

一片黑,現在開始新增程式碼:

我們來烤一個多層蛋糕

寫app的時候,不管你用的是Flutter或者其他的框架,把類分層都是很關鍵的。這更像是一個非正式的約定,不是一定要在程式碼裡有怎麼樣的體現。

每層,或者一組類,都負責一個總體的職責。在初始專案裡有一個目錄DataLayer。這個資料層專門用來負責app的model和與後臺通訊。它對UI一無所知。

每個app都不盡相同,但是總體來說你都會構建一個這樣的東西:

這個架構約定並沒有和MVC太過不同。UI/Flutter層只能和BLoC層通訊,BLoC層處理邏輯並給資料層和UI傳送事件。這樣的結構可以保證app規模變大的時候可以平滑的擴充套件。

深入BLoC

BLoC基本就是基於Dart的流(Stream)的。

流,和Future一樣,也是在dart:async包裡。一個流就像一個future,不同的是流不只是非同步的返回一個值,流可以隨著時間的推移返回很多的值。如果一個future最終是一個值的話,那麼一個流就是會隨著時間可以返回一個系列的值。

dart:async包提供了一個StreamController類。流控制器管理的兩個物件流和槽(sink)。sink和流相對應,流提供提供資料,sink接受輸入值。

總結一下,BLoC用來處理邏輯,sink接受輸入,流輸出。

定位介面

在查詢餐館之前,你要告訴Zomato你要在哪裡吃飯。在這一節,你要新建一個簡單的介面,有一個搜尋欄和一個列表顯示搜尋的結果。

在輸入程式碼之前不要忘記開啟DartFmt。這才是編寫Flutter app的組好編碼方式。

lib/UI目錄,席間一個location_screen.dart檔案。新增一個StatelessWidget,並命名為LocationScreen

import 'package:flutter/material.dart';
class LocationScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Where do you want to eat?')),
      body: Column(
        children: <Widget>[
          Padding(
            padding: const EdgeInsets.all(10.0),
            child: TextField(
              decoration: InputDecoration(
                  border: OutlineInputBorder(), hintText: 'Enter a location'),
              onChanged: (query) { },
            ),
          ),
          Expanded(
            child: _buildResults(),
          )
        ],
      ),
    );
  }


  Widget _buildResults() {
    return Center(child: Text('Enter a location'));
  }
 }

定位介面包含了一個TextField,使用者可以在這裡輸入位置。

你的IDE在你輸入的類沒有被import的話會有報錯。要改正這個錯誤的話只要把游標移動到這個識別符號上,然後按下蘋果系統下option+enter(windows下Alt+Enter)或者點一下邊上的紅色小燈泡。點了之後會出現一個選單,選擇import那條就OK。

新增另一個檔案main_screen.dart檔案,它會用來管理介面的導航。新增如下的程式碼:

class MainScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return LocationScreen();
  }
}

更新main.dart檔案:

MaterialApp(
  title: 'Restaurant Finder',
  theme: ThemeData(
    primarySwatch: Colors.red,
  ),
  home: MainScreen(),
),

現在執行程式碼,是這樣的:

現在到了BLoC時間了。

第一個BLoC

lib目錄下建立一個BLoC目錄。這裡用來存放所有的BLoC類。

新建一個bloc.dart檔案,新增如下程式碼:

abstract class Bloc {
  void dispose();
}

所有的BLoC類都會遵循這個介面。這個介面並沒有做什麼,只是強制你的程式碼要包含一個dispoose方法。使用流很重要的一點就是不用的時候要關掉,否則會引起記憶體洩漏。有了dispose方法,app會直接呼叫。

第一個BLoC會處理app選定的地點。

BLoC目錄,新建一個檔案location_bloc.dart。新增如下的程式碼:

class LocationBloc implements Bloc {
  Location _location;
  Location get selectedLocation => _location;

  // 1
  final _locationController = StreamController<Location>();

  // 2
  Stream<Location> get locationStream => _locationController.stream;

  // 3
  void selectLocation(Location location) {
    _location = location;
    _locationController.sink.add(location);
  }

  // 4
  @override
  void dispose() {
    _locationController.close();
  }
}

使用option+enter import bloc類。

LocationBloc主要處理一下的事情:

  1. 有一個私有的StreamController來管理流和sink。StreamController使用泛型來告訴呼叫程式碼返回的資料是什麼型別的。
  2. 這一行使用getter來暴露流
  3. 這個方法用來給BLoC輸入值。並且位置資料也快取在了_location屬性裡。
  4. 最終,在清理方法裡StreamController在這個物件被回收之前被關閉。如果你不這麼做,你的IDE也會顯示出錯誤。

現在你的第一個BLoC就完成了,下面就要找地點了。

第二個BLoC

BLoC目錄下新建一個location_query_bloc.dart檔案,並新增如下程式碼:

class LocationQueryBloc implements Bloc {
  final _controller = StreamController<List<Location>>();
  final _client = ZomatoClient();
  Stream<List<Location>> get locationStream => _controller.stream;

  void submitQuery(String query) async {
    // 1
    final results = await _client.fetchLocations(query);
    _controller.sink.add(results);
  }

  @override
  void dispose() {
    _controller.close();
  }
}

//1,這個方法接受一個字串引數,並且用ZomatoClient類來獲取位置資料。這裡用了async/await來讓程式碼看起來清晰一些。結果隨後會被推進流裡。

這個BLoC和上一個基本上類似,只是這個裡面還包含了一個API請求。

把BLoC和元件樹結合

現在已經有兩個BLoC了,你需要把他們和元件結合到一起。這樣的方式在Flutter基本就叫做provider。一個provider就是給這個元件和它的子元件提供資料的。

一般來說這是InheritedWidget元件的工作,但是因為BLoC需要釋放,StatefulWidget也會提供相同的服務。所以語法會稍顯複雜,但是結果是一樣的。

BLoC新建一個bloc_provider.dart檔案,並新增下面的程式碼:

// 1
class BlocProvider<T extends Bloc> extends StatefulWidget {
  final Widget child;
  final T bloc;

  const BlocProvider({Key key, @required this.bloc, @required this.child})
      : super(key: key);

  // 2
  static T of<T extends Bloc>(BuildContext context) {
    final type = _providerType<BlocProvider<T>>();
    final BlocProvider<T> provider = findAncestorWidgetOfExactType(type);
    return provider.bloc;
  }

  // 3
  static Type _providerType<T>() => T;

  @override
  State createState() => _BlocProviderState();
}

class _BlocProviderState extends State<BlocProvider> {
  // 4
  @override
  Widget build(BuildContext context) => widget.child;

  // 5
  @override
  void dispose() {
    widget.bloc.dispose();
    super.dispose();
  }
}

上面的程式碼解析如下:

  1. BlocProvider是一個泛型類。型別T要求必須實現了Bloc介面。這也就是說provider只能儲存BLoC型別的物件。
  2. of方法允許元件從當前的context裡獲取元件樹中的BlocProvider。這是Flutter的常規操作。
  3. 這裡是獲取一個泛型型別的物件
  4. 這個build方法並不會構建任何的東西
  5. 最後,這個provider為什麼要繼承StatefulWidget呢,主要是為了dispose方法。當一個元件從樹裡移除的時候,Flutter就會呼叫dispose方法關閉流

組合定位介面

你已經有了查詢位置的完整的BLoC層程式碼,是時候用起來了。

首先,在main.dart裡用一個BLoC包裹material app。最簡單的就是把游標移動到MaterialApp上,按下option+enter(windows使用alt+enter),這樣會彈出一個選單,選擇Wrap with a new widget

注意:這段程式碼是收到Didier Boelens的https://www.didierboelens.com...—streams—bloc/。的啟發。這個元件還沒有優化,不過理論上是可以優化的。本文會繼續使用比較初始的方式,因為這樣可以滿足大多數的場景。如果之後你發現有效能的問題,那麼可以在Flutter BLoC包裡找到改進的方法。

之後程式碼就是這樣的了:

return BlocProvider<LocationBloc>(
  bloc: LocationBloc(),
  child: MaterialApp(
    title: 'Restaurant Finder',
    theme: ThemeData(
      primarySwatch: Colors.red,
    ),
    home: MainScreen(),
  ),
);

在material app外面包一層provider是給需要資料的元件傳遞資料最簡單的方法了。

main_screen.dart檔案也要做類似的事情。在LocationScreen.dart上按下option + enter,選擇**Wrap with StreamBuilder`。更新之後的程式碼是這樣的:

return StreamBuilder<Location>(
  // 1
  stream: BlocProvider.of<LocationBloc>(context).locationStream,
  builder: (context, snapshot) {
    final location = snapshot.data;

    // 2
    if (location == null) {
      return LocationScreen();
    }
    
    // This will be changed this later
    return Container();
  },
);

StreamBuilder是BLoC模式的催化劑。這些元件會自動監聽流的事件。當收到一個新的事件的時候,builder方法就會執行,更新元件樹。使用StreamBuilder和BLoC模式就完全不需要setState方法了。

程式碼解析:

  1. stream屬性,使用of方法獲取LocationBloc,並把流交給StreamBuilder
  2. 一開始流是沒有資料的,這樣很正常。如果沒有任何資料app就返回LocationScreen。否則暫時返回一個空白介面。

接下來,在location_screen.dart裡面使用LocationQueryBloc更新定位介面。不要忘了使用IDE提供的快捷鍵來更新程式碼:

@override
Widget build(BuildContext context) {
  // 1
  final bloc = LocationQueryBloc();

  // 2
  return BlocProvider<LocationQueryBloc>(
    bloc: bloc,
    child: Scaffold(
      appBar: AppBar(title: Text('Where do you want to eat?')),
      body: Column(
        children: <Widget>[
          Padding(
            padding: const EdgeInsets.all(10.0),
            child: TextField(
              decoration: InputDecoration(
                  border: OutlineInputBorder(), hintText: 'Enter a location'),
              
              // 3
              onChanged: (query) => bloc.submitQuery(query),
            ),
          ),
          // 4
          Expanded(
            child: _buildResults(bloc),
          )
        ],
      ),
    ),
  );
}

解析如下:

  1. 首先,在build方法的一開始初始化了一個LocationQueryBloc類。
  2. BLoC隨後被儲存到了BlocProvider裡面
  3. 更新TextFieldonChange方法,在這裡把修改的文字提交給了LocationQueryBloc物件。這會出發請求API並返回資料的鏈條反應。
  4. 把bloc物件傳遞給_buildResult方法。

LocationScreen新增一個bool成員,一次來標記是否是一個全屏對話方塊。

class LocationScreen extends StatelessWidget {
  final bool isFullScreenDialog;
  const LocationScreen({Key key, this.isFullScreenDialog = false})
      : super(key: key);
  ...  

這個bool只是一個簡單的標記。以後選中某個位置的時候會用到。

現在更新_buildResults方法。新增一個stream builder在一個列表裡顯示結果。你可以使用Wrap with StreamBuilder來快速更新程式碼:

Widget _buildResults(LocationQueryBloc bloc) {
  return StreamBuilder<List<Location>>(
    stream: bloc.locationStream,
    builder: (context, snapshot) {

      // 1
      final results = snapshot.data;
    
      if (results == null) {
        return Center(child: Text('Enter a location'));
      }
    
      if (results.isEmpty) {
        return Center(child: Text('No Results'));
      }
    
      return _buildSearchResults(results);
    },
  );
}

Widget _buildSearchResults(List<Location> results) {
  // 2
  return ListView.separated(
    itemCount: results.length,
    separatorBuilder: (BuildContext context, int index) => Divider(),
    itemBuilder: (context, index) {
      final location = results[index];
      return ListTile(
        title: Text(location.title),
        onTap: () {
          // 3
          final locationBloc = BlocProvider.of<LocationBloc>(context);
          locationBloc.selectLocation(location);

          if (isFullScreenDialog) {
            Navigator.of(context).pop();
          }
        },
      );
    },
  );
}

程式碼解析如下:

  1. Stream可以返回三種結果:無資料(使用者未做任何操作),空陣列,也就是說Zomato沒有找到符合條件的結果。最後是一組餐廳列表。
  2. 展示返回的一組資料。這也是flutter的常規操作
  3. onTap方法,使用者點選一個餐廳之後獲取LocationBloc並跳轉回上一個頁面

再次執行程式碼。你會看到這樣的效果:

總算有點進展了。

餐廳頁面

app的第二個頁面會根據查詢的結果展示一組餐廳。它也會有對應的BLoC物件來管理狀態。

BLoC目錄新建一個檔案restaurant_bloc.dart。並新增如下的程式碼:

class RestaurantBloc implements Bloc {
  final Location location;
  final _client = ZomatoClient();
  final _controller = StreamController<List<Restaurant>>();

  Stream<List<Restaurant>> get stream => _controller.stream;
  RestaurantBloc(this.location);

  void submitQuery(String query) async {
    final results = await _client.fetchRestaurants(location, query);
    _controller.sink.add(results);
  }

  @override
  void dispose() {
    _controller.close();
  }
}

LocationQueryBloc基類類似。唯一 不同的是返回的資料型別。

現在在UI目錄下新建一個restaurant_screen.dart的檔案。並把新建的BLoC投入使用:

class RestaurantScreen extends StatelessWidget {
  final Location location;

  const RestaurantScreen({Key key, @required this.location}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(location.title),
      ),
      body: _buildSearch(context),
    );
  }

  Widget _buildSearch(BuildContext context) {
    final bloc = RestaurantBloc(location);

    return BlocProvider<RestaurantBloc>(
      bloc: bloc,
      child: Column(
        children: <Widget>[
          Padding(
            padding: const EdgeInsets.all(10.0),
            child: TextField(
              decoration: InputDecoration(
                  border: OutlineInputBorder(),
                  hintText: 'What do you want to eat?'),
              onChanged: (query) => bloc.submitQuery(query),
            ),
          ),
          Expanded(
            child: _buildStreamBuilder(bloc),
          )
        ],
      ),
    );
  }

  Widget _buildStreamBuilder(RestaurantBloc bloc) {
    return StreamBuilder(
      stream: bloc.stream,
      builder: (context, snapshot) {
        final results = snapshot.data;

        if (results == null) {
          return Center(child: Text('Enter a restaurant name or cuisine type'));
        }
    
        if (results.isEmpty) {
          return Center(child: Text('No Results'));
        }
    
        return _buildSearchResults(results);
      },
    );
  }

  Widget _buildSearchResults(List<Restaurant> results) {
    return ListView.separated(
      itemCount: results.length,
      separatorBuilder: (context, index) => Divider(),
      itemBuilder: (context, index) {
        final restaurant = results[index];
        return RestaurantTile(restaurant: restaurant);
      },
    );
  }
}

另外新建一個restaurant_tile.dart的檔案來顯示餐廳的細節:

class RestaurantTile extends StatelessWidget {
  const RestaurantTile({
    Key key,
    @required this.restaurant,
  }) : super(key: key);

  final Restaurant restaurant;

  @override
  Widget build(BuildContext context) {
    return ListTile(
      leading: ImageContainer(width: 50, height: 50, url: restaurant.thumbUrl),
      title: Text(restaurant.name),
      trailing: Icon(Icons.keyboard_arrow_right),
    );
  }
}

這個程式碼看起來和定位介面的程式碼非常的像。唯一不同的是它顯示的是餐廳而不是定位。

修改main_screen.dartMainScreen的程式碼:

builder: (context, snapshot) {
  final location = snapshot.data;

  if (location == null) {
    return LocationScreen();
  }

  return RestaurantScreen(location: location);
},

你選擇了一個定位之後,一列餐廳就可以顯示出來了。

最愛餐廳

目前為止,BLoC僅僅被用於處理使用者輸入。它能做到不止於此。假設使用者想要記錄他們最喜歡的餐廳,並且把這些餐廳顯示到另外的一個列表頁裡面。這也可以用BLoC模式來解決。

BLoC目錄,新建一個favorite_bloc.dart檔案來儲存這個列表:

class FavoriteBloc implements Bloc {
  var _restaurants = <Restaurant>[];
  List<Restaurant> get favorites => _restaurants;
  // 1
  final _controller = StreamController<List<Restaurant>>.broadcast();
  Stream<List<Restaurant>> get favoritesStream => _controller.stream;

  void toggleRestaurant(Restaurant restaurant) {
    if (_restaurants.contains(restaurant)) {
      _restaurants.remove(restaurant);
    } else {
      _restaurants.add(restaurant);
    }

    _controller.sink.add(_restaurants);
  }

  @override
  void dispose() {
    _controller.close();
  }
}

程式碼解析:在// 1的部分,使用了一個廣播(Broadcast)StreamController,而不是一個常規的StreamControllerBroadcast型別的stream可以有多個監聽器(listener),而常規的只允許有一個。在前兩個BLoC裡面只存在一對一的關係,所以也不需要多個監聽器。對於最喜歡這個功能,需要兩個地方去監聽,所以廣播就是必須的了。

注意:使用BLoC的一般規則是使用首先使用常規的流,之後如果需要廣播的時候才去重構程式碼。如果多個物件監聽同一個常規的流,那麼Flutter會丟擲一個異常。使用這個來作為需要重構程式碼的一個標誌。

這個BLoC需要多個頁面都可以訪問到,也就是說要放在導航器的外面了。更新main.dart,新增如下的元件:

return BlocProvider<LocationBloc>(
  bloc: LocationBloc(),
  child: BlocProvider<FavoriteBloc>(
    bloc: FavoriteBloc(),
    child: MaterialApp(
      title: 'Restaurant Finder',
      theme: ThemeData(
        primarySwatch: Colors.red,
      ),
      home: MainScreen(),
    ),
  ),
);

接下來,在UI目錄下新增一個favorite_screen.dart檔案。這個元件會顯示使用者最喜歡的餐廳:

class FavoriteScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final bloc = BlocProvider.of<FavoriteBloc>(context);

    return Scaffold(
      appBar: AppBar(
        title: Text('Favorites'),
      ),
      body: StreamBuilder<List<Restaurant>>(
        stream: bloc.favoritesStream,
        // 1
        initialData: bloc.favorites,
        builder: (context, snapshot) {
          // 2
          List<Restaurant> favorites =
              (snapshot.connectionState == ConnectionState.waiting)
                  ? bloc.favorites
                  : snapshot.data;
    
          if (favorites == null || favorites.isEmpty) {
            return Center(child: Text('No Favorites'));
          }
    
          return ListView.separated(
            itemCount: favorites.length,
            separatorBuilder: (context, index) => Divider(),
            itemBuilder: (context, index) {
              final restaurant = favorites[index];
              return RestaurantTile(restaurant: restaurant);
            },
          );
        },
      ),
    );
  }
}

在這個元件裡:

  1. StreamBuilder裡新增初始資料。StreamBuilder會立即呼叫builder方法,即使是沒有資料的。
  2. 檢查app的連線狀態。

接下來更新餐廳介面的build方法,把最喜歡的餐廳加到導航裡面:

@override
Widget build(BuildContext context) {
  return Scaffold(
      appBar: AppBar(
        title: Text(location.title),
        actions: <Widget>[
          IconButton(
            icon: Icon(Icons.favorite_border),
            onPressed: () => Navigator.of(context)
                .push(MaterialPageRoute(builder: (_) => FavoriteScreen())),
          )
        ],
      ),
      body: _buildSearch(context),
  );
}

你需要另外一個介面,使用者可以把這個餐廳設定為最喜歡。

UI目錄下新建restaurant_details_screen.dart檔案。主要的程式碼如下:

class RestaurantDetailsScreen extends StatelessWidget {
  final Restaurant restaurant;

  const RestaurantDetailsScreen({Key key, this.restaurant}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final textTheme = Theme.of(context).textTheme;

    return Scaffold(
      appBar: AppBar(title: Text(restaurant.name)),
      body: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: <Widget>[
          _buildBanner(),
          Padding(
            padding: const EdgeInsets.all(8.0),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: <Widget>[
                Text(
                  restaurant.cuisines,
                  style: textTheme.subtitle.copyWith(fontSize: 18),
                ),
                Text(
                  restaurant.address,
                  style: TextStyle(fontSize: 18, fontWeight: FontWeight.w100),
                ),
              ],
            ),
          ),
          _buildDetails(context),
          _buildFavoriteButton(context)
        ],
      ),
    );
  }

  Widget _buildBanner() {
    return ImageContainer(
      height: 200,
      url: restaurant.imageUrl,
    );
  }

  Widget _buildDetails(BuildContext context) {
    final style = TextStyle(fontSize: 16);

    return Padding(
      padding: EdgeInsets.only(left: 10),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.start,
        children: <Widget>[
          Text(
            'Price: ${restaurant.priceDisplay}',
            style: style,
          ),
          SizedBox(width: 40),
          Text(
            'Rating: ${restaurant.rating.average}',
            style: style,
          ),
        ],
      ),
    );
  }

  // 1
  Widget _buildFavoriteButton(BuildContext context) {
    final bloc = BlocProvider.of<FavoriteBloc>(context);
    return StreamBuilder<List<Restaurant>>(
      stream: bloc.favoritesStream,
      initialData: bloc.favorites,
      builder: (context, snapshot) {
        List<Restaurant> favorites =
            (snapshot.connectionState == ConnectionState.waiting)
                ? bloc.favorites
                : snapshot.data;
        bool isFavorite = favorites.contains(restaurant);

        return FlatButton.icon(
          // 2
          onPressed: () => bloc.toggleRestaurant(restaurant),
          textColor: isFavorite ? Theme.of(context).accentColor : null,
          icon: Icon(isFavorite ? Icons.favorite : Icons.favorite_border),
          label: Text('Favorite'),
        );
      },
    );
  }
}

程式碼解析:

  1. 這個元件使用了FavoriteBloc來判斷某個餐廳是否是最喜歡的餐廳,並對應的更新介面
  2. FavoriteBloc#toggleRestaurant方法可以讓元件不用去關心某個餐廳是不是最喜歡的。

restaurant_tile.dart檔案的onTap方法裡新增下面的程式碼:

onTap: () {
  Navigator.of(context).push(
    MaterialPageRoute(
      builder: (context) =>
          RestaurantDetailsScreen(restaurant: restaurant),
    ),
  );
},

執行程式碼:

更新定位

如果使用者想要更新他們查詢的定位呢?現在如果你更改了位置,那麼app就要重啟才行。

因為你已經讓程式碼工作在流傳遞過來的一組資料上了,那麼新增一個功能就變得非常的簡單,就像在蛋糕上放一個櫻桃那麼簡單。

在餐廳頁,新增一個浮動按鈕。點下這個按鈕之後就會把定位頁面彈出來。

 ...
    body: _buildSearch(context),
    floatingActionButton: FloatingActionButton(
      child: Icon(Icons.edit_location),
      onPressed: () => Navigator.of(context).push(MaterialPageRoute(
          builder: (context) => LocationScreen(
                // 1
                isFullScreenDialog: true,
              ),
          fullscreenDialog: true)),
    ),
  );
}

// 1isFullScreenDialog設定為true,這樣定位頁彈出之後就會顯示為全屏。

LocationScreenLisTile#onTap方法是這麼使用isFullScreenDialog的:

onTap: () {
  final locationBloc = BlocProvider.of<LocationBloc>(context);
  locationBloc.selectLocation(location);
  if (isFullScreenDialog) {
    Navigator.of(context).pop();
  }
},

這麼做是為了可以在定位也作為對話方塊顯示的時候也可以去掉。

再次執行程式碼你會看到一個浮動按鈕,點了之後就會彈出定位頁。

最後

祝賀你已經學會了BLoC模式。BLoC是一個簡單而強大的app狀態管理模式。

你可以在本例裡下載到最終的專案程式碼。如果要執行起來的話,千萬記住要先從zomato獲得一個app key並且更新zomato_client.dart程式碼(不要放到程式碼版本控制裡,比如github等)。其他可以看的模式:

也可以檢視官方文件,或者Google IO的視訊

希望你喜歡這個BLoC教程,有什麼問題可以留在評論區裡。

相關文章