乐趣区

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

你的 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<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+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<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,这个方法接受一个字符串参数,并且用ZomatoClient 类来获取位置数据。这里用了 async/await 来让代码看起来清晰一些。结果随后会被推进流里。

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

把 BLoC 和组件树结合

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

一般来说这是 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方法允许组件从当前的 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<LocationBloc>(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<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 模式的催化剂。这些组件会自动监听流的事件。当收到一个新的事件的时候,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<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 里面
  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<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 并跳转回上一个页面

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

总算有点进展了。

餐厅页面

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

现在在 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.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 = <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 的部分,使用了一个 广播(Broadcast)StreamController,而不是一个常规的StreamControllerBroadcast 类型的 stream 可以有多个监听器(listener),而常规的只允许有一个。在前两个 BLoC 里面只存在一对一的关系,所以也不需要多个监听器。对于最喜欢这个功能,需要两个地方去监听,所以广播就是必须的了。

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

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

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 文件。这个组件会显示用户最喜欢的餐厅:

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);
            },
          );
        },
      ),
    );
  }
}

在这个组件里:

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

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

@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. 这个组件使用了 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<LocationBloc>(context);
  locationBloc.selectLocation(location);
  if (isFullScreenDialog) {Navigator.of(context).pop();}
},

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

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

最后

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

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

  • Provider:https://pub.dev/packages/prov…
  • Scoped Model:https://pub.dev/packages/prov…
  • RxDart:https://pub.dev/packages/prov…
  • Redux:https://pub.dev/packages/prov…

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

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

退出移动版