原文地址:https://medium.com/flutterpub/architect-your-flutter-project-using-bloc-pattern-part-2-d8dd1eca9ba5
上一篇文章中已經(jīng)使用了BLoC模式實(shí)現(xiàn)了項(xiàng)目的構(gòu)建,這篇文章中將會(huì)對(duì)上次的項(xiàng)目進(jìn)行優(yōu)化指孤。
這篇文章主要覆蓋的主題:
- 解決架構(gòu)中的設(shè)計(jì)缺陷
- Single Instance與Scoped Instance(對(duì) BLoC的訪問(wèn))
- Navigation
- RxDart’s Transformers
當(dāng)前架構(gòu)中的設(shè)計(jì)缺陷
第一個(gè)缺陷就是列牺,在MovieBloc類中創(chuàng)建了一個(gè)dispose方法,該方法是用來(lái)關(guān)閉流以防導(dǎo)致內(nèi)存溢出。我們創(chuàng)建了這個(gè)方法划纽,但是從來(lái)沒(méi)有調(diào)用過(guò)脆侮,這將會(huì)導(dǎo)致內(nèi)存溢出。
另一個(gè)缺陷就是在build方法中進(jìn)行網(wǎng)絡(luò)調(diào)用勇劣。
現(xiàn)在MovieList是StatelessWidget靖避,而StatelessWidget是只要將其添加到Widget樹(shù)中,之后所有屬性都是不可變的比默,而build方法時(shí)入口幻捏,由于配置更改,可以被多次調(diào)用命咐。所以該方法不適合網(wǎng)絡(luò)調(diào)用粘咖。而StatelessWidget中也沒(méi)有一個(gè)適合調(diào)用dispose方法的地方嗤攻。
而StatefulWidget方法提供了initState和dispose方法寡喝。initState方法用來(lái)分配資源,而dispose方法用來(lái)在界面回收的之前處理掉這些資源。
首先我們將MovieList從StatelessWidget轉(zhuǎn)換成StatefulWidget情龄,接著在initState中調(diào)用網(wǎng)絡(luò)請(qǐng)求方法,接著在dispose方法中調(diào)用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,
),
);
});
}
}
新功能實(shí)現(xiàn)
我們添加一個(gè)新界面用來(lái)顯示電影詳情蜕企。我們重新設(shè)計(jì)一下app的流程。
上面的圖很簡(jiǎn)單路呜,但是我們還是來(lái)解釋一下:
- MovieList Screen:電影列表界面
- MovieList Bloc:這是一個(gè)從Repository獲取數(shù)據(jù)并傳遞到UI界面的橋梁迷捧。
- MovieDetail Screen: 用來(lái)顯示從列表選擇的電影的詳情。
- Repository:用來(lái)控制數(shù)據(jù)流的中心
- API provider:用來(lái)控制所有的網(wǎng)絡(luò)請(qǐng)求
Single Instance vs Scoped Instance
正如你所看到的胀葱,兩個(gè)Screen都可以訪問(wèn)各自的BLoC類漠秋,我們可以通過(guò)下面的兩種方式將這些BLoC類暴露給各自的Screen。單例是指將BLoC類的單個(gè)引用(Singleton)暴露給Screen抵屿∏旖酰可以從應(yīng)用程序的任何部分訪問(wèn)此類型的BLoC類。任何Screen都可以使用單實(shí)例BLoC類轧葛。
但是Scoped Instance BLoC類具有有限的訪問(wèn)權(quán)限搂抒。它只與Screen相關(guān)聯(lián)。
正如上圖看到的尿扯,只有Screen本身以及其下面兩個(gè)自定的widget可以訪問(wèn)bloc求晶。我們使用InheritedWidget將BLoC包裹起來(lái)。InheritedWidget將包裝Screen衷笋,讓Screen組件和其里面的自定義的組件可以訪問(wèn)Bloc芳杏。Screen的父組件都不能訪問(wèn)該Bloc。
Single Instance只適合用在小型的項(xiàng)目中辟宗,如果你開(kāi)發(fā)的是一個(gè)復(fù)雜的項(xiàng)目爵赵,那么Scoped Widget將會(huì)是你的首選。
添加詳情界面
當(dāng)點(diǎn)擊列表中的其中一個(gè)item時(shí)慢蜓,將會(huì)跳轉(zhuǎn)到該電影的詳情界面亚再,用戶可以看到電影的詳細(xì)信息,然后一些數(shù)據(jù)將從列表界面?zhèn)鞯皆斍榻缑娉柯眨A(yù)告片從服務(wù)器加載氛悬。
在創(chuàng)建文件之前,我們要遵從上一篇文章中提到的項(xiàng)目結(jié)構(gòu)耘柱。首先在ui包中創(chuàng)建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中如果你想從一個(gè)界面跳轉(zhuǎn)到另一個(gè)界面的話,使用Navigator類,我們?cè)?strong>movie_list.dart中實(shí)現(xiàn)導(dǎo)航的邏輯调煎。
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方法中镜遣。
接著我們?nèi)シ?wù)器上獲取預(yù)告片信息:
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包中創(chuàng)建一個(gè)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;
}
接下來(lái)在movie_api_provider.dart文件中實(shí)現(xiàn)網(wǎng)絡(luò)請(qǐng)求谎僻。
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)方法就是我們執(zhí)行網(wǎng)絡(luò)請(qǐng)求然后將返回的信息轉(zhuǎn)換成TrailerModel對(duì)象,并返回Future<TrailerModel>寓辱。
接著在Repository類中添加網(wǎng)絡(luò)調(diào)用實(shí)現(xiàn)艘绍。接下來(lái)讓我們使用Scoped Instance方案來(lái)實(shí)現(xiàn)功能。在bloc包中創(chuàng)建一個(gè)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;
}
}
此類實(shí)現(xiàn)了InheritedWidget诱鞠,并通過(guò)(Context)的of方法來(lái)放問(wèn)bloc。
接下來(lái)編寫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請(qǐng)求航夺,然后返回預(yù)告片列表。用到了RxDart的Transformers崔涂。
Transformers
Transformers是用來(lái)連接兩個(gè)或多個(gè)Subject并獲得最終的結(jié)果阳掐。如果想要對(duì)數(shù)據(jù)進(jìn)行一些操作后,從一個(gè)Subject轉(zhuǎn)換成另一個(gè)Subject的話堪伍,我們將使用Transformers對(duì)傳入的第一個(gè)Subject進(jìn)行一些操作之后傳遞給下一個(gè)Subject锚烦。
在我們的項(xiàng)目中觅闽,我們將movieId添加給_movieId,他是一個(gè)PublishSubject的類型帝雇,我們將_movieId傳遞給ScanStreamTransformer,然后ScanStreamTransformer將進(jìn)行網(wǎng)絡(luò)調(diào)用蛉拙,最后把結(jié)果傳遞給_trailers,_trailers是一個(gè)BehaviorSubject尸闸。
最后一步就是通過(guò)MovieDetail訪問(wèn)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,
),
);
}),
);
}
}
我們最終返回了一個(gè)包裹MovieDetail的MovieDetailBlocProvider孕锄,這樣MovieDetailBloc就可以被MovieDetail組件以及子組件所訪問(wèn)了吮廉。
接下來(lái)就是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()中畸肆,原因在這里宦芦,最后是項(xiàng)目地址
自己的話
這兩篇文章原作者通過(guò)一步步深入和優(yōu)化的方式向我們介紹了Flutter項(xiàng)目架構(gòu)和分層的東西,看了之后轴脐,讓我有種豁然開(kāi)朗的感覺(jué)调卑,瞬間知道該怎么動(dòng)手開(kāi)發(fā)一個(gè)Flutter項(xiàng)目了。