Flutter - BLoC模式入门

原文地址在这里, 作者是Brian Kayfitz。

这里提一点关于IDE的问题,很多人是移动转过来的,所以用Android Studio的人很多。其实Flutter也可以用VS Code来开发。笔者,两个都用过,他们各有好处。Android Studio在项目初期,目录、文件处理多的时候方便。重构的时候关于文件的修改,都会在其他文件引用里一起修改,删除也会有提示。在VS Code里这些没有,改文件名要手动去把import也一起改了。但是,VS Code调试方便很多。但是,在真机调试的时候还是要记得先Select Device

正文

设计app的架构经常会引起争论。每个人都有自己喜欢的一套炫酷的架构和一大堆名词。

iOS和Android开发者都对MVC非常了解,并且在开发的时候把这个模式作为默认的架构。Model和View是分开的,Controller来作为他们沟通的桥梁。

然而,Flutter带来的一套响应式设计并不能很好的兼容MVC。一个脱胎于这个经典模式的新的架构就出现在了Flutter社区--BLoC

BLoC是Business Logic Components的缩写。BLoC的哲学就是app里的所有东西都应该被认为是事件流:一部分组件订阅事件,另一部分组件则响应事件。BLoC居中管理这些会话。Dart甚至把流(Stream)内置到了语言本身里。

这个模式最好的地方就是你不需要引入任何的插件,也不需要学习其他的语法。所有需要的内容Flutter都有提供。

在本文里,我们要新建一个查找餐厅的app。API是有Zomato提供。最后你会学到以下内容:

  • 在BLoC模式里包装API调用
  • 查找并异步显示结果
  • 维护一个可以从多个页面访问到的最爱餐厅列表

开始

这里下载开始项目代码,使用你最喜欢的IDE打开。记得开始的时候运行flutter pub get,在IDE里也好,在命令行里也可以。在所有依赖都下载完成后就可以开始编码了。

在开始项目里包含了基本的model文件和网络请求文件。看起来是这样的:

获取API的Key

在开始开发应用之前,首先要获得一个我们要用的API的key。在Zomato的开发者站点https://developers.zomato.com/api,注册并生成一个key。

DataLayer目录下,打开zomato_client.dart文件。修改这个常量值:

class ZomatoClient {
  final _apiKey = "Your api key here";
}
实际的开发中把key放进源码或者夹杂到版本控制工具里可不是什么明智之举。这里只是为了方便,可不要用在实际的开发里。

运行起来,你会看到这样的效果:

一片黑,现在开始添加代码:

我们来烤一个多层蛋糕

写app的时候,不管你用的是Flutter或者其他的框架,把类分层都是很关键的。这更像是一个非正式的约定,不是一定要在代码里有怎么样的体现。

每层,或者一组类,都负责一个总体的职责。在初始项目里有一个目录DataLayer。这个数据层专门用来负责app的model和与后台通信。它对UI一无所知。

每个app都不尽相同,但是总体来说你都会构建一个这样的东西:

这个架构约定并没有和MVC太过不同。UI/Flutter层只能和BLoC层通信,BLoC层处理逻辑并给数据层和UI发送事件。这样的结构可以保证app规模变大的时候可以平滑的扩展。

深入BLoC

BLoC基本就是基于Dart的流(Stream)的。

流,和Future一样,也是在dart:async包里。一个流就像一个future,不同的是流不只是异步的返回一个值,流可以随着时间的推移返回很多的值。如果一个future最终是一个值的话,那么一个流就是会随着时间可以返回一个系列的值。

dart:async包提供了一个StreamController类。流控制器管理的两个对象流和槽(sink)。sink和流相对应,流提供提供数据,sink接受输入值。

总结一下,BLoC用来处理逻辑,sink接受输入,流输出。

定位界面

在查找餐馆之前,你要告诉Zomato你要在哪里吃饭。在这一节,你要新建一个简单的界面,有一个搜索栏和一个列表显示搜索的结果。

在输入代码之前不要忘记打开 DartFmt。这才是编写Flutter app的组好编码方式。

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: [
          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,用户可以在这里输入位置。

你的IDE在你输入的类没有被import的话会有报错。要改正这个错误的话只要把光标移动到这个标识符上,然后按下苹果系统下 option+enter(windows下Alt+Enter)或者点一下边上的红色小灯泡。点了之后会出现一个菜单,选择import那条就OK。

添加另一个文件main_screen.dart文件,它会用来管理界面的导航。添加如下的代码:

class MainScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return LocationScreen();
  }
}

