使用BLoC构建您的Flutter项目之二
原文地址:https://medium.com/flutterpub/architect-your-flutter-project-using-bloc-pattern-part-2-d8dd1eca9ba5
上一篇文章中已经使用了BLoC模式实现了项目的构建,这篇文章中将会对上次的项目进行优化。
这篇文章主要覆盖的主题:
- 解决架构中的设计缺陷
- Single Instance与Scoped Instance(对 BLoC的访问)
- Navigation
- RxDart’s Transformers
当前架构中的设计缺陷
第一个缺陷就是,在MovieBloc类中创建了一个dispose方法,该方法是用来关闭流以防导致内存溢出。我们创建了这个方法,但是从来没有调用过,这将会导致内存溢出。
另一个缺陷就是在build方法中进行网络调用。
现在MovieList是StatelessWidget,而StatelessWidget是只要将其添加到Widget树中,之后所有属性都是不可变的,而build方法时入口,由于配置更改,可以被多次调用。所以该方法不适合网络调用。而StatelessWidget中也没有一个适合调用dispose方法的地方。
而StatefulWidget方法提供了initState和dispose方法。initState方法用来分配资源,而dispose方法用来在界面回收的之前处理掉这些资源。
首先我们将MovieList从StatelessWidget转换成StatefulWidget,接着在initState中调用网络请求方法,接着在dispose方法中调用bloc的dispose方法。
import 'package:flutter/material.dart';
import '../models/item_model.dart';
import '../blocs/movies_bloc.dart';
class MovieList extends StatefulWidget {
@override
State<StatefulWidget> createState() {
return MovieListState();
}
}
class MovieListState extends State<MovieList> {
@override
void initState() {
super.initState();
bloc.fetchAllMovies();
}
@override
void dispose() {
bloc.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Popular Movies'),
),
body: StreamBuilder(
stream: bloc.allMovies,
builder: (context, AsyncSnapshot<ItemModel> snapshot) {
if (snapshot.hasData) {
return buildList(snapshot);
} else if (snapshot.hasError) {
return Text(snapshot.error.toString());
}
return Center(child: CircularProgressIndicator());
},
),
);
}
Widget buildList(AsyncSnapshot<ItemModel> snapshot) {
return GridView.builder(
itemCount: snapshot.data.results.length,
gridDelegate:
new SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 2),
itemBuilder: (BuildContext context, int index) {
return GridTile(
child: Image.network(
'https://image.tmdb.org/t/p/w185${snapshot.data
.results[index].poster_path}',
fit: BoxFit.cover,
),
);
});
}
}
新功能实现
我们添加一个新界面用来显示电影详情。我们重新设计一下app的流程。
image.png上面的图很简单,但是我们还是来解释一下:
- MovieList Screen:电影列表界面
- MovieList Bloc:这是一个从Repository获取数据并传递到UI界面的桥梁。
- MovieDetail Screen: 用来显示从列表选择的电影的详情。
- Repository:用来控制数据流的中心
- API provider:用来控制所有的网络请求
Single Instance vs Scoped Instance
正如你所看到的,两个Screen都可以访问各自的BLoC类,我们可以通过下面的两种方式将这些BLoC类暴露给各自的Screen。单例是指将BLoC类的单个引用(Singleton)暴露给Screen。可以从应用程序的任何部分访问此类型的BLoC类。任何Screen都可以使用单实例BLoC类。
但是Scoped Instance BLoC类具有有限的访问权限。它只与Screen相关联。
2.png
正如上图看到的,只有Screen本身以及其下面两个自定的widget可以访问bloc。我们使用InheritedWidget将BLoC包裹起来。InheritedWidget将包装Screen,让Screen组件和其里面的自定义的组件可以访问Bloc。Screen的父组件都不能访问该Bloc。
Single Instance只适合用在小型的项目中,如果你开发的是一个复杂的项目,那么Scoped Widget将会是你的首选。
添加详情界面
当点击列表中的其中一个item时,将会跳转到该电影的详情界面,用户可以看到电影的详细信息,然后一些数据将从列表界面传到详情界面,预告片从服务器加载。
在创建文件之前,我们要遵从上一篇文章中提到的项目结构。首先在ui包中创建movie_detail.dart文件。
import 'package:flutter/material.dart';
class MovieDetail extends StatefulWidget {
final posterUrl;
final description;
final releaseDate;
final String title;
final String voteAverage;
final int movieId;
MovieDetail({
this.title,
this.posterUrl,
this.description,
this.releaseDate,
this.voteAverage,
this.movieId,
});
@override
State<StatefulWidget> createState() {
return MovieDetailState(
title: title,
posterUrl: posterUrl,
description: description,
releaseDate: releaseDate,
voteAverage: voteAverage,
movieId: movieId,
);
}
}
class MovieDetailState extends State<MovieDetail> {
final posterUrl;
final description;
final releaseDate;
final String title;
final String voteAverage;
final int movieId;
MovieDetailState({
this.title,
this.posterUrl,
this.description,
this.releaseDate,
this.voteAverage,
this.movieId,
});
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
top: false,
bottom: false,
child: NestedScrollView(
headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
return <Widget>[
SliverAppBar(
expandedHeight: 200.0,
floating: false,
pinned: true,
elevation: 0.0,
flexibleSpace: FlexibleSpaceBar(
background: Image.network(
"https://image.tmdb.org/t/p/w500$posterUrl",
fit: BoxFit.cover,
)),
),
];
},
body: Padding(
padding: const EdgeInsets.all(10.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Container(margin: EdgeInsets.only(top: 5.0)),
Text(
title,
style: TextStyle(
fontSize: 25.0,
fontWeight: FontWeight.bold,
),
),
Container(margin: EdgeInsets.only(top: 8.0, bottom: 8.0)),
Row(
children: <Widget>[
Icon(
Icons.favorite,
color: Colors.red,
),
Container(
margin: EdgeInsets.only(left: 1.0, right: 1.0),
),
Text(
voteAverage,
style: TextStyle(
fontSize: 18.0,
),
),
Container(
margin: EdgeInsets.only(left: 10.0, right: 10.0),
),
Text(
releaseDate,
style: TextStyle(
fontSize: 18.0,
),
),
],
),
Container(margin: EdgeInsets.only(top: 8.0, bottom: 8.0)),
Text(description),
],
),
),
),
),
);
}
}
Navigation
Flutter中如果你想从一个界面跳转到另一个界面的话,使用Navigator类,我们在movie_list.dart中实现导航的逻辑。
import 'package:flutter/material.dart';
import '../models/item_model.dart';
import '../blocs/movies_bloc.dart';
import 'movie_detail.dart';
class MovieList extends StatefulWidget {
@override
State<StatefulWidget> createState() {
return MovieListState();
}
}
class MovieListState extends State<MovieList> {
@override
void initState() {
super.initState();
bloc.fetchAllMovies();
}
@override
void dispose() {
bloc.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Popular Movies'),
),
body: StreamBuilder(
stream: bloc.allMovies,
builder: (context, AsyncSnapshot<ItemModel> snapshot) {
if (snapshot.hasData) {
return buildList(snapshot);
} else if (snapshot.hasError) {
return Text(snapshot.error.toString());
}
return Center(child: CircularProgressIndicator());
},
),
);
}
Widget buildList(AsyncSnapshot<ItemModel> snapshot) {
return GridView.builder(
itemCount: snapshot.data.results.length,
gridDelegate:
new SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 2),
itemBuilder: (BuildContext context, int index) {
return GridTile(
child: InkResponse(
enableFeedback: true,
child: Image.network(
'https://image.tmdb.org/t/p/w185${snapshot.data
.results[index].poster_path}',
fit: BoxFit.cover,
),
onTap: () => openDetailPage(snapshot.data, index),
),
);
});
}
openDetailPage(ItemModel data, int index) {
Navigator.push(
context,
MaterialPageRoute(builder: (context) {
return MovieDetail(
title: data.results[index].title,
posterUrl: data.results[index].backdrop_path,
description: data.results[index].overview,
releaseDate: data.results[index].release_date,
voteAverage: data.results[index].vote_average.toString(),
movieId: data.results[index].id,
);
}),
);
}
}
具体的逻辑在openDetailPage方法中。
接着我们去服务器上获取预告片信息:
https://api.themoviedb.org/3/movie/<movie_id>/videos?api_key=your_api_key,我们需要传入movie_id和api_key。下面是api返回的信息:
{
"id": 299536,
"results": [
{
"id": "5a200baa925141033608f5f0",
"iso_639_1": "en",
"iso_3166_1": "US",
"key": "6ZfuNTqbHE8",
"name": "Official Trailer",
"site": "YouTube",
"size": 1080,
"type": "Trailer"
},
{
"id": "5a200bcc925141032408d21b",
"iso_639_1": "en",
"iso_3166_1": "US",
"key": "sAOzrChqmd0",
"name": "Action...Avengers: Infinity War",
"site": "YouTube",
"size": 720,
"type": "Clip"
},
{
"id": "5a200bdd0e0a264cca08d39f",
"iso_639_1": "en",
"iso_3166_1": "US",
"key": "3VbHg5fqBYw",
"name": "Trailer Tease",
"site": "YouTube",
"size": 720,
"type": "Teaser"
},
{
"id": "5a7833440e0a26597f010849",
"iso_639_1": "en",
"iso_3166_1": "US",
"key": "pVxOVlm_lE8",
"name": "Big Game Spot",
"site": "YouTube",
"size": 1080,
"type": "Teaser"
},
{
"id": "5aabd7e69251413feb011276",
"iso_639_1": "en",
"iso_3166_1": "US",
"key": "QwievZ1Tx-8",
"name": "Official Trailer #2",
"site": "YouTube",
"size": 1080,
"type": "Trailer"
},
{
"id": "5aea2ed2c3a3682bf7001205",
"iso_639_1": "en",
"iso_3166_1": "US",
"key": "LXPaDL_oILs",
"name": "\"Legacy\" TV Spot",
"site": "YouTube",
"size": 1080,
"type": "Teaser"
},
{
"id": "5aea2f3e92514172a7001672",
"iso_639_1": "en",
"iso_3166_1": "US",
"key": "PbRmbhdHDDM",
"name": "\"Family\" Featurette",
"site": "YouTube",
"size": 1080,
"type": "Featurette"
}
]
}
我们需要在model包中创建一个trailer_model.dart文件。
class TrailerModel {
int _id;
List<_Result> _results = [];
TrailerModel.fromJson(Map<String, dynamic> parsedJson) {
_id = parsedJson['id'];
List<_Result> temp = [];
for (int i = 0; i < parsedJson['results'].length; i++) {
_Result result = _Result(parsedJson['results'][i]);
temp.add(result);
}
_results = temp;
}
List<_Result> get results => _results;
int get id => _id;
}
class _Result {
String _id;
String _iso_639_1;
String _iso_3166_1;
String _key;
String _name;
String _site;
int _size;
String _type;
_Result(result) {
_id = result['id'];
_iso_639_1 = result['iso_639_1'];
_iso_3166_1 = result['iso_3166_1'];
_key = result['key'];
_name = result['name'];
_site = result['site'];
_size = result['size'];
_type = result['type'];
}
String get id => _id;
String get iso_639_1 => _iso_639_1;
String get iso_3166_1 => _iso_3166_1;
String get key => _key;
String get name => _name;
String get site => _site;
int get size => _size;
String get type => _type;
}
接下来在movie_api_provider.dart文件中实现网络请求。
import 'dart:async';
import 'package:http/http.dart' show Client;
import 'dart:convert';
import '../models/item_model.dart';
import '../models/trailer_model.dart';
class MovieApiProvider {
Client client = Client();
final _apiKey = '802b2c4b88ea1183e50e6b285a27696e';
final _baseUrl = "http://api.themoviedb.org/3/movie";
Future<ItemModel> fetchMovieList() async {
final response = await client.get("$_baseUrl/popular?api_key=$_apiKey");
if (response.statusCode == 200) {
// If the call to the server was successful, parse the JSON
return ItemModel.fromJson(json.decode(response.body));
} else {
// If that call was not successful, throw an error.
throw Exception('Failed to load post');
}
}
Future<TrailerModel> fetchTrailer(int movieId) async {
final response =
await client.get("$_baseUrl/$movieId/videos?api_key=$_apiKey");
if (response.statusCode == 200) {
return TrailerModel.fromJson(json.decode(response.body));
} else {
throw Exception('Failed to load trailers');
}
}
}
fetchTrailer(movie_id)方法就是我们执行网络请求然后将返回的信息转换成TrailerModel对象,并返回Future<TrailerModel>。
接着在Repository类中添加网络调用实现。接下来让我们使用Scoped Instance方案来实现功能。在bloc包中创建一个movie_detail_bloc.dart和movie_detail_bloc_provider.dart文件。
下面是movie_detail_bloc_provider.dart中的代码:
import 'package:flutter/material.dart';
import 'movie_detail_bloc.dart';
export 'movie_detail_bloc.dart';
class MovieDetailBlocProvider extends InheritedWidget {
final MovieDetailBloc bloc;
MovieDetailBlocProvider({Key key, Widget child})
: bloc = MovieDetailBloc(),
super(key: key, child: child);
@override
bool updateShouldNotify(_) {
return true;
}
static MovieDetailBloc of(BuildContext context) {
return (context.inheritFromWidgetOfExactType(MovieDetailBlocProvider)
as MovieDetailBlocProvider)
.bloc;
}
}
此类实现了InheritedWidget,并通过(Context)的of方法来放问bloc。
接下来编写movie_detail.dart文件。
import 'dart:async';
import 'package:rxdart/rxdart.dart';
import '../models/trailer_model.dart';
import '../resources/repository.dart';
class MovieDetailBloc {
final _repository = Repository();
final _movieId = PublishSubject<int>();
final _trailers = BehaviorSubject<Future<TrailerModel>>();
Function(int) get fetchTrailersById => _movieId.sink.add;
Observable<Future<TrailerModel>> get movieTrailers => _trailers.stream;
MovieDetailBloc() {
_movieId.stream.transform(_itemTransformer()).pipe(_trailers);
}
dispose() async {
_movieId.close();
await _trailers.drain();
_trailers.close();
}
_itemTransformer() {
return ScanStreamTransformer(
(Future<TrailerModel> trailer, int id, int index) {
print(index);
trailer = _repository.fetchTrailers(id);
return trailer;
},
);
}
}
上面的逻辑就是将movieId传递给api请求,然后返回预告片列表。用到了RxDart的Transformers。
Transformers
Transformers是用来连接两个或多个Subject并获得最终的结果。如果想要对数据进行一些操作后,从一个Subject转换成另一个Subject的话,我们将使用Transformers对传入的第一个Subject进行一些操作之后传递给下一个Subject。
在我们的项目中,我们将movieId添加给_movieId,他是一个PublishSubject的类型,我们将_movieId传递给ScanStreamTransformer,然后ScanStreamTransformer将进行网络调用,最后把结果传递给_trailers,_trailers是一个BehaviorSubject。
最后一步就是通过MovieDetail访问MovieDetailBloc,为此我们需要更新openDetailPage()方法。
import 'package:flutter/material.dart';
import '../models/item_model.dart';
import '../blocs/movies_bloc.dart';
import 'movie_detail.dart';
import '../blocs/movie_detail_bloc_provider.dart';
class MovieList extends StatefulWidget {
@override
State<StatefulWidget> createState() {
return MovieListState();
}
}
class MovieListState extends State<MovieList> {
@override
void initState() {
super.initState();
bloc.fetchAllMovies();
}
@override
void dispose() {
bloc.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Popular Movies'),
),
body: StreamBuilder(
stream: bloc.allMovies,
builder: (context, AsyncSnapshot<ItemModel> snapshot) {
if (snapshot.hasData) {
return buildList(snapshot);
} else if (snapshot.hasError) {
return Text(snapshot.error.toString());
}
return Center(child: CircularProgressIndicator());
},
),
);
}
Widget buildList(AsyncSnapshot<ItemModel> snapshot) {
return GridView.builder(
itemCount: snapshot.data.results.length,
gridDelegate:
new SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 2),
itemBuilder: (BuildContext context, int index) {
return GridTile(
child: InkResponse(
enableFeedback: true,
child: Image.network(
'https://image.tmdb.org/t/p/w185${snapshot.data
.results[index].poster_path}',
fit: BoxFit.cover,
),
onTap: () => openDetailPage(snapshot.data, index),
),
);
});
}
openDetailPage(ItemModel data, int index) {
Navigator.push(
context,
MaterialPageRoute(builder: (context) {
return MovieDetailBlocProvider(
child: MovieDetail(
title: data.results[index].title,
posterUrl: data.results[index].backdrop_path,
description: data.results[index].overview,
releaseDate: data.results[index].release_date,
voteAverage: data.results[index].vote_average.toString(),
movieId: data.results[index].id,
),
);
}),
);
}
}
我们最终返回了一个包裹MovieDetail的MovieDetailBlocProvider,这样MovieDetailBloc就可以被MovieDetail组件以及子组件所访问了。
接下来就是movie_detail.dart类了
import 'dart:async';
import 'package:flutter/material.dart';
import '../blocs/movie_detail_bloc_provider.dart';
import '../models/trailer_model.dart';
class MovieDetail extends StatefulWidget {
final posterUrl;
final description;
final releaseDate;
final String title;
final String voteAverage;
final int movieId;
MovieDetail({
this.title,
this.posterUrl,
this.description,
this.releaseDate,
this.voteAverage,
this.movieId,
});
@override
State<StatefulWidget> createState() {
return MovieDetailState(
title: title,
posterUrl: posterUrl,
description: description,
releaseDate: releaseDate,
voteAverage: voteAverage,
movieId: movieId,
);
}
}
class MovieDetailState extends State<MovieDetail> {
final posterUrl;
final description;
final releaseDate;
final String title;
final String voteAverage;
final int movieId;
MovieDetailBloc bloc;
MovieDetailState({
this.title,
this.posterUrl,
this.description,
this.releaseDate,
this.voteAverage,
this.movieId,
});
@override
void didChangeDependencies() {
bloc = MovieDetailBlocProvider.of(context);
bloc.fetchTrailersById(movieId);
super.didChangeDependencies();
}
@override
void dispose() {
bloc.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
top: false,
bottom: false,
child: NestedScrollView(
headerSliverBuilder: (BuildContext context,
bool innerBoxIsScrolled) {
return <Widget>[
SliverAppBar(
expandedHeight: 200.0,
floating: false,
pinned: true,
elevation: 0.0,
flexibleSpace: FlexibleSpaceBar(
background: Image.network(
"https://image.tmdb.org/t/p/w500$posterUrl",
fit: BoxFit.cover,
)),
),
];
},
body: Padding(
padding: const EdgeInsets.all(10.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Container(margin: EdgeInsets.only(top: 5.0)),
Text(
title,
style: TextStyle(
fontSize: 25.0,
fontWeight: FontWeight.bold,
),
),
Container(margin: EdgeInsets.only(top: 8.0,
bottom: 8.0)),
Row(
children: <Widget>[
Icon(
Icons.favorite,
color: Colors.red,
),
Container(
margin: EdgeInsets.only(left: 1.0,
right: 1.0),
),
Text(
voteAverage,
style: TextStyle(
fontSize: 18.0,
),
),
Container(
margin: EdgeInsets.only(left: 10.0,
right: 10.0),
),
Text(
releaseDate,
style: TextStyle(
fontSize: 18.0,
),
),
],
),
Container(margin: EdgeInsets.only(top: 8.0,
bottom: 8.0)),
Text(description),
Container(margin: EdgeInsets.only(top: 8.0,
bottom: 8.0)),
Text(
"Trailer",
style: TextStyle(
fontSize: 25.0,
fontWeight: FontWeight.bold,
),
),
Container(margin: EdgeInsets.only(top: 8.0,
bottom: 8.0)),
StreamBuilder(
stream: bloc.movieTrailers,
builder:
(context, AsyncSnapshot<Future<TrailerModel>> snapshot) {
if (snapshot.hasData) {
return FutureBuilder(
future: snapshot.data,
builder: (context,
AsyncSnapshot<TrailerModel> itemSnapShot) {
if (itemSnapShot.hasData) {
if (itemSnapShot.data.results.length > 0)
return trailerLayout(itemSnapShot.data);
else
return noTrailer(itemSnapShot.data);
} else {
return Center(child: CircularProgressIndicator());
}
},
);
} else {
return Center(child: CircularProgressIndicator());
}
},
),
],
),
),
),
),
);
}
Widget noTrailer(TrailerModel data) {
return Center(
child: Container(
child: Text("No trailer available"),
),
);
}
Widget trailerLayout(TrailerModel data) {
if (data.results.length > 1) {
return Row(
children: <Widget>[
trailerItem(data, 0),
trailerItem(data, 1),
],
);
} else {
return Row(
children: <Widget>[
trailerItem(data, 0),
],
);
}
}
trailerItem(TrailerModel data, int index) {
return Expanded(
child: Column(
children: <Widget>[
Container(
margin: EdgeInsets.all(5.0),
height: 100.0,
color: Colors.grey,
child: Center(child: Icon(Icons.play_circle_filled)),
),
Text(
data.results[index].name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
);
}
}
在这里,我们将初始化bloc的代码放在了didChangeDependencies()中,原因在这里,最后是项目地址
自己的话
这两篇文章原作者通过一步步深入和优化的方式向我们介绍了Flutter项目架构和分层的东西,看了之后,让我有种豁然开朗的感觉,瞬间知道该怎么动手开发一个Flutter项目了。