Flutter: BLoC 模式入门教程

原文: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 将完成下面的事情:

  1. 使用 BLoC 模式封装 API 调用
  2. 搜索餐厅并异步显示结果
  3. 维护收藏列表,并在多个页面展示

准备开始

下载并使用你最喜欢的 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/api,创建一个账号,并产生一个新的 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 主要实现如下功能:

  1. 声明了一个 private StreamController,管理 BLoC 的 stream 和 sink。StreamController 使用泛型告诉类型系统它将通过 stream 发送何种类型的对象。
  2. 这行暴露了一个 public 的 getter 方法,调用者通过该方法获取 StreamController 的 stream。
  3. 该方法是 BLoC 的输入,接收一个 Location 模型对象,将其缓存到私有成员属性 _location ,并添加到流的接收器(sink)中。
  4. 最后,当这个 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();
  }
}

代码解读如下:

  1. BlocProvider 是一个泛型类,泛型 T 被限定为一个实现了 BLoC 接口的对象。意味着这个 provider 只能存储 BLoC 对象。
  2. of 方法允许 widget tree 的子孙节点使用当前的 build context 检索 BlocProvider。在 Flutter 里这是非常常见的模式。
  3. 这是获取泛型类型引用的通用方式。
  4. build 方法只是返回了 widget 的 child,并没有渲染任何东西。
  5. 最后,这个 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() 方法。

在上面的代码中:

  1. 对于 stream 属性,使用 of 方法获取 LocationBloc 并将其 stream 添加到 StreamBuilder 中。
  2. 最初 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),
          )
        ],
      ),
    ),
  );
}

在这段代码里:

  1. 首先,在 build 方法的开始部分实例化了一个新的 LocationQueryBloc 对象。
  2. 将 BLoC 存储在 BlocProvider 中,BlocProvider 将管理 BLoC的生命周期。
  3. 更新 TextFieldonChanged 闭包方法,传递文本到 LocationQueryBloc。这将触发获取数据的调用链,首先调用 Zomato,然后将返回的位置信息发送到 stream 中。
  4. 将 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();
          }
        },
      );
    },
  );
}

在上面的代码中:

  1. stream 有三个条件分支,返回不同的结果。可能没有数据,意味着用户没有输入任何信息;可能是一个空的列表,意味着 Zomato 找不到任何你想要查找的内容;最后,可能是一个完整的餐厅列表,意味着每一件事都做的很完美。
  2. 这里展示位置信息列表。这个方法的行为就是普通的声明式 Flutter 代码。
  3. 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 里:

  1. 添加初始化数据到 StreamBuilderStreamBuilder 将立即触发对 builder 闭包的执行,即使没有任何数据。这允许 Flutter 确保快照(snapshot)始终有数据,而不是毫无必要的重绘页面。
  2. 检测 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'),
        );
      },
    );
  }
}

在上面代码中:

  1. 这个 widget 使用收藏 stream 检测餐厅是否已被收藏,然后渲染适合的 widget。
  2. 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

其他值得一看的架构模式有:

同时请查阅 流 (stream) 的官方文档,和关于 BLoC 模式的 Google IO 讨论

希望你喜欢本 Flutter BLoC 教程。与往常一样,如果有任何问题或意见,请随时联系我,或者在下面评论!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,294评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,493评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,790评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,595评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,718评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,906评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,053评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,797评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,250评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,570评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,711评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,388评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,018评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,796评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,023评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,461评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,595评论 2 350

推荐阅读更多精彩内容