Flutter: BLoC 模式入門教程

原文:Flutter: BLoC 模式入門教程

了解如何使用流行的 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 將完成下面的事情:

  1. 使用 BLoC 模式封裝 API 調(diào)用
  2. 搜索餐廳并異步顯示結(jié)果
  3. 維護(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)如下功能:

  1. 聲明了一個(gè) private StreamController画饥,管理 BLoC 的 stream 和 sink衔瓮。StreamController 使用泛型告訴類型系統(tǒng)它將通過(guò) stream 發(fā)送何種類型的對(duì)象。
  2. 這行暴露了一個(gè) public 的 getter 方法抖甘,調(diào)用者通過(guò)該方法獲取 StreamController 的 stream热鞍。
  3. 該方法是 BLoC 的輸入,接收一個(gè) Location 模型對(duì)象衔彻,將其緩存到私有成員屬性 _location 薇宠,并添加到流的接收器(sink)中。
  4. 最后艰额,當(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();
  }
}

代碼解讀如下:

  1. BlocProvider 是一個(gè)泛型類,泛型 T 被限定為一個(gè)實(shí)現(xiàn)了 BLoC 接口的對(duì)象灯变。意味著這個(gè) provider 只能存儲(chǔ) BLoC 對(duì)象殴玛。
  2. of 方法允許 widget tree 的子孫節(jié)點(diǎn)使用當(dāng)前的 build context 檢索 BlocProvider。在 Flutter 里這是非常常見的模式添祸。
  3. 這是獲取泛型類型引用的通用方式滚粟。
  4. build 方法只是返回了 widget 的 child,并沒有渲染任何東西刃泌。
  5. 最后凡壤,這個(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() 方法锚沸。

在上面的代碼中:

  1. 對(duì)于 stream 屬性,使用 of 方法獲取 LocationBloc 并將其 stream 添加到 StreamBuilder 中涕癣。
  2. 最初 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),
          )
        ],
      ),
    ),
  );
}

在這段代碼里:

  1. 首先洞翩,在 build 方法的開始部分實(shí)例化了一個(gè)新的 LocationQueryBloc 對(duì)象稽犁。
  2. 將 BLoC 存儲(chǔ)在 BlocProvider 中,BlocProvider 將管理 BLoC的生命周期骚亿。
  3. 更新 TextFieldonChanged 閉包方法已亥,傳遞文本到 LocationQueryBloc。這將觸發(fā)獲取數(shù)據(jù)的調(diào)用鏈来屠,首先調(diào)用 Zomato虑椎,然后將返回的位置信息發(fā)送到 stream 中。
  4. 將 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();
          }
        },
      );
    },
  );
}

在上面的代碼中:

  1. stream 有三個(gè)條件分支,返回不同的結(jié)果宽涌∑揭梗可能沒有數(shù)據(jù),意味著用戶沒有輸入任何信息卸亮;可能是一個(gè)空的列表忽妒,意味著 Zomato 找不到任何你想要查找的內(nèi)容;最后兼贸,可能是一個(gè)完整的餐廳列表段直,意味著每一件事都做的很完美。
  2. 這里展示位置信息列表溶诞。這個(gè)方法的行為就是普通的聲明式 Flutter 代碼鸯檬。
  3. 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 里:

  1. 添加初始化數(shù)據(jù)到 StreamBuilderStreamBuilder 將立即觸發(fā)對(duì) builder 閉包的執(zhí)行奋渔,即使沒有任何數(shù)據(jù)镊逝。這允許 Flutter 確保快照(snapshot)始終有數(shù)據(jù)嫉鲸,而不是毫無(wú)必要的重繪頁(yè)面撑蒜。
  2. 檢測(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'),
        );
      },
    );
  }
}

在上面代碼中:

  1. 這個(gè) widget 使用收藏 stream 檢測(cè)餐廳是否已被收藏,然后渲染適合的 widget甜紫。
  2. 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)模式有:

