Flutter: BLoC 模式入门教程

2,117 阅读18分钟

原文:zhuanlan.zhihu.com/p/461244648

了解如何使用流行的 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 开发者页面 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 获取位置结果并将它们显示在列表中。

07-location-screen-finished-2.png

很好!这是真正的进步。

餐厅页面

这个 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。选中一个位置,然后搜索想吃的东西,一个餐厅的列表会出现在你面前。

08-restaurant-screen-finished-1.png

看上去很美味。这是谁准备吃蛋糕了?

收藏餐厅

到目前为止,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)的力量!

09-Restaurant-Details-2-563x500.png

更新位置信息

如果用户想更改他们正在搜索的位置怎么办?现在的代码实现,如果想更改位置信息,必须重新启动这个 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,当点击该按钮时,将以模态方式展示位置页面。

10-Update-Location-563x500.png

然后去哪?

恭喜你掌握了 BLoC 模式。 BLoC 是一种简单但功能强大的模式,可以帮助你轻松驯服 app 的状态管理,因为它可以在 widget tree 上上下飞舞。

可以在本教程的 Download Materials 中找到最终的示例项目工程,如果想运行最终的示例项目,需要先把你的 API key 添加到 zomato_client.dart

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

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

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