使用 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 上加载数据的主要内容,如果未能解决你的问题,请参考以下文章

颤振:如何使用“等待”等待其他 BLoC 事件完成

如何使用flutter bloc模式通过分页加载数据列表?

在加载 Firebase 快照时使用 Flutter 中的 Bloc 流式等待和阻止其他事件

屏幕加载时如何仅调用一次 bloc 事件

flutter_bloc :使 initialState 方法异步

我正在借助 bloc(cubit)创建异步计数器应用程序,每次计数器返回 0 时我都会发出加载状态