原文:Flutter: BLoC 模式入門教程
瞭解如何使用流行的 BLoC 模式來構建 Flutter 應用程序,並使用 Dart streams 管理通過 Widgets 的數據流。
設計應用程序的結構通常是應用程序開發中爭論最激烈的話題之一。每個人似乎都有他們最喜歡的、帶有花哨首字母縮略詞的架構模式。
iOS 和 Android 開發人員精通 Model-View-Controller(MVC),並將其作為構建應用程序的默認選擇。Model 和 View 是分開的,Controller 負責在它們之間發送信號。
然而, Flutter 帶來了一種新的響應式風格,其與 MVC 並不完全兼容。這個經典模式的一個變體已經出現在了 Flutter 社區 - 那就是 BLoC。
BLoC 代表 Business Logic Components。BLoC 的主旨是 app 中的所有內容都應該表現為事件流:部分 widgets 發送事件;其他的 widgets 進行響應。BloC 位於中間,管理這些會話。Dart 甚至提供了處理流的語法,這些語法已經融入到了語言中。
這種模式最好的地方是不需要導入任何插件,也不需要學習任何自定義語法。Flutter 本身已經包含了你需要的所有東西。
在本教程裏,你將創建一個 app,使用 Zomato 提供的 API 查找餐廳。在教程的結尾,這個 app 將完成下面的事情:
- 使用 BLoC 模式封裝 API 調用
- 搜索餐廳並異步顯示結果
- 維護收藏列表,並在多個頁面展示
準備開始
下載並使用你最喜歡的 IDE 打開 starter 項目工程。本教程將使用 Android Studio,如果你喜歡使用 Visual Studio Code 也完全可以。確保在命令行或 IDE 提示時運行 flutter packages get,以便下載最新版本的 http 包。
這個 starter 項目工程包含一些基礎的數據模型和網絡文件。打開項目時,應該如下圖所示:
這裏有3個文件用來和 Zomato 通信。
獲取 Zomato API Key
在開始構建 app 之前,需要獲取一個 API key。跳轉到 Zomato 開發者頁面 https://developers.zomato.com...,創建一個賬號,併產生一個新的 key。
打開 DataLayer 目錄下的 zomato_client.dart,修改類聲明中的常量:
class ZomatoClient {
final _apiKey = 'PASTE YOUR API KEY HERE';
...
Note: 產品級 app 的最佳實踐是,不要將 API key 存儲在源碼或 VCS(版本控制系統)中。最好是從一個配置文件中讀取,配置文件在構建 app 時從其他地方引入。
構建並運行這個工程,它將顯示一個空白的界面。
沒有什麼讓人興奮的,不是嗎?是時候改變它了。
讓我們烤一個夾心蛋糕
在寫應用程序的時候,將類分層進行組織是非常重要的,無論是使用 Flutter 還是使用其他的什麼框架。這更像是一種非正式的約定;並不是可以在代碼中看到的具象的東西。
每一層,或者一組類,負責一個具體的任務。starter 工程中有一個命名為 DataLayer 的目錄,這個數據層負責應用程序的數據模型和與後端服務器的通信,但它對 UI 一無所知。
每個項目工程都有輕微的不同,但總的來説,大體結構基本如下所示:
這種架構約定與經典的 MVC 並沒有太大的不同。 UI/Flutter 層只能與 BLoC 層通信。BLoC 層發送事件給數據層和 UI 層,同時處理業務邏輯。隨着應用程序功能的不斷增長,這種結構能夠很好的進行擴展。
深入剖析 BLoC
流(stream),和 Future 一樣,也是由 dart:async 包提供。流類似 Future,不同的是,Future 異步返回一個值,但流可以隨着時間的推移生產多個值。如果 Future 是一個最終將被提供的值,那麼流則是隨着時間推移零星的提供的一系列的值。
dart:async 包提供一個名叫 StreamController 的對象。StreamController 是實例化 stream 和 sink 的管理器對象。sink 是 stream 的對立面。stream 不斷的產生輸出,sink 不斷的接收輸入。
總而言之,BLoCs 是這樣一種實體,它們負責處理和存儲業務邏輯,使用 sinks 接收輸入數據,同時使用 stream 提供數據輸出。
位置頁面
在使用 app 找到適合吃飯的地方之前,需要告知 Zomato 你想在哪個地理位置就餐。在本章節,將創建一個簡單的頁面,包含一個頭部搜索區域和一個展示搜索結果的列表。
Note: 在輸入這些代碼示例之前,不要忘記打開 DartFmt 。它是保持 Flutter 應用程序代碼風格的唯一方法。
在工程的 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,用户可以在這裏輸入地理位置信息。
Note: 輸入類時,IDE 會提示錯誤,這是因為這些類沒有導入。要解決此問題,請將光標移到任何帶有紅色下劃線的符號上,然後,在 macOS 上按 option+enter(在 Windows/Linux 上按 Alt+Enter)或單擊紅色燈泡。將會彈出一個菜單,在菜單中選擇正確的文件進行導入。
創建另外一個文件,main_screen.dart,用來管理 app 的頁面流轉。添加下面的代碼到文件中:
class MainScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return LocationScreen();
}
}
最後,更新 main.dart 以返回新頁面。
MaterialApp(
title: 'Restaurant Finder',
theme: ThemeData(
primarySwatch: Colors.red,
),
home: MainScreen(),
),
構建並運行 app,看上去應該是這樣:
雖然比之前好了一些,但它仍然什麼都做不了。是時候創建一些 BLoC 了。
第一個 BLoC
在 lib 目錄下創建新的目錄 BLoC,所有的 BLoC 類將放置到這裏。
在該目錄下新建文件 bloc.dart,並添加如下代碼:
abstract class Bloc {
void dispose();
}
所有的 BLoC 類都將遵循這個接口。這個接口裏只有一個 dispose 方法。需要牢記的一點是,當不再需要流的時候,必須將其關閉,否則會產生內存泄漏。可以在 dispose 方法中檢查和釋放資源。
第一個 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+return 導入基類的時候,選擇第二個選項 - Import library package:restaurant_finder/BLoC/bloc.dart。
對所有錯誤提示使用 option+return,直到所有依賴都被正確導入。
LocationBloc 主要實現如下功能:
- 聲明瞭一個 private
StreamController,管理 BLoC 的 stream 和 sink。StreamController使用泛型告訴類型系統它將通過 stream 發送何種類型的對象。 - 這行暴露了一個 public 的 getter 方法,調用者通過該方法獲取
StreamController的 stream。 - 該方法是 BLoC 的輸入,接收一個
Location模型對象,將其緩存到私有成員屬性_location,並添加到流的接收器(sink)中。 - 最後,當這個 BLoC 對象被釋放時,在清理方法中關閉
StreamController。否則 IDE 會提示StreamController存在內存泄漏。
到目前為止,第一個 BLoC 已經完成,接下來創建一個查找位置的 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 處,是 BLoC 輸入端,該方法接收一個字符串類型參數,使用 start 工程中的 ZomatoClient 類從 API 獲取位置信息。Dart 的 `async
/await` 語法可以使代碼更加簡潔。結果返回後將其發佈到流(stream)中。
這個 BLoC 與上一個幾乎相同,只是這個 BLoC 不僅存儲和報告位置,還封裝了一個 API 調用。
將 BLoC 注入到 Widget Tree
現在已經建立了兩個 BLoC,需要一種方式將它們注入到 Flutter 的 widget 樹。使用 provider 類型的 weidget 已成為Flutter的慣例。一個 provider 就是一個存儲數據的 widget,它能夠將數據很好的提供給它所有的子 widget。
通常這是 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();
}
}
代碼解讀如下:
BlocProvider是一個泛型類,泛型T被限定為一個實現了BLoC接口的對象。意味着這個 provider 只能存儲 BLoC 對象。of方法允許 widget tree 的子孫節點使用當前的 build context 檢索BlocProvider。在 Flutter 裏這是非常常見的模式。- 這是獲取泛型類型引用的通用方式。
build方法只是返回了 widget 的 child,並沒有渲染任何東西。- 最後,這個 provider 繼承自
StatefulWidget的唯一原因是需要訪問dispose方法。當 widget 從 widget tree 中移除,Flutter 將調用 dispose 方法,該方法將依次關閉流。
對接位置頁面
現在已經完成了用於查找位置的 BLoC 層,下面將使用該層。
首選,在 main.dart 文件裏,在 material app 的上層放置一個 Location BLoC,用於存儲應用狀態。最簡單的方法是,將光標移動到 MaterialApp 上方,按下 option+return (Windows/Linux 上是 Alt+Enter),在彈出的菜單中選擇 Wrap with a new widget。
Note: 此代碼片段的靈感來自 Didier Boelens 的這篇精彩文章 Reactive Programming — Streams — BLoC。這個 widget 沒有做任何優化,理論上是可以改進的。出於本文的目的,我們仍然使用這種簡單的方法,它在大部分情況下完全可以接受。如果在 app 生命週期的後期發現它引起了性能問題,可以在 Flutter BLoC Package 中找到更全面的解決方案。
使用 LocationBloc 類型的 BlocProvider 進行包裝,並在 bloc 屬性位置創建一個 LocationBloc 實例。
return BlocProvider<LocationBloc>(
bloc: LocationBloc(),
child: MaterialApp(
title: 'Restaurant Finder',
theme: ThemeData(
primarySwatch: Colors.red,
),
home: MainScreen(),
),
);
在 material app 的上層添加 widget,在 widget 裏添加數據,這是在多個頁面共享訪問數據的好方式。
在主界面 main_screen.dart 中需要做類似的事情。在 LocationScreen widget 上方點擊 option+return,這次選擇 ‘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 模式如此美味的秘製醬汁。這些 widget 將自動監聽來自 stream 的事件。當一個新的事件到達,builder 閉包函數將被執行來更新 widget tree。使用 StreamBuilder 和 BLoC 模式,在整個教程中都不需要調用 setState() 方法。
在上面的代碼中:
- 對於
stream屬性,使用of方法獲取LocationBloc並將其 stream 添加到StreamBuilder中。 - 最初 stream 裏沒有數據,這是完全正常的。如果沒有數據,返回
LocationScreen。否則,現在僅返回一個空白容器。
下一步,使用之前創建的 LocationQueryBloc 更新 location_screen.dart 中的位置頁面。不要忘記使用 IDE 提供的 widget 包裝工具更輕鬆地更新代碼。
@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),
)
],
),
),
);
}
在這段代碼裏:
- 首先,在 build 方法的開始部分實例化了一個新的
LocationQueryBloc對象。 - 將 BLoC 存儲在
BlocProvider中,BlocProvider 將管理 BLoC的生命週期。 - 更新
TextField的onChanged閉包方法,傳遞文本到LocationQueryBloc。這將觸發獲取數據的調用鏈,首先調用 Zomato,然後將返回的位置信息發送到 stream 中。 - 將 bloc 傳遞給
_buildResults方法。
在 LocationScreen 中添加一個 boolean 字段,用來跟蹤這個頁面是否是全屏對話框:
class LocationScreen extends StatelessWidget {
final bool isFullScreenDialog;
const LocationScreen({Key key, this.isFullScreenDialog = false})
: super(key: key);
...
這個 boolean 字段僅僅是一個簡單標誌位(默認值為 false),稍後點擊位置信息的時候,用來更新頁面導航行為。
現在更新 _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();
}
},
);
},
);
}
在上面的代碼中:
- stream 有三個條件分支,返回不同的結果。可能沒有數據,意味着用户沒有輸入任何信息;可能是一個空的列表,意味着 Zomato 找不到任何你想要查找的內容;最後,可能是一個完整的餐廳列表,意味着每一件事都做的很完美。
- 這裏展示位置信息列表。這個方法的行為就是普通的聲明式 Flutter 代碼。
- 在
onTap閉包中,應用程序檢索位於樹根部的LocationBloc,並告訴它用户已經選擇了一個位置。點擊列表項將會導致整個屏幕暫時變黑。
繼續構建並運行,該應用程序應該從 Zomato 獲取位置結果並將它們顯示在列表中。
很好!這是真正的進步。
餐廳頁面
這個 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 一樣,唯一的不同是 API 和返回的數據類型。
在 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.dart 文件中的 MainScreen,當得到位置信息後返回一個餐廳頁面。
builder: (context, snapshot) {
final location = snapshot.data;
if (location == null) {
return LocationScreen();
}
return RestaurantScreen(location: location);
},
Hot restart 這個 app。選中一個位置,然後搜索想吃的東西,一個餐廳的列表會出現在你面前。
看上去很美味。這是誰準備吃蛋糕了?
收藏餐廳
到目前為止,BLoC 模式已被用來管理用户輸入,但遠不止於此。假設用户想要跟蹤他們最喜歡的餐廳並將其顯示在單獨的列表中。這也可以通過 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 這裏,BLoC 使用一個 Broadcast StreamController 代替常規的 StreamController。廣播 stream 允許多個監聽者,但常規 stream 只允許一個。前面兩個 bloc 不需要廣播流,因為只有一個一對一的關係。對於收藏功能,有兩個地方需要同時監聽 stream,所以廣播在這裏是需要的。
Note: 作為通用規則,在設計 BLoC 的時候,應該優先使用常規 stream,當後面發現需要廣播的時候,再將代碼修改成使用廣播 stream。當多個對象嘗試監聽同一個常規 stream 的時候,Flutter 會拋出異常。可以將此看作是需要修改代碼的標誌。
這個 BLoC 需要從多個頁面訪問,意味着需要將其放置在導航器的上方。更新 main.dart 文件,再添加一個 widget,包裹在 MaterialApp 外面,並且在原來的 provider 裏面。
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。這個 widget 將用於展示收藏的餐廳列表:
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);
},
);
},
),
);
}
}
在這個 widget 裏:
- 添加初始化數據到
StreamBuilder。StreamBuilder將立即觸發對 builder 閉包的執行,即使沒有任何數據。這允許 Flutter 確保快照(snapshot)始終有數據,而不是毫無必要的重繪頁面。 - 檢測 stream 的狀態,如果這時還沒有建立鏈接,則使用明確的收藏餐廳列表代替 stream 中發送的新事件。
更新餐廳頁面的 build 方法,添加一個 action,當點擊事件觸發時將收藏餐廳頁面添加到導航棧中。
@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'),
);
},
);
}
}
在上面代碼中:
- 這個 widget 使用收藏 stream 檢測餐廳是否已被收藏,然後渲染適合的 widget。
FavoriteBloc中的toggleRestaurant方法的實現,使得 UI 不需要關心餐廳的狀態。如果餐廳不在收藏列表中,它將會被添加進來;反之,如果餐廳在收藏列表中,它將會被刪除。
在 restaurant_tile.dart 文件中添加 onTap 閉包,用來將這個新的頁面添加到 app 中。
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) =>
RestaurantDetailsScreen(restaurant: restaurant),
),
);
},
構建並運行這個 app。
用户應該可以收藏、取消收藏和查看收藏列表了。甚至可以從收藏餐廳頁面中刪除餐廳,而無需添加額外的代碼。這就是流(stream)的力量!
更新位置信息
如果用户想更改他們正在搜索的位置怎麼辦?現在的代碼實現,如果想更改位置信息,必須重新啓動這個 app。
因為已經將 app 的工作設置為基於一系列的流,所以添加這個功能簡直不費吹灰之力的。甚至就像是在蛋糕上放一顆櫻桃一樣簡單!
在餐廳頁面添加一個 floating action button,並將位置頁面以模態方式展示:
...
body: _buildSearch(context),
floatingActionButton: FloatingActionButton(
child: Icon(Icons.edit_location),
onPressed: () => Navigator.of(context).push(MaterialPageRoute(
builder: (context) => LocationScreen(
// 1
isFullScreenDialog: true,
),
fullscreenDialog: true)),
),
);
}
在 // 1 處,設置 isFullScreenDialog 的值為 true。這是我們之前添加到位置頁面的。
之前在為 LocationScreen 編寫的 ListTile 中,添加 onTap 閉包時使用過這個標誌。
onTap: () {
final locationBloc = BlocProvider.of<LocationBloc>(context);
locationBloc.selectLocation(location);
if (isFullScreenDialog) {
Navigator.of(context).pop();
}
},
這樣做的原因是,如果位置頁面是以模態方式展現的,需要將它從導航棧中移除。如果沒有這個代碼,當點擊 ListTile 時,什麼都不會發生。位置信息 stream 將被更新,但 UI 不會有任何響應。
最後一次構建並運行這個 app。你將看到一個 floating action button,當點擊該按鈕時,將以模態方式展示位置頁面。
然後去哪?
恭喜你掌握了 BLoC 模式。 BLoC 是一種簡單但功能強大的模式,可以幫助你輕鬆馴服 app 的狀態管理,因為它可以在 widget tree 上上下飛舞。
可以在本教程的 Download Materials 中找到最終的示例項目工程,如果想運行最終的示例項目,需要先把你的 API key 添加到 zomato_client.dart。
其他值得一看的架構模式有:
- Provider - https://pub.dev/packages/prov...
- Scoped Model - https://pub.dev/packages/scop...
- RxDart - https://pub.dev/packages/rxdart
- Redux - https://pub.dev/packages/redux
同時請查閲 流 (stream) 的官方文檔,和關於 BLoC 模式的 Google IO 討論。
希望你喜歡本 Flutter BLoC 教程。與往常一樣,如果有任何問題或意見,請隨時聯繫我,或者在下面評論!