使用 BLoC 在 init 上加载数据
Posted
技术标签:
【中文标题】使用 BLoC 在 init 上加载数据【英文标题】:Loading data on init with BLoC 【发布时间】:2020-05-08 11:00:38 【问题描述】:刚开始使用 BLoC 进行 Flutter。基于搜索模板构建,希望在应用加载而不是状态更改时加载数据 (items
)。
getCrystals()
方法在搜索意图 .isEmpty
时返回正确的数据,但如何在应用加载时完成?
crystal_repo.dart
abstract class CrystalRepo
Future<BuiltList<Crystal>> getCrystals();
Future<BuiltList<Crystal>> searchCrystal(
@required String query,
int startIndex: 0,
);
crystal_repo_impl.dart
class CrystalRepoImpl implements CrystalRepo
static const _timeoutInMilliseconds = 120000; // 2 minutes
final Map<String, Tuple2<int, CrystalResponse>> _cached = ;
///
final CrystalApi _api;
final Mappers _mappers;
CrystalRepoImpl(this._api, this._mappers);
@override
Future<BuiltList<Crystal>> searchCrystal(
String query,
int startIndex = 0,
) async
assert(query != null);
final crystalsResponse = await _api.searchCrystal(
query: query,
startIndex: startIndex,
);
final crystal = crystalsResponse.map(_mappers.crystalResponseToDomain);
return BuiltList<Crystal>.of(crystal);
@override
Future<BuiltList<Crystal>> getCrystals() async
final crystalsResponse = await _api.getCrystals();
final crystal = crystalsResponse.map(_mappers.crystalResponseToDomain);
return BuiltList<Crystal>.of(crystal);
search_bloc.dart
class SearchBloc implements BaseBloc
/// Input [Function]s
final void Function(String) changeQuery;
final void Function() loadNextPage;
final void Function() retryNextPage;
final void Function() retryFirstPage;
final void Function(String) toggleFavorited;
/// Ouput [Stream]s
final ValueStream<SearchPageState> state$;
final ValueStream<int> favoriteCount$;
/// Subscribe to this stream to show message like snackbar, toast, ...
final Stream<SearchPageMessage> message$;
/// Clean up resource
final void Function() _dispose;
SearchBloc._(
this.changeQuery,
this.loadNextPage,
this.state$,
this._dispose,
this.retryNextPage,
this.retryFirstPage,
this.toggleFavorited,
this.message$,
this.favoriteCount$,
);
@override
void dispose() => _dispose();
factory SearchBloc(final CrystalRepo crystalRepo, final FavoritedCrystalsRepo favCrystalsRepo,)
assert(crystalRepo != null);
assert(favCrystalsRepo != null);
/// Stream controllers, receive input intents
final queryController = PublishSubject<String>();
final loadNextPageController = PublishSubject<void>();
final retryNextPageController = PublishSubject<void>();
final retryFirstPageController = PublishSubject<void>();
final toggleFavoritedController = PublishSubject<String>();
final controllers = [
queryController,
loadNextPageController,
retryNextPageController,
retryFirstPageController,
toggleFavoritedController,
];
/// Debounce query stream
final searchString$ = queryController
.debounceTime(const Duration(milliseconds: 300))
.distinct()
.map((s) => s.trim());
/// Search intent
final searchIntent$ = searchString$.mergeWith([
retryFirstPageController.withLatestFrom(
searchString$,
(_, String query) => query,
)
]).map((s) => SearchIntent.searchIntent(search: s));
/// Forward declare to [loadNextPageIntent] can access latest state via [DistinctValueConnectableStream.value] getter
DistinctValueConnectableStream<SearchPageState> state$;
/// Load next page intent
final loadAndRetryNextPageIntent$ = Rx.merge(
[
loadNextPageController.map((_) => state$.value).where((currentState)
/// Can load next page?
return currentState.crystals.isNotEmpty &&
currentState.loadFirstPageError == null &&
currentState.loadNextPageError == null;
),
retryNextPageController.map((_) => state$.value).where((currentState)
/// Can retry?
return currentState.loadFirstPageError != null ||
currentState.loadNextPageError != null;
)
],
).withLatestFrom(searchString$, (currentState, String query) =>
Tuple2(currentState.crystals.length, query),
).map(
(tuple2) => SearchIntent.loadNextPageIntent(
search: tuple2.item2,
startIndex: tuple2.item1,
),
);
/// State stream
state$ = Rx.combineLatest2(
Rx.merge([searchIntent$, loadAndRetryNextPageIntent$]) // All intent
.doOnData((intent) => print('[INTENT] $intent'))
.switchMap((intent) => _processIntent$(intent, crystalRepo))
.doOnData((change) => print('[CHANGE] $change'))
.scan((state, action, _) => action.reduce(state),
SearchPageState.initial(),
),
favCrystalsRepo.favoritedIds$,
(SearchPageState state, BuiltSet<String> ids) => state.rebuild(
(b) => b.crystals.map(
(crystal) => crystal.rebuild((b) => b.isFavorited = ids.contains(b.id)),
),
),
).publishValueSeededDistinct(seedValue: SearchPageState.initial());
final message$ = _getMessage$(toggleFavoritedController, favCrystalsRepo, state$);
final favoriteCount = favCrystalsRepo.favoritedIds$
.map((ids) => ids.length)
.publishValueSeededDistinct(seedValue: 0);
return SearchBloc._(
queryController.add,
() => loadNextPageController.add(null),
state$,
DisposeBag([
...controllers,
message$.listen((message) => print('[MESSAGE] $message')),
favoriteCount.listen((count) => print('[FAV_COUNT] $count')),
state$.listen((state) => print('[STATE] $state')),
state$.connect(),
message$.connect(),
favoriteCount.connect(),
]).dispose,
() => retryNextPageController.add(null),
() => retryFirstPageController.add(null),
toggleFavoritedController.add,
message$,
favoriteCount,
);
/// Process [intent], convert [intent] to [Stream] of [PartialStateChange]s
Stream<PartialStateChange> _processIntent$(
SearchIntent intent,
CrystalRepo crystalRepo,
)
perform<RESULT, PARTIAL_CHANGE>(
Stream<RESULT> streamFactory(),
PARTIAL_CHANGE map(RESULT a),
PARTIAL_CHANGE loading,
PARTIAL_CHANGE onError(dynamic e),
)
return Rx.defer(streamFactory)
.map(map)
.startWith(loading)
.doOnError((e, s) => print(s))
.onErrorReturnWith(onError);
searchIntentToPartialChange$(SearchInternalIntent intent) =>
perform<BuiltList<Crystal>, PartialStateChange>(
()
if (intent.search.isEmpty)
return Stream.fromFuture(crystalRepo.getCrystals());
return Stream.fromFuture(crystalRepo.searchCrystal(query: intent.search));
,
(list)
final crystalItems = list.map((crystal) => CrystalItem.fromDomain(crystal)).toList();
return PartialStateChange.firstPageLoaded(crystals: crystalItems, textQuery: intent.search,);
,
PartialStateChange.firstPageLoading(),
(e)
return PartialStateChange.firstPageError(error: e,textQuery: intent.search,);
,
);
loadNextPageIntentToPartialChange$(LoadNextPageIntent intent) =>
perform<BuiltList<Crystal>, PartialStateChange>();
return intent.join(
searchIntentToPartialChange$,
loadNextPageIntentToPartialChange$,
);
search_state.dart
abstract class SearchPageState implements Built<SearchPageState, SearchPageStateBuilder>
String get resultText;
BuiltList<CrystalItem> get crystals;
bool get isFirstPageLoading;
@nullable
Object get loadFirstPageError;
bool get isNextPageLoading;
@nullable
Object get loadNextPageError;
SearchPageState._();
factory SearchPageState([updates(SearchPageStateBuilder b)]) = _$SearchPageState;
factory SearchPageState.initial()
return SearchPageState((b) => b
..resultText = ''
..crystals = ListBuilder<CrystalItem>()
..isFirstPageLoading = false
..loadFirstPageError = null
..isNextPageLoading = false
..loadNextPageError = null);
class PartialStateChange extends Union6Impl<
LoadingFirstPage,
LoadFirstPageError,
FirstPageLoaded,
LoadingNextPage,
NextPageLoaded,
LoadNextPageError>
static const Sextet<LoadingFirstPage, LoadFirstPageError, FirstPageLoaded,
LoadingNextPage, NextPageLoaded, LoadNextPageError> _factory =
Sextet<LoadingFirstPage, LoadFirstPageError, FirstPageLoaded,
LoadingNextPage, NextPageLoaded, LoadNextPageError>();
PartialStateChange._(
Union6<LoadingFirstPage, LoadFirstPageError, FirstPageLoaded,
LoadingNextPage, NextPageLoaded, LoadNextPageError>
union)
: super(union);
factory PartialStateChange.firstPageLoading()
return PartialStateChange._(
_factory.first(
const LoadingFirstPage()
)
);
factory PartialStateChange.firstPageError(
@required Object error,
@required String textQuery,
)
return PartialStateChange._(
_factory.second(
LoadFirstPageError(
error: error,
textQuery: textQuery,
),
),
);
factory PartialStateChange.firstPageLoaded(
@required List<CrystalItem> crystals,
@required String textQuery,
)
return PartialStateChange._(
_factory.third(
FirstPageLoaded(
crystals: crystals,
textQuery: textQuery,
),
)
);
factory PartialStateChange.nextPageLoading()
return PartialStateChange._(
_factory.fourth(
const LoadingNextPage()
)
);
factory PartialStateChange.nextPageLoaded(
@required List<CrystalItem> crystals,
@required String textQuery,
)
return PartialStateChange._(
_factory.fifth(
NextPageLoaded(
textQuery: textQuery,
crystals: crystals,
),
),
);
factory PartialStateChange.nextPageError(
@required Object error,
@required String textQuery,
)
return PartialStateChange._(
_factory.sixth(
LoadNextPageError(
textQuery: textQuery,
error: error,
),
),
);
/// Pure function, produce new state from previous state [state] and partial state change [partialChange]
SearchPageState reduce(SearchPageState state)
return join<SearchPageState>(
(LoadingFirstPage change)
return state.rebuild((b) => b..isFirstPageLoading = true);
,
(LoadFirstPageError change)
return state.rebuild((b) => b
..resultText = "Search for '$change.textQuery', error occurred"
..isFirstPageLoading = false
..loadFirstPageError = change.error
..isNextPageLoading = false
..loadNextPageError = null
..crystals = ListBuilder<CrystalItem>());
,
(FirstPageLoaded change)
return state.rebuild((b) => b
//..resultText = "Search for $change.textQuery, have $change.crystals.length crystals"
..resultText = ""
..crystals = ListBuilder<CrystalItem>(change.crystals)
..isFirstPageLoading = false
..isNextPageLoading = false
..loadFirstPageError = null
..loadNextPageError = null);
,
(LoadingNextPage change)
return state.rebuild((b) => b..isNextPageLoading = true);
,
(NextPageLoaded change)
return state.rebuild((b)
var newListBuilder = b.crystals..addAll(change.crystals);
return b
..crystals = newListBuilder
..resultText =
"Search for '$change.textQuery', have $newListBuilder.length crystals"
..isNextPageLoading = false
..loadNextPageError = null;
);
,
(LoadNextPageError change)
return state.rebuild((b) => b
..resultText =
"Search for '$change.textQuery', have $state.crystals.length crystals"
..isNextPageLoading = false
..loadNextPageError = change.error);
,
);
@override
String toString() => join<String>(_toString, _toString, _toString, _toString, _toString, _toString);
search_page.dart
class SearchListViewWidget extends StatelessWidget
final SearchPageState state;
const SearchListViewWidget(Key key, @required this.state)
: assert(state != null),
super(key: key);
@override
Widget build(BuildContext context)
final bloc = BlocProvider.of<SearchBloc>(context);
if (state.loadFirstPageError != null)
// LOOKING TO HAVE items LOADED ON APP LOAD //
final BuiltList<CrystalItem> items = state.crystals;
if (items.isEmpty)
debugPrint('items.isEmpty');
return ListView.builder(
itemCount: items.length + 1,
padding: const EdgeInsets.all(0),
physics: const BouncingScrollPhysics(),
itemBuilder: (context, index)
debugPrint('itemBuilder');
if (index < items.length)
final item = items[index];
return SearchCrystalItemWidget(
crystal: item,
key: Key(item.id),
);
if (state.loadNextPageError != null)
final Object error = state.loadNextPageError;
return Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Text(
error is HttpException
? error.message
: 'An error occurred $error',
textAlign: TextAlign.center,
maxLines: 2,
style:
Theme.of(context).textTheme.body1.copyWith(fontSize: 15),
),
SizedBox(height: 8),
RaisedButton(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
onPressed: bloc.retryNextPage,
padding: const EdgeInsets.all(16.0),
child: Text(
'Retry',
style: Theme.of(context).textTheme.body1.copyWith(fontSize: 16),
),
elevation: 4.0,
),
],
),
);
return Container();
,
);
【问题讨论】:
运气好能解决这个问题吗? 【参考方案1】:最终通过在app.dart
中传入一个空查询来解决此问题
home: Consumer2<FavoritedCrystalsRepo, CrystalRepo>(
builder: (BuildContext context, FavoritedCrystalsRepo sharedPref, CrystalRepo crystalRepo)
final searchBloc = SearchBloc(crystalRepo, sharedPref);
// Do the first search to get first result on init
searchBloc.changeQuery('');
return BlocProvider<SearchBloc>(
child: SearchPage(),
initBloc: () => searchBloc,
);
,
【讨论】:
以上是关于使用 BLoC 在 init 上加载数据的主要内容,如果未能解决你的问题,请参考以下文章
在加载 Firebase 快照时使用 Flutter 中的 Bloc 流式等待和阻止其他事件