同時(shí)請(qǐng)查閱 流 (stream) 的官方文檔展氓,和關(guān)于 BLoC 模式的 Google IO 討論

希望你喜歡本 Flutter BLoC 教程怒炸。與往常一樣带饱,如果有任何問(wèn)題或意見,請(qǐng)隨時(shí)聯(lián)系我,或者在下面評(píng)論勺疼!

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末教寂,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子执庐,更是在濱河造成了極大的恐慌酪耕,老刑警劉巖,帶你破解...
    沈念sama閱讀 216,372評(píng)論 6 498
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件轨淌,死亡現(xiàn)場(chǎng)離奇詭異迂烁,居然都是意外死亡,警方通過(guò)查閱死者的電腦和手機(jī)递鹉,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,368評(píng)論 3 392
  • 文/潘曉璐 我一進(jìn)店門盟步,熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái),“玉大人躏结,你說(shuō)我怎么就攤上這事却盘。” “怎么了媳拴?”我有些...
    開封第一講書人閱讀 162,415評(píng)論 0 353
  • 文/不壞的土叔 我叫張陵黄橘,是天一觀的道長(zhǎng)。 經(jīng)常有香客問(wèn)我屈溉,道長(zhǎng)塞关,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 58,157評(píng)論 1 292
  • 正文 為了忘掉前任子巾,我火速辦了婚禮帆赢,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘线梗。我一直安慰自己匿醒,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,171評(píng)論 6 388
  • 文/花漫 我一把揭開白布缠导。 她就那樣靜靜地躺著廉羔,像睡著了一般。 火紅的嫁衣襯著肌膚如雪僻造。 梳的紋絲不亂的頭發(fā)上憋他,一...
    開封第一講書人閱讀 51,125評(píng)論 1 297
  • 那天,我揣著相機(jī)與錄音髓削,去河邊找鬼竹挡。 笑死,一個(gè)胖子當(dāng)著我的面吹牛立膛,可吹牛的內(nèi)容都是我干的揪罕。 我是一名探鬼主播梯码,決...
    沈念sama閱讀 40,028評(píng)論 3 417
  • 文/蒼蘭香墨 我猛地睜開眼,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼好啰!你這毒婦竟也來(lái)了轩娶?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 38,887評(píng)論 0 274
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤框往,失蹤者是張志新(化名)和其女友劉穎鳄抒,沒想到半個(gè)月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體椰弊,經(jīng)...
    沈念sama閱讀 45,310評(píng)論 1 310
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡许溅,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,533評(píng)論 2 332
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了秉版。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片贤重。...
    茶點(diǎn)故事閱讀 39,690評(píng)論 1 348
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖清焕,靈堂內(nèi)的尸體忽然破棺而出游桩,到底是詐尸還是另有隱情,我是刑警寧澤耐朴,帶...
    沈念sama閱讀 35,411評(píng)論 5 343
  • 正文 年R本政府宣布,位于F島的核電站盹憎,受9級(jí)特大地震影響筛峭,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜陪每,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,004評(píng)論 3 325
  • 文/蒙蒙 一影晓、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧檩禾,春花似錦挂签、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,659評(píng)論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)。三九已至戏售,卻和暖如春侨核,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背灌灾。 一陣腳步聲響...
    開封第一講書人閱讀 32,812評(píng)論 1 268
  • 我被黑心中介騙來(lái)泰國(guó)打工搓译, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人锋喜。 一個(gè)月前我還...
    沈念sama閱讀 47,693評(píng)論 2 368
  • 正文 我出身青樓些己,卻偏偏與公主長(zhǎng)得像豌鸡,于是被迫代替她去往敵國(guó)和親。 傳聞我的和親對(duì)象是個(gè)殘疾皇子段标,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,577評(píng)論 2 353

推薦閱讀更多精彩內(nèi)容