StreamController
是 dart:async
的method, 用來管理已實例化的 stream
和 sink
。 sink
和 stream
是相反的,sink
的用途是將 API data 送到 StreamController
, 而 stream
監聽。
Stream
是 sequence of async events, 所以通常是和 Future
一起使用。
BLoCs
是 處理和儲存 business logic 的 objects
sinks
接收來自API 的 input, 透過 streams
提供 output
- 在創建 BLoCs 之前, 每個BLoC都會 implements 這個接口。
abstract class Bloc {
void dispose();
}
- 創建BLoC
class ArticleListBloc implements Bloc {
// 1 RWClient 與 API 溝通
final _client = RWClient();
// 2 <String?> Sink會接收Textfield的字串
final _searchQueryController = StreamController<String?>();
// 3 Sink<String?> 是 public sink interface, 它會傳送事件到這個Bloc
Sink<String?> get searchQuery => _searchQueryController.sink;
// 4 articlesStream 是 view 和 這個bloc 之間的橋樑,
late Stream<List<Article>?> articlesStream;
ArticleListBloc() {
// 5 asyncMap 監聽 client.fetchArticles(query) 是否完成?
//如果完成,會將結果傳到 _searchQueryControll.stream,
// 並附值給 late<Stream<List<Article>?> articlesStream
articlesStream = _searchQueryController.stream
.asyncMap((query) => _client.fetchArticles(query));
}
// 6 StreamController 結束之後, 需要把它關掉,不然會導致 leaking。
@override
void dispose() {
_searchQueryController.close();
}
}
- BLoC 注入 widget tree
- 命名為Provider 是 Flutter convention
- Provider 用來 保存 data 並且 'well provides’ 給它的children
- InheritedWidget 和 StatefulWidget 都能實現關閉所有BLoCs的功能,選擇StatefulWidget 的原因是,代碼比較簡單。
- 步驟三之後, BLoC layer 已完成
// 1 T extends Bloc 意味著只能保存 BLoC objects
class BlocProvider<T extends Bloc> extends StatefulWidget {
final Widget child;
final T bloc;
BlocProvider({
Key? key,
required this.bloc,
required this.child,
}) : super(key: key);
// 2 of method 允與 BlocProvider 獲取 它的子輩的資料 例子: articlesStream
static T of<T extends Bloc>(BuildContext context) {
final BlocProvider<T> provider = context.findAncestorWidgetOfExactType()!;
return provider.bloc;
}
@override
State createState() => _BlocProviderState();
}
class _BlocProviderState extends State<BlocProvider> {
// 3 BlocProvider的context == 它的child.context, so this widget won't render anythining
@override
Widget build(BuildContext context) => widget.child;
// 4 繼承StatefulWidget 為一個原因是,可以使用 dispose()。 當事件結束, 這個widget從 widget tree 移除
// Flutter 會呼叫 dispose()。
@override
void dispose() {
widget.bloc.dispose();
super.dispose();
}
}
- BLoC 和 UI 連接
//class article_list_screen.dart....
@override
Widget build(BuildContext context) {
// 1 BlockProvider 從 widget tree 找到 ArticleListBloc
final bloc = BlocProvider.of<ArticleListBloc>(context);
return Scaffold(
appBar: AppBar(title: const Text('Articles')),
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: TextField(
decoration: const InputDecoration(
border: OutlineInputBorder(),
hintText: 'Search ...',
),
// 2 void add(T) 是 StreamController.sink的 function,當TextField onChanged
// sink 會開始調用 RWClient(API) 並找到相關的 articles 然後發射給 stream
onChanged: bloc.searchQuery.add,
),
),
Expanded(
child:_buildResults(bloc),
)
],
),
);
//3 StreamBuilder 從 bloc.stream 監聽事件, 這個widget會執行 builder和更新widget tree
//當接收到新的事件, 因為StreamBuilder 和 BLoC 在這個專案你不需要使用 setState() 來更新畫面
Widget _buildResults(ArticleListBloc bloc) {
// 4 StreamBuilder透過ArticleListBloc知道了。喔~我需要拿到一堆Article
return StreamBuilder<List<Article>?>(
stream: bloc.articlesStream, // <- Stream<list<Article>?>
builder: (context, snapshot) {
// 5 當stream還沒有資料或沒有資料
final results = snapshot.data;
if (results == null) {
return const Center(child: Text('Loading ...'));
} else if (results.isEmpty) {
return const Center(child: Text('No Results'));
}
// 6 將results 傳遞給常規方法。
return _buildSearchResults(results);
},
);
}
Widget _buildSearchResults(List<Article> results) {
return ListView.builder(
itemCount: results.length,
itemBuilder: (context, index) {
final article = results[index];
return InkWell(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
// 1
child: ArticleListItem(article: article),
),
// 2 導向 article detail page
onTap: () {
// TODO: Later will be implemented
},
);
},
);
}
}
Debouncing ←連結 means the app skips input events that come in short intervals.
- 改善UX and performance issues
- 每當Textfield onchanged 就會發送網路請求,解決方法: (Debouncing)略過短時間的按鍵輸入。
- 當 bloc.qeury.add 時 沒有loading 畫面
- asyncMap 等待請求完成,因此用戶會一一看到所有輸入的查詢響應。通常,您必須忽略先前的請求結果來處理新的查詢。
//Replace
ArticleListBloc() {
articlesStream = _searchQueryController.stream
.asyncMap((query) => _client.fetchArticles(query));
}
//with
ArticleListBloc() {
articlesStream = _searchQueryController.stream
.startWith(null) // 1 如果用戶沒有輸入任何query,將會loading 全部的Articles
// 2 輸入間隔小於0.1秒將會被忽略,並且會忽略大部分的連續輸入直到會後一個字
.debounceTime(const Duration(milliseconds: 100))
.switchMap( // 3 只會發射最後的Stream
(query) => _client.fetchArticles(query)
.asStream() // 4 Convert Future to Stream
// 5 每個fetch request開始時會刪除 StreamBuilder.Articles
//,用來正確的顯示loading畫面
.startWith(null),
);
}
- Article Detail and its BLoC
//step1
class ArticleDetailBloc implements Bloc {
final String id;
final _refreshController = StreamController<void>();
final _client = RWClient();
late Stream<Article?> articleStream;
ArticleDetailBloc({
required this.id,
}) {
articleStream = _refreshController.stream
.startWith({})
.mapTo(id)
.switchMap(
(id) => _client.getDetailArticle(id).asStream(),
)
.asBroadcastStream();
}
@override
void dispose() {
_refreshController.close();
}
}
//step 2
class ArticleDetailScreen extends StatelessWidget {
const ArticleDetailScreen({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
// 1 BlockProvider 從 widget tree 找到 ArticleDetailBloc
final bloc = BlocProvider.of<ArticleDetailBloc>(context);
return Scaffold(
appBar: AppBar(
title: const Text('Articles detail'),
),
body: Container(
alignment: Alignment.center,
child: _buildContent(bloc),
),
);
}
Widget _buildContent(ArticleDetailBloc bloc) {
return StreamBuilder<Article?>(
stream: bloc.articleStream,
builder: (context, snapshot) {
final article = snapshot.data;
if (article == null) {
return const Center(child: CircularProgressIndicator());
}
return ArticleDetail(article);
},
);
}
}
//step3 更新onTap()
Widget _buildSearchResults(List<Article> results) {
return ListView.builder(
itemCount: results.length,
itemBuilder: (context, index) {
final article = results[index];
return InkWell(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
// 1
child: ArticleListItem(article: article),
),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => BlocProvider(
bloc: ArticleDetailBloc(id: article.id),
child: const ArticleDetailScreen(),
- Refresh 功能
- articleStream.first is async and wait for sink.add then, render UI
- • Do you remember the
asBroadcastStream()
call before? It’s required because of this line.first
creates another subscription toarticleStream
.
// 在 ArticleDetailBloc 添加refresh function
class article_detail_bloc.dart
Future refresh() {
final future = articleStream.first;
_refreshController.sink.add({});
return future;
}
//article_detail_screen.dart
...
// 1 下拉更新
body: RefreshIndicator(
// 2 refreshIndicator 需要知道何時hide the loading indicator,所以需要用到Future
onRefresh: bloc.refresh,
child: Container(
alignment: Alignment.center,
child: _buildContent(bloc),
),
),
...
Note : Dart stream doesn’t allow waiting for an event after you send something to sink in a simple way. This code can fail in rare cases when
refresh
is called at the same time an API fetch is in progress. ReturnedFuture
completes early, then the new update comes toarticleStream
andRefreshIndicator
hides itself before the final update. It’s also wrong to send an event to sink and then request thefirst
future. If a refresh event is processed immediately and a newArticle
comes before the call offirst
, the user sees infinity loading.