了解如何使用流行的 BLoC 模式來(lái)構(gòu)建 Flutter 應(yīng)用程序,并使用 Dart streams 管理通過(guò) Widgets 的數(shù)據(jù)流乡恕。
設(shè)計(jì)應(yīng)用程序的結(jié)構(gòu)通常是應(yīng)用程序開發(fā)中爭(zhēng)論最激烈的話題之一言询。每個(gè)人似乎都有他們最喜歡的、帶有花哨首字母縮略詞的架構(gòu)模式傲宜。
iOS 和 Android 開發(fā)人員精通 Model-View-Controller(MVC)运杭,并將其作為構(gòu)建應(yīng)用程序的默認(rèn)選擇。Model 和 View 是分開的函卒,Controller 負(fù)責(zé)在它們之間發(fā)送信號(hào)辆憔。
然而, Flutter 帶來(lái)了一種新的響應(yīng)式風(fēng)格报嵌,其與 MVC 并不完全兼容虱咧。這個(gè)經(jīng)典模式的一個(gè)變體已經(jīng)出現(xiàn)在了 Flutter 社區(qū) - 那就是 BLoC。
BLoC 代表 Business Logic Components锚国。BLoC 的主旨是 app 中的所有內(nèi)容都應(yīng)該表現(xiàn)為事件流:部分 widgets 發(fā)送事件彤钟;其他的 widgets 進(jìn)行響應(yīng)。BloC 位于中間跷叉,管理這些會(huì)話。Dart 甚至提供了處理流的語(yǔ)法营搅,這些語(yǔ)法已經(jīng)融入到了語(yǔ)言中云挟。
這種模式最好的地方是不需要導(dǎo)入任何插件,也不需要學(xué)習(xí)任何自定義語(yǔ)法转质。Flutter 本身已經(jīng)包含了你需要的所有東西园欣。
在本教程里,你將創(chuàng)建一個(gè) app休蟹,使用 Zomato 提供的 API 查找餐廳沸枯。在教程的結(jié)尾,這個(gè) app 將完成下面的事情:
- 使用 BLoC 模式封裝 API 調(diào)用
- 搜索餐廳并異步顯示結(jié)果
- 維護(hù)收藏列表赂弓,并在多個(gè)頁(yè)面展示
準(zhǔn)備開始
下載并使用你最喜歡的 IDE 打開 starter 項(xiàng)目工程绑榴。本教程將使用 Android Studio,如果你喜歡使用 Visual Studio Code 也完全可以盈魁。確保在命令行或 IDE 提示時(shí)運(yùn)行 flutter packages get
翔怎,以便下載最新版本的 http 包。
這個(gè) starter 項(xiàng)目工程包含一些基礎(chǔ)的數(shù)據(jù)模型和網(wǎng)絡(luò)文件。打開項(xiàng)目時(shí)赤套,應(yīng)該如下圖所示:
這里有3個(gè)文件用來(lái)和 Zomato 通信飘痛。
獲取 Zomato API Key
在開始構(gòu)建 app 之前,需要獲取一個(gè) API key容握。跳轉(zhuǎn)到 Zomato 開發(fā)者頁(yè)面 https://developers.zomato.com/api宣脉,創(chuàng)建一個(gè)賬號(hào),并產(chǎn)生一個(gè)新的 key剔氏。
打開 DataLayer 目錄下的 zomato_client.dart塑猖,修改類聲明中的常量:
class ZomatoClient {
final _apiKey = 'PASTE YOUR API KEY HERE';
...
Note: 產(chǎn)品級(jí) app 的最佳實(shí)踐是,不要將 API key 存儲(chǔ)在源碼或 VCS(版本控制系統(tǒng))中介蛉。最好是從一個(gè)配置文件中讀取萌庆,配置文件在構(gòu)建 app 時(shí)從其他地方引入。
構(gòu)建并運(yùn)行這個(gè)工程币旧,它將顯示一個(gè)空白的界面践险。
沒有什么讓人興奮的,不是嗎吹菱?是時(shí)候改變它了巍虫。
讓我們烤一個(gè)夾心蛋糕
在寫應(yīng)用程序的時(shí)候,將類分層進(jìn)行組織是非常重要的鳍刷,無(wú)論是使用 Flutter
還是使用其他的什么框架占遥。這更像是一種非正式的約定;并不是可以在代碼中看到的具象的東西输瓜。
每一層瓦胎,或者一組類,負(fù)責(zé)一個(gè)具體的任務(wù)尤揣。starter 工程中有一個(gè)命名為 DataLayer 的目錄搔啊,這個(gè)數(shù)據(jù)層負(fù)責(zé)應(yīng)用程序的數(shù)據(jù)模型和與后端服務(wù)器的通信,但它對(duì) UI 一無(wú)所知北戏。
每個(gè)項(xiàng)目工程都有輕微的不同负芋,但總的來(lái)說(shuō),大體結(jié)構(gòu)基本如下所示:
這種架構(gòu)約定與經(jīng)典的 MVC 并沒有太大的不同嗜愈。 UI/Flutter 層只能與 BLoC 層通信旧蛾。BLoC 層發(fā)送事件給數(shù)據(jù)層和 UI 層,同時(shí)處理業(yè)務(wù)邏輯蠕嫁。隨著應(yīng)用程序功能的不斷增長(zhǎng)锨天,這種結(jié)構(gòu)能夠很好的進(jìn)行擴(kuò)展。
深入剖析 BLoC
流(stream)拌阴,和 Future 一樣绍绘,也是由 dart:async
包提供。流類似 Future,不同的是陪拘,F(xiàn)uture 異步返回一個(gè)值厂镇,但流可以隨著時(shí)間的推移生產(chǎn)多個(gè)值。如果 Future 是一個(gè)最終將被提供的值左刽,那么流則是隨著時(shí)間推移零星的提供的一系列的值捺信。
dart:async
包提供一個(gè)名叫 StreamController 的對(duì)象。StreamController 是實(shí)例化 stream 和 sink 的管理器對(duì)象欠痴。sink 是 stream 的對(duì)立面迄靠。stream 不斷的產(chǎn)生輸出,sink 不斷的接收輸入喇辽。
總而言之掌挚,BLoCs 是這樣一種實(shí)體,它們負(fù)責(zé)處理和存儲(chǔ)業(yè)務(wù)邏輯菩咨,使用 sinks 接收輸入數(shù)據(jù)吠式,同時(shí)使用 stream 提供數(shù)據(jù)輸出。
位置頁(yè)面
在使用 app 找到適合吃飯的地方之前抽米,需要告知 Zomato 你想在哪個(gè)地理位置就餐特占。在本章節(jié),將創(chuàng)建一個(gè)簡(jiǎn)單的頁(yè)面云茸,包含一個(gè)頭部搜索區(qū)域和一個(gè)展示搜索結(jié)果的列表是目。
Note: 在輸入這些代碼示例之前,不要忘記打開 DartFmt 标捺。它是保持 Flutter 應(yīng)用程序代碼風(fēng)格的唯一方法懊纳。
在工程的 lib/UI 目錄下,創(chuàng)建一個(gè)名為 location_screen.dart 的新文件亡容。在文件中添加一個(gè) StatelessWidget
的擴(kuò)展類长踊,命名為 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'));
}
}
位置頁(yè)面包含一個(gè) TextField
,用戶可以在這里輸入地理位置信息萍倡。
Note: 輸入類時(shí),IDE 會(huì)提示錯(cuò)誤辟汰,這是因?yàn)檫@些類沒有導(dǎo)入列敲。要解決此問(wèn)題,請(qǐng)將光標(biāo)移到任何帶有紅色下劃線的符號(hào)上帖汞,然后戴而,在 macOS 上按 option+enter(在 Windows/Linux 上按 Alt+Enter)或單擊紅色燈泡。將會(huì)彈出一個(gè)菜單翩蘸,在菜單中選擇正確的文件進(jìn)行導(dǎo)入所意。
創(chuàng)建另外一個(gè)文件,main_screen.dart,用來(lái)管理 app 的頁(yè)面流轉(zhuǎn)扶踊。添加下面的代碼到文件中:
class MainScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return LocationScreen();
}
}
最后泄鹏,更新 main.dart 以返回新頁(yè)面。
MaterialApp(
title: 'Restaurant Finder',
theme: ThemeData(
primarySwatch: Colors.red,
),
home: MainScreen(),
),
構(gòu)建并運(yùn)行 app秧耗,看上去應(yīng)該是這樣:
雖然比之前好了一些备籽,但它仍然什么都做不了。是時(shí)候創(chuàng)建一些 BLoC 了分井。
第一個(gè) BLoC
在 lib 目錄下創(chuàng)建新的目錄 BLoC车猬,所有的 BLoC 類將放置到這里。
在該目錄下新建文件 bloc.dart
尺锚,并添加如下代碼:
abstract class Bloc {
void dispose();
}
所有的 BLoC 類都將遵循這個(gè)接口珠闰。這個(gè)接口里只有一個(gè) dispose
方法。需要牢記的一點(diǎn)是瘫辩,當(dāng)不再需要流的時(shí)候伏嗜,必須將其關(guān)閉,否則會(huì)產(chǎn)生內(nèi)存泄漏杭朱≡淖校可以在 dispose
方法中檢查和釋放資源。
第一個(gè) BLoC 將負(fù)責(zé)管理 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 導(dǎo)入基類的時(shí)候,選擇第二個(gè)選項(xiàng) - Import library package:restaurant_finder/BLoC/bloc.dart刃唐。
對(duì)所有錯(cuò)誤提示使用 option+return羞迷,直到所有依賴都被正確導(dǎo)入。
LocationBloc
主要實(shí)現(xiàn)如下功能:
- 聲明了一個(gè) private
StreamController
画饥,管理 BLoC 的 stream 和 sink衔瓮。StreamController
使用泛型告訴類型系統(tǒng)它將通過(guò) stream 發(fā)送何種類型的對(duì)象。 - 這行暴露了一個(gè) public 的 getter 方法抖甘,調(diào)用者通過(guò)該方法獲取
StreamController
的 stream热鞍。 - 該方法是 BLoC 的輸入,接收一個(gè)
Location
模型對(duì)象衔彻,將其緩存到私有成員屬性_location
薇宠,并添加到流的接收器(sink)中。 - 最后艰额,當(dāng)這個(gè) BLoC 對(duì)象被釋放時(shí)澄港,在清理方法中關(guān)閉
StreamController
。否則 IDE 會(huì)提示StreamController
存在內(nèi)存泄漏柄沮。
到目前為止回梧,第一個(gè) BLoC 已經(jīng)完成憨栽,接下來(lái)創(chuàng)建一個(gè)查找位置的 BLoC寓调。
第二個(gè) 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 輸入端措译,該方法接收一個(gè)字符串類型參數(shù)螟加,使用 start 工程中的 ZomatoClient
類從 API 獲取位置信息杂数。Dart 的 async
/await
語(yǔ)法可以使代碼更加簡(jiǎn)潔年叮。結(jié)果返回后將其發(fā)布到流(stream)中。
這個(gè) BLoC 與上一個(gè)幾乎相同纬纪,只是這個(gè) BLoC 不僅存儲(chǔ)和報(bào)告位置蚓再,還封裝了一個(gè) API 調(diào)用。
將 BLoC 注入到 Widget Tree
現(xiàn)在已經(jīng)建立了兩個(gè) BLoC包各,需要一種方式將它們注入到 Flutter 的 widget 樹摘仅。使用 provider
類型的 weidget 已成為Flutter的慣例。一個(gè) provider 就是一個(gè)存儲(chǔ)數(shù)據(jù)的 widget问畅,它能夠?qū)?shù)據(jù)很好的提供給它所有的子 widget娃属。
通常這是 InheritedWidget
的工作,但由于 BLoC 對(duì)象需要被釋放护姆,StatefulWidget
將提供相同的功能矾端。雖然語(yǔ)法有點(diǎn)復(fù)雜,但結(jié)果是一樣的卵皂。
在 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
是一個(gè)泛型類,泛型T
被限定為一個(gè)實(shí)現(xiàn)了BLoC
接口的對(duì)象灯变。意味著這個(gè) provider 只能存儲(chǔ) BLoC 對(duì)象殴玛。 -
of
方法允許 widget tree 的子孫節(jié)點(diǎn)使用當(dāng)前的 build context 檢索BlocProvider
。在 Flutter 里這是非常常見的模式添祸。 - 這是獲取泛型類型引用的通用方式滚粟。
-
build
方法只是返回了 widget 的 child,并沒有渲染任何東西刃泌。 - 最后凡壤,這個(gè) provider 繼承自
StatefulWidget
的唯一原因是需要訪問(wèn)dispose
方法。當(dāng) widget 從 widget tree 中移除耙替,F(xiàn)lutter 將調(diào)用 dispose 方法鲤遥,該方法將依次關(guān)閉流。
對(duì)接位置頁(yè)面
現(xiàn)在已經(jīng)完成了用于查找位置的 BLoC 層林艘,下面將使用該層。
首選混坞,在 main.dart
文件里狐援,在 material app 的上層放置一個(gè) Location BLoC钢坦,用于存儲(chǔ)應(yīng)用狀態(tài)。最簡(jiǎn)單的方法是啥酱,將光標(biāo)移動(dòng)到 MaterialApp 上方爹凹,按下 option+return (Windows/Linux 上是 Alt+Enter),在彈出的菜單中選擇 Wrap with a new widget镶殷。
Note: 此代碼片段的靈感來(lái)自 Didier Boelens 的這篇精彩文章 Reactive Programming — Streams — BLoC禾酱。這個(gè) widget 沒有做任何優(yōu)化,理論上是可以改進(jìn)的绘趋。出于本文的目的颤陶,我們?nèi)匀皇褂眠@種簡(jiǎn)單的方法,它在大部分情況下完全可以接受陷遮。如果在 app 生命周期的后期發(fā)現(xiàn)它引起了性能問(wèn)題滓走,可以在 Flutter BLoC Package 中找到更全面的解決方案。
使用 LocationBloc
類型的 BlocProvider
進(jìn)行包裝帽馋,并在 bloc
屬性位置創(chuàng)建一個(gè) LocationBloc
實(shí)例搅方。
return BlocProvider<LocationBloc>(
bloc: LocationBloc(),
child: MaterialApp(
title: 'Restaurant Finder',
theme: ThemeData(
primarySwatch: Colors.red,
),
home: MainScreen(),
),
);
在 material app 的上層添加 widget,在 widget 里添加數(shù)據(jù)绽族,這是在多個(gè)頁(yè)面共享訪問(wèn)數(shù)據(jù)的好方式姨涡。
在主界面 main_screen.dart 中需要做類似的事情。在 LocationScreen
widget 上方點(diǎn)擊 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 將自動(dòng)監(jiān)聽來(lái)自 stream 的事件娄蔼。當(dāng)一個(gè)新的事件到達(dá)怖喻,builder 閉包函數(shù)將被執(zhí)行來(lái)更新 widget tree。使用 StreamBuilder
和 BLoC 模式岁诉,在整個(gè)教程中都不需要調(diào)用 setState() 方法锚沸。
在上面的代碼中:
- 對(duì)于
stream
屬性,使用of
方法獲取LocationBloc
并將其 stream 添加到StreamBuilder
中涕癣。 - 最初 stream 里沒有數(shù)據(jù)哗蜈,這是完全正常的。如果沒有數(shù)據(jù)坠韩,返回
LocationScreen
距潘。否則,現(xiàn)在僅返回一個(gè)空白容器只搁。
下一步音比,使用之前創(chuàng)建的 LocationQueryBloc
更新 location_screen.dart
中的位置頁(yè)面。不要忘記使用 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 方法的開始部分實(shí)例化了一個(gè)新的
LocationQueryBloc
對(duì)象稽犁。 - 將 BLoC 存儲(chǔ)在
BlocProvider
中,BlocProvider 將管理 BLoC的生命周期骚亿。 - 更新
TextField
的onChanged
閉包方法已亥,傳遞文本到LocationQueryBloc
。這將觸發(fā)獲取數(shù)據(jù)的調(diào)用鏈来屠,首先調(diào)用 Zomato虑椎,然后將返回的位置信息發(fā)送到 stream 中。 - 將 bloc 傳遞給
_buildResults
方法俱笛。
在 LocationScreen
中添加一個(gè) boolean 字段捆姜,用來(lái)跟蹤這個(gè)頁(yè)面是否是全屏對(duì)話框:
class LocationScreen extends StatelessWidget {
final bool isFullScreenDialog;
const LocationScreen({Key key, this.isFullScreenDialog = false})
: super(key: key);
...
這個(gè) boolean 字段僅僅是一個(gè)簡(jiǎn)單標(biāo)志位(默認(rèn)值為 false),稍后點(diǎn)擊位置信息的時(shí)候嫂粟,用來(lái)更新頁(yè)面導(dǎo)航行為娇未。
現(xiàn)在更新 _buildResults
方法,添加一個(gè) stream builder 并將結(jié)果顯示在一個(gè)列表中星虹。使用 ‘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 有三個(gè)條件分支,返回不同的結(jié)果宽涌∑揭梗可能沒有數(shù)據(jù),意味著用戶沒有輸入任何信息卸亮;可能是一個(gè)空的列表忽妒,意味著 Zomato 找不到任何你想要查找的內(nèi)容;最后兼贸,可能是一個(gè)完整的餐廳列表段直,意味著每一件事都做的很完美。
- 這里展示位置信息列表溶诞。這個(gè)方法的行為就是普通的聲明式 Flutter 代碼鸯檬。
- 在
onTap
閉包中,應(yīng)用程序檢索位于樹根部的LocationBloc
螺垢,并告訴它用戶已經(jīng)選擇了一個(gè)位置喧务。點(diǎn)擊列表項(xiàng)將會(huì)導(dǎo)致整個(gè)屏幕暫時(shí)變黑。
繼續(xù)構(gòu)建并運(yùn)行枉圃,該應(yīng)用程序應(yīng)該從 Zomato 獲取位置結(jié)果并將它們顯示在列表中功茴。
很好!這是真正的進(jìn)步孽亲。
餐廳頁(yè)面
這個(gè) app 的第二個(gè)頁(yè)面將根據(jù)搜索查詢的結(jié)果顯示餐廳列表坎穿。它也有自己的 BLoC 對(duì)象,用來(lái)管理頁(yè)面狀態(tài)返劲。
在 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 和返回的數(shù)據(jù)類型。
在 UI 目錄下創(chuàng)建文件 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);
},
);
}
}
新建一個(gè)獨(dú)立的 restaurant_tile.dart 文件,用于顯示餐廳的詳細(xì)信息:
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),
);
}
}
代碼和位置頁(yè)面的非常相似搔耕,幾乎是一樣的隙袁。唯一不同的是這里顯示的是餐廳而不是位置信息。
修改 main_screen.dart 文件中的 MainScreen
弃榨,當(dāng)?shù)玫轿恢眯畔⒑蠓祷匾粋€(gè)餐廳頁(yè)面菩收。
builder: (context, snapshot) {
final location = snapshot.data;
if (location == null) {
return LocationScreen();
}
return RestaurantScreen(location: location);
},
Hot restart 這個(gè) app。選中一個(gè)位置鲸睛,然后搜索想吃的東西娜饵,一個(gè)餐廳的列表會(huì)出現(xiàn)在你面前。
看上去很美味官辈。這是誰(shuí)準(zhǔn)備吃蛋糕了箱舞?
收藏餐廳
到目前為止,BLoC 模式已被用來(lái)管理用戶輸入拳亿,但遠(yuǎn)不止于此晴股。假設(shè)用戶想要跟蹤他們最喜歡的餐廳并將其顯示在單獨(dú)的列表中。這也可以通過(guò) BLoC 模式解決肺魁。
在 BLoC 目錄下為 BLoC 新建文件 favorite_bloc.dart电湘,用于存儲(chǔ)這個(gè)列表:
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 使用一個(gè) Broadcast StreamController
代替常規(guī)的 StreamController
鹅经。廣播 stream 允許多個(gè)監(jiān)聽者寂呛,但常規(guī) stream 只允許一個(gè)。前面兩個(gè) bloc 不需要廣播流瘾晃,因?yàn)橹挥幸粋€(gè)一對(duì)一的關(guān)系贷痪。對(duì)于收藏功能,有兩個(gè)地方需要同時(shí)監(jiān)聽 stream酗捌,所以廣播在這里是需要的呢诬。
Note: 作為通用規(guī)則,在設(shè)計(jì) BLoC 的時(shí)候胖缤,應(yīng)該優(yōu)先使用常規(guī) stream尚镰,當(dāng)后面發(fā)現(xiàn)需要廣播的時(shí)候,再將代碼修改成使用廣播 stream哪廓。當(dāng)多個(gè)對(duì)象嘗試監(jiān)聽同一個(gè)常規(guī) stream 的時(shí)候狗唉,F(xiàn)lutter 會(huì)拋出異常∥姓妫可以將此看作是需要修改代碼的標(biāo)志分俯。
這個(gè) BLoC 需要從多個(gè)頁(yè)面訪問(wèn)肾筐,意味著需要將其放置在導(dǎo)航器的上方。更新 main.dart 文件缸剪,再添加一個(gè) widget吗铐,包裹在 MaterialApp
外面,并且在原來(lái)的 provider 里面杏节。
return BlocProvider<LocationBloc>(
bloc: LocationBloc(),
child: BlocProvider<FavoriteBloc>(
bloc: FavoriteBloc(),
child: MaterialApp(
title: 'Restaurant Finder',
theme: ThemeData(
primarySwatch: Colors.red,
),
home: MainScreen(),
),
),
);
接下來(lái)在 UI 目錄下新建文件 favorite_screen.dart唬渗。這個(gè) 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);
},
);
},
),
);
}
}
在這個(gè) widget 里:
- 添加初始化數(shù)據(jù)到
StreamBuilder
。StreamBuilder
將立即觸發(fā)對(duì) builder 閉包的執(zhí)行奋渔,即使沒有任何數(shù)據(jù)镊逝。這允許 Flutter 確保快照(snapshot)始終有數(shù)據(jù)嫉鲸,而不是毫無(wú)必要的重繪頁(yè)面撑蒜。 - 檢測(cè) stream 的狀態(tài),如果這時(shí)還沒有建立鏈接玄渗,則使用明確的收藏餐廳列表代替 stream 中發(fā)送的新事件座菠。
更新餐廳頁(yè)面的 build
方法,添加一個(gè) action捻爷,當(dāng)點(diǎn)擊事件觸發(fā)時(shí)將收藏餐廳頁(yè)面添加到導(dǎo)航棧中辈灼。
@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),
);
}
還需要一個(gè)頁(yè)面,用來(lái)將餐廳添加到收藏餐廳中也榄。
在 UI 目錄下新建文件 restaurant_details_screen.dart巡莹。這個(gè)頁(yè)面大部分是靜態(tài)的布局代碼:
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'),
);
},
);
}
}
在上面代碼中:
- 這個(gè) widget 使用收藏 stream 檢測(cè)餐廳是否已被收藏,然后渲染適合的 widget甜紫。
-
FavoriteBloc
中的toggleRestaurant
方法的實(shí)現(xiàn)降宅,使得 UI 不需要關(guān)心餐廳的狀態(tài)。如果餐廳不在收藏列表中囚霸,它將會(huì)被添加進(jìn)來(lái)腰根;反之,如果餐廳在收藏列表中拓型,它將會(huì)被刪除额嘿。
在 restaurant_tile.dart 文件中添加 onTap
閉包,用來(lái)將這個(gè)新的頁(yè)面添加到 app 中劣挫。
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) =>
RestaurantDetailsScreen(restaurant: restaurant),
),
);
},
構(gòu)建并運(yùn)行這個(gè) app册养。
用戶應(yīng)該可以收藏、取消收藏和查看收藏列表了压固。甚至可以從收藏餐廳頁(yè)面中刪除餐廳球拦,而無(wú)需添加額外的代碼。這就是流(stream)的力量!
更新位置信息
如果用戶想更改他們正在搜索的位置怎么辦坎炼?現(xiàn)在的代碼實(shí)現(xiàn)愧膀,如果想更改位置信息,必須重新啟動(dòng)這個(gè) app谣光。
因?yàn)橐呀?jīng)將 app 的工作設(shè)置為基于一系列的流檩淋,所以添加這個(gè)功能簡(jiǎn)直不費(fèi)吹灰之力的。甚至就像是在蛋糕上放一顆櫻桃一樣簡(jiǎn)單萄金!
在餐廳頁(yè)面添加一個(gè) floating action button狼钮,并將位置頁(yè)面以模態(tài)方式展示:
...
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
處,設(shè)置 isFullScreenDialog
的值為 true
捡絮。這是我們之前添加到位置頁(yè)面的。
之前在為 LocationScreen
編寫的 ListTile
中莲镣,添加 onTap
閉包時(shí)使用過(guò)這個(gè)標(biāo)志福稳。
onTap: () {
final locationBloc = BlocProvider.of<LocationBloc>(context);
locationBloc.selectLocation(location);
if (isFullScreenDialog) {
Navigator.of(context).pop();
}
},
這樣做的原因是,如果位置頁(yè)面是以模態(tài)方式展現(xiàn)的瑞侮,需要將它從導(dǎo)航棧中移除的圆。如果沒有這個(gè)代碼,當(dāng)點(diǎn)擊 ListTile
時(shí)半火,什么都不會(huì)發(fā)生越妈。位置信息 stream 將被更新,但 UI 不會(huì)有任何響應(yīng)钮糖。
最后一次構(gòu)建并運(yùn)行這個(gè) app梅掠。你將看到一個(gè) floating action button,當(dāng)點(diǎn)擊該按鈕時(shí)店归,將以模態(tài)方式展示位置頁(yè)面阎抒。
然后去哪?
恭喜你掌握了 BLoC 模式消痛。 BLoC 是一種簡(jiǎn)單但功能強(qiáng)大的模式且叁,可以幫助你輕松馴服 app 的狀態(tài)管理,因?yàn)樗梢栽?widget tree 上上下飛舞秩伞。
可以在本教程的 Download Materials 中找到最終的示例項(xiàng)目工程逞带,如果想運(yùn)行最終的示例項(xiàng)目,需要先把你的 API key 添加到 zomato_client.dart纱新。
其他值得一看的架構(gòu)模式有:
- Provider - https://pub.dev/packages/provider
- Scoped Model - https://pub.dev/packages/scoped_model
- RxDart - https://pub.dev/packages/rxdart
- Redux - https://pub.dev/packages/redux
同時(shí)請(qǐng)查閱 流 (stream) 的官方文檔展氓,和關(guān)于 BLoC 模式的 Google IO 討論。
希望你喜歡本 Flutter BLoC 教程怒炸。與往常一樣带饱,如果有任何問(wèn)題或意見,請(qǐng)隨時(shí)聯(lián)系我,或者在下面評(píng)論勺疼!