原文地址在这里, 做者是Brian Kayfitz。react
这里提一点关于IDE的问题,不少人是移动转过来的,因此用Android Studio的人不少。其实Flutter也能够用VS Code来开发。笔者,两个都用过,他们各有好处。Android Studio在项目初期,目录、文件处理多的时候方便。重构的时候关于文件的修改,都会在其余文件引用里一块儿修改,删除也会有提示。在VS Code里这些没有,改文件名要手动去把import
也一块儿改了。可是,VS Code调试方便不少。可是,在真机调试的时候仍是要记得先Select Device。git
设计app的架构常常会引发争论。每一个人都有本身喜欢的一套炫酷的架构和一大堆名词。github
iOS和Android开发者都对MVC很是了解,而且在开发的时候把这个模式做为默认的架构。Model和View是分开的,Controller来做为他们沟通的桥梁。c#
然而,Flutter带来的一套响应式设计并不能很好的兼容MVC。一个脱胎于这个经典模式的新的架构就出如今了Flutter社区--BLoC。windows
BLoC是Business Logic Components的缩写。BLoC的哲学就是app里的全部东西都应该被认为是事件流:一部分组件订阅事件,另外一部分组件则响应事件。BLoC居中管理这些会话。Dart甚至把流(Stream)内置到了语言自己里。api
这个模式最好的地方就是你不须要引入任何的插件,也不须要学习其余的语法。全部须要的内容Flutter都有提供。数组
在本文里,咱们要新建一个查找餐厅的app。API是有Zomato
提供。最后你会学到如下内容:缓存
在这里下载开始项目代码,使用你最喜欢的IDE打开。记得开始的时候运行flutter pub get
,在IDE里也好,在命令行里也能够。在全部依赖都下载完成后就能够开始编码了。网络
在开始项目里包含了基本的model文件和网络请求文件。看起来是这样的:架构
在开始开发应用以前,首先要得到一个咱们要用的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基本就是基于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时间了。
在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
主要处理一下的事情:
StreamController
来管理流和sink。StreamController
使用泛型来告诉调用代码返回的数据是什么类型的。_location
属性里。StreamController
在这个对象被回收以前被关闭。若是你不这么作,你的IDE也会显示出错误。如今你的第一个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了,你须要把他们和组件结合到一块儿。这样的方式在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(); } }
上面的代码解析以下:
BlocProvider
是一个泛型类。类型T
要求必须实现了Bloc
接口。这也就是说provider只能存储BLoC类型的对象。of
方法容许组件从当前的context里获取组件树中的BlocProvider
。这是Flutter的常规操做。build
方法并不会构建任何的东西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
方法了。
代码解析:
stream
属性,使用of
方法获取LocationBloc
,并把流交给StreamBuilder
。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), ) ], ), ), ); }
解析以下:
build
方法的一开始初始化了一个LocationQueryBloc
类。BlocProvider
里面TextField
的onChange
方法,在这里把修改的文字提交给了LocationQueryBloc
对象。这会出发请求API并返回数据的链条反应。_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(); } }, ); }, ); }
代码解析以下:
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.dart里MainScreen
的代码:
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
,而不是一个常规的StreamController
。Broadcast类型的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); }, ); }, ), ); } }
在这个组件里:
StreamBuilder
里添加初始数据。StreamBuilder
会当即调用builder方法,即便是没有数据的。接下来更新餐厅界面的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'), ); }, ); } }
代码解析:
FavoriteBloc
来判断某个餐厅是不是最喜欢的餐厅,并对应的更新界面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)), ), ); }
在// 1
,isFullScreenDialog
设置为true,这样定位页弹出以后就会显示为全屏。
在LocationScreen
的LisTile#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等)。其余能够看的模式:
也能够查看官方文档,或者Google IO的视频。
但愿你喜欢这个BLoC教程,有什么问题能够留在评论区里。