更新main.dart文件:

MaterialApp(
  title: 'Restaurant Finder',
  theme: ThemeData(
    primarySwatch: Colors.red,
  ),
  home: MainScreen(),
),

现在运行代码,是这样的:

现在到了BLoC时间了。

第一个BLoC

lib目录下创建一个BLoC目录。这里用来存放所有的BLoC类。

新建一个bloc.dart文件,添加如下代码:

abstract class Bloc {
  void dispose();
}

所有的BLoC类都会遵循这个接口。这个接口并没有做什么,只是强制你的代码要包含一个dispoose方法。使用流很重要的一点就是不用的时候要关掉,否则会引起内存泄漏。有了dispose方法,app会直接调用。

第一个BLoC会处理app选定的地点。

BLoC目录,新建一个文件location_bloc.dart。添加如下的代码:

class LocationBloc implements Bloc {
  Location _location;
  Location get selectedLocation => _location;

  // 1
  final _locationController = StreamController();

  // 2
  Stream get locationStream => _locationController.stream;

  // 3
  void selectLocation(Location location) {
    _location = location;
    _locationController.sink.add(location);
  }

  // 4
  @override
  void dispose() {
    _locationController.close();
  }
}

使用option+enter import bloc类。

LocationBloc主要处理一下的事情:

  1. 有一个私有的StreamController来管理流和sink。StreamController使用泛型来告诉调用代码返回的数据是什么类型的。
  2. 这一行使用getter来暴露流
  3. 这个方法用来给BLoC输入值。并且位置数据也缓存在了_location属性里。
  4. 最终,在清理方法里StreamController在这个对象被回收之前被关闭。如果你不这么做,你的IDE也会显示出错误。

现在你的第一个BLoC就完成了,下面就要找地点了。

第二个BLoC

BLoC目录下新建一个location_query_bloc.dart文件,并添加如下代码:

class LocationQueryBloc implements Bloc {
  final _controller = StreamController>();
  final _client = ZomatoClient();
  Stream> 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,这个方法接受一个字符串参数,并且用ZomatoClient类来获取位置数据。这里用了async/await来让代码看起来清晰一些。结果随后会被推进流里。

这个BLoC和上一个基本上类似,只是这个里面还包含了一个API请求。

把BLoC和组件树结合

现在已经有两个BLoC了,你需要把他们和组件结合到一起。这样的方式在Flutter基本就叫做provider。一个provider就是给这个组件和它的子组件提供数据的。

一般来说这是InheritedWidget组件的工作,但是因为BLoC需要释放,StatefulWidget也会提供相同的服务。所以语法会稍显复杂,但是结果是一样的。

BLoC新建一个bloc_provider.dart文件,并添加下面的代码:

// 1
class BlocProvider 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(BuildContext context) {
    final type = _providerType>();
    final BlocProvider provider = context.ancestorWidgetOfExactType(type);
    return provider.bloc;
  }

  // 3
  static Type _providerType() => T;

  @override
  State createState() => _BlocProviderState();
}

class _BlocProviderState extends State {
  // 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方法允许组件从当前的context里获取组件树中的BlocProvider。这是Flutter的常规操作。
  3. 这里是获取一个泛型类型的对象
  4. 这个build方法并不会构建任何的东西
  5. 最后,这个provider为什么要继承StatefulWidget呢,主要是为了dispose方法。当一个组件从树里移除的时候,Flutter就会调用dispose方法关闭流

组合定位界面

你已经有了查找位置的完整的BLoC层代码,是时候用起来了。

首先,在main.dart里用一个BLoC包裹material app。最简单的就是把光标移动到MaterialApp上,按下option+enter(windows使用alt+enter),这样会弹出一个菜单,选择Wrap with a new widget

注意:这段代码是收到Didier Boelens的 https://www.didierboelens.com...—streams—bloc/。的启发。这个组件还没有优化,不过理论上是可以优化的。本文会继续使用比较初始的方式,因为这样可以满足大多数的场景。如果之后你发现有性能的问题,那么可以在 Flutter BLoC包里找到改进的方法。

之后代码就是这样的了:

return BlocProvider(
  bloc: LocationBloc(),
  child: MaterialApp(
    title: 'Restaurant Finder',
    theme: ThemeData(
      primarySwatch: Colors.red,
    ),
    home: MainScreen(),
  ),
);

在material app外面包一层provider是给需要数据的组件传递数据最简单的方法了。

main_screen.dart文件也要做类似的事情。在LocationScreen.dart上按下option + enter,选择**Wrap with StreamBuilder`。更新之后的代码是这样的:

return StreamBuilder(
  // 1
  stream: BlocProvider.of(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模式的催化剂。这些组件会自动监听流的事件。当收到一个新的事件的时候,builder方法就会执行,更新组件树。使用StreamBuilder和BLoC模式就完全不需要setState方法了。

代码解析:

  1. stream属性,使用of方法获取LocationBloc,并把流交给StreamBuilder
  2. 一开始流是没有数据的,这样很正常。如果没有任何数据app就返回LocationScreen。否则暂时返回一个空白界面。

接下来,在location_screen.dart里面使用LocationQueryBloc更新定位界面。不要忘了使用IDE提供的快捷键来更新代码:

@override
Widget build(BuildContext context) {
  // 1
  final bloc = LocationQueryBloc();

  // 2
  return BlocProvider(
    bloc: bloc,
    child: Scaffold(
      appBar: AppBar(title: Text('Where do you want to eat?')),
      body: Column(
        children: [
          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里面
  3. 更新TextFieldonChange方法,在这里把修改的文字提交给了LocationQueryBloc对象。这会出发请求API并返回数据的链条反应。
  4. 把bloc对象传递给_buildResult方法。

LocationScreen添加一个bool成员,一次来标记是否是一个全屏对话框。

class LocationScreen extends StatelessWidget {
  final bool isFullScreenDialog;
  const LocationScreen({Key key, this.isFullScreenDialog = false})
      : super(key: key);
  ...  

这个bool只是一个简单的标记。以后选中某个位置的时候会用到。

现在更新_buildResults方法。添加一个stream builder在一个列表里显示结果。你可以使用Wrap with StreamBuilder来快速更新代码:

Widget _buildResults(LocationQueryBloc bloc) {
  return StreamBuilder>(
    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 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(context);
          locationBloc.selectLocation(location);

          if (isFullScreenDialog) {
            Navigator.of(context).pop();
          }
        },
      );
    },
  );
}

代码解析如下:

  1. Stream可以返回三种结果:无数据(用户未做任何操作),空数组,也就是说Zomato没有找到符合条件的结果。最后是一组餐厅列表。
  2. 展示返回的一组数据。这也是flutter的常规操作
  3. onTap方法,用户点击一个餐厅之后获取LocationBloc并跳转回上一个页面

再次运行代码。你会看到这样的效果:

总算有点进展了。

餐厅页面

app的第二个页面会根据查找的结果展示一组餐厅。它也会有对应的BLoC对象来管理状态。

BLoC目录新建一个文件restaurant_bloc.dart。并添加如下的代码:

class RestaurantBloc implements Bloc {
  final Location location;
  final _client = ZomatoClient();
  final _controller = StreamController>();

  Stream> 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基类类似。唯一 不同的是返回的数据类型。

现在在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(
      bloc: bloc,
      child: Column(
        children: [
          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 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.dartMainScreen的代码:

builder: (context, snapshot) {
  final location = snapshot.data;

  if (location == null) {
    return LocationScreen();
  }

  return RestaurantScreen(location: location);
},

你选择了一个定位之后,一列餐厅就可以显示出来了。

最爱餐厅

目前为止,BLoC仅仅被用于处理用户输入。它能做到不止于此。假设用户想要记录他们最喜欢的餐厅,并且把这些餐厅显示到另外的一个列表页里面。这也可以用BLoC模式来解决。

BLoC目录,新建一个favorite_bloc.dart文件来存储这个列表:

class FavoriteBloc implements Bloc {
  var _restaurants = [];
  List get favorites => _restaurants;
  // 1
  final _controller = StreamController>.broadcast();
  Stream> 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的部分,使用了一个广播(Broadcast)StreamController,而不是一个常规的StreamControllerBroadcast类型的stream可以有多个监听器(listener),而常规的只允许有一个。在前两个BLoC里面只存在一对一的关系,所以也不需要多个监听器。对于最喜欢这个功能,需要两个地方去监听,所以广播就是必须的了。

注意:使用BLoC的一般规则是使用首先使用常规的流,之后如果需要广播的时候才去重构代码。如果多个对象监听同一个常规的流,那么Flutter会抛出一个异常。使用这个来作为需要重构代码的一个标志。

这个BLoC需要多个页面都可以访问到,也就是说要放在导航器的外面了。更新main.dart,添加如下的组件:

return BlocProvider(
  bloc: LocationBloc(),
  child: BlocProvider(
    bloc: FavoriteBloc(),
    child: MaterialApp(
      title: 'Restaurant Finder',
      theme: ThemeData(
        primarySwatch: Colors.red,
      ),
      home: MainScreen(),
    ),
  ),
);

接下来,在UI目录下添加一个favorite_screen.dart文件。这个组件会显示用户最喜欢的餐厅:

class FavoriteScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final bloc = BlocProvider.of(context);

    return Scaffold(
      appBar: AppBar(
        title: Text('Favorites'),
      ),
      body: StreamBuilder>(
        stream: bloc.favoritesStream,
        // 1
        initialData: bloc.favorites,
        builder: (context, snapshot) {
          // 2
          List 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);
            },
          );
        },
      ),
    );
  }
}

在这个组件里:

  1. StreamBuilder里添加初始数据。StreamBuilder会立即调用builder方法,即使是没有数据的。
  2. 检查app的连接状态。

接下来更新餐厅界面的build方法,把最喜欢的餐厅加到导航里面:

@override
Widget build(BuildContext context) {
  return Scaffold(
      appBar: AppBar(
        title: Text(location.title),
        actions: [
          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: [
          _buildBanner(),
          Padding(
            padding: const EdgeInsets.all(8.0),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                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: [
          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(context);
    return StreamBuilder>(
      stream: bloc.favoritesStream,
      initialData: bloc.favorites,
      builder: (context, snapshot) {
        List 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. 这个组件使用了FavoriteBloc来判断某个餐厅是否是最喜欢的餐厅,并对应的更新界面
  2. FavoriteBloc#toggleRestaurant方法可以让组件不用去关心某个餐厅是不是最喜欢的。

restaurant_tile.dart文件的onTap方法里添加下面的代码:

onTap: () {
  Navigator.of(context).push(
    MaterialPageRoute(
      builder: (context) =>
          RestaurantDetailsScreen(restaurant: restaurant),
    ),
  );
},

运行代码:

更新定位

如果用户想要更新他们查找的定位呢?现在如果你更改了位置,那么app就要重启才行。

因为你已经让代码工作在流传递过来的一组数据上了,那么添加一个功能就变得非常的简单,就像在蛋糕上放一个樱桃那么简单。

在餐厅页,添加一个浮动按钮。点下这个按钮之后就会把定位页面弹出来。

 ...
    body: _buildSearch(context),
    floatingActionButton: FloatingActionButton(
      child: Icon(Icons.edit_location),
      onPressed: () => Navigator.of(context).push(MaterialPageRoute(
          builder: (context) => LocationScreen(
                // 1
                isFullScreenDialog: true,
              ),
          fullscreenDialog: true)),
    ),
  );
}

// 1isFullScreenDialog设置为true,这样定位页弹出之后就会显示为全屏。

LocationScreenLisTile#onTap方法是这么使用isFullScreenDialog的:

onTap: () {
  final locationBloc = BlocProvider.of(context);
  locationBloc.selectLocation(location);
  if (isFullScreenDialog) {
    Navigator.of(context).pop();
  }
},

这么做是为了可以在定位也作为对话框显示的时候也可以去掉。

再次运行代码你会看到一个浮动按钮,点了之后就会弹出定位页。

最后

祝贺你已经学会了BLoC模式。BLoC是一个简单而强大的app状态管理模式。

你可以在本例里下载到最终的项目代码。如果要运行起来的话,千万记住要先从zomato获得一个app key并且更新zomato_client.dart代码(不要放到代码版本控制里,比如github等)。其他可以看的模式:

也可以查看官方文档,或者Google IO的视频

希望你喜欢这个BLoC教程,有什么问题可以留在评论区里。

你可能感兴趣的:(flutter,区块链,stream)