原文地址在這裏, 做者是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裏也好,在命令行裏也能夠。在全部依賴都下載完成後就能夠開始編碼了。markdown
在開始項目裏包含了基本的model文件和網絡請求文件。看起來是這樣的:網絡
在開始開發應用以前,首先要得到一個咱們要用的API的key。在Zomato的開發者站點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 = context.ancestorWidgetOfExactType(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/2018/08/reactive-programming—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教程,有什麼問題能夠留在評論區裏。