Flutter下MVVM——Bloc的探索

转载注明出处:https://blog.csdn.net/skysukai

1、背景

1.1 MVVM

MVVM是Model-View-ViewModel的简写。它本质上就是MVC 的改进版。MVVM 就是将其中的View 的状态和行为抽象化,让我们将视图 UI 和业务逻辑分开。当然这些事 ViewModel 已经帮我们做了,它可以取出 Model 的数据同时帮忙处理 View 中由于需要展示内容而涉及的业务逻辑。通俗地来说,MVVM要达到的目的就是数据发生变化自动更新UI显示,而不用显式的调用UI刷新的代码。

1.2 Bloc

BLoC表示为业务逻辑组件 (Business Logic Component)。为什么会想到使用Bloc呢?因为项目中有大量的UI刷新,随着业务的增加,setstate的数量不断上升。flutter的生命周期管理变得混乱起来,这时候就考虑了引入Bloc。来达到不使用或减少使用setstate的目的。

2、实践1——搜索首界面使用BLoC

最先想到了在项目中的搜索模块引入Bloc,这个部分逻辑简单,如果出错代码回退比较容易。先来看下搜索的设计稿:
搜索首页
这是搜索首界面的设计稿,初次进入页面时,会给用户推荐热搜关键字及热搜专辑。

2.1 setstate实现

比如搜索首页,用白线框出了内容展示的地方。按一般的思路,只需要请求一次数据,再调用setstate刷新界面即可。而对于搜索结果页,则每个tab页单独获取数据即可。给出一小段示例代码:

  void _loadFromServer() {
  	//请求热搜关键字
    if (_hotWordList.length == 0) {
      Request.getHotWordList(HotRecommendListParam(pageNum: 1, pageSize: 10),
		//请求成功回调
		 (dynamic result) {
            HotwordListResult hotwordListResult = result as HotwordListResult;
            if (hotwordListResult.count > 0) {
              if(mounted) {
              //刷新界面
                setState(() {
                  if(_hotWordList.length == 0) {
                    _hotWordList.addAll(hotwordListResult.data);
                  }
                });
              }
            }
          }, 
          //请求失败回调
          (int code, String desc) {
            Log.d(TAG, "error: $code, $desc");
            if(mounted) {
              setState(() {
                _loadingHotWord = false;
                _loadHotWordError = true;
              });
            }
          });
    }
	……
  }

代码里边,请求成功和失败都调用了setstate来刷新界面。

2.2 BLoC实现

BLoC的实现参考了大神的架构传送门,即设立一个全局统一的BLoC,其他BLoC继承于这个顶层BLoC。搜索模块设置一个BLoC:

class SearchMainBloc implements BlocBase {
  BehaviorSubject<List<String>> _hotWordController =
    BehaviorSubject<List<String>>();

  Sink<List<String>> get _hotWordSink => _hotWordController.sink;

  Stream<List<String>> get hotWordStream => _hotWordController.stream;

  @override
  void dispose() {
    _hotWordController.close();
  }

  void getHotWord() {
    Request.getHotWordList(HotRecommendListParam(pageNum: 1, pageSize: 10),
            (dynamic result) {
          HotwordListResult hotwordListResult =
          result as HotwordListResult;
		  //刷新界面
          _hotWordSink.add(UnmodifiableListView<String>(hotwordListResult.data));
        }, (int code, String desc) {

        });
  }
……

再来看widget实现:

	……
  Widget _buildStreamBody(BuildContext context) {
    final SearchMainBloc bloc = BlocProvider.of<SearchMainBloc>(context);
    bloc.getHotWord();

    return Column(
      mainAxisAlignment: MainAxisAlignment.start,
      children: <Widget>[
        StreamBuilder<List<String>>(
          stream: bloc.hotWordStream,
          builder: (BuildContext context,
            AsyncSnapshot<List<String>> snapshot) {
            return Flexible(
              child: GridView.builder(
                gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
                  crossAxisCount: 2,
                  childAspectRatio: 5.4,
                ),
                itemBuilder: (BuildContext context, int index) => _SearchHomeWordItem(
                  words: snapshot.data[index],
                  index: index,
                ),
                itemCount: (snapshot.data == null ? 0 : snapshot.data.length),
              )
            );
          },
        ),
		……
      ],
    );
  }
  ……

可以看到,代码当中没有直接调用setstate达到了刷新界面的目的。

3、 实践2——搜索结果页使用BLoC

搜索结果
这是搜索结果页的设计稿,搜索结果分成了四个部分:小视频、专辑、照片及用户。之所以把搜索结果页单独提出来讲是因为,通常在flutter中,我们会用TabBarView来做页签,这里涉及到一个问题就是父Widget和子Widget的状态交互。在设计稿的顶部输入框输入搜索内容,需要通知子tab刷新界面;切换到其他tab页时,也需根据当前搜索内容来发起搜索请求。

3.1 setstate实现

给出一小段实现代码:

 //停止刷新回调
 typedef void StopRefresh();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: PreferredSize(
        child: AppBar(
          bottom: PreferredSize(
              child: SearchBarInSearchPage(
                //输入框有输入回调
                handleSearch: _handleSearch,
                //textController
                textController: _setTextController,
              ),
			……
        ),
      ),
      body: Column(
        children: <Widget>[
          Container(
            child: TabBar(
              controller: _tabController,
			  ……
              tabs: <Widget>[
               //Tab标题
                Tab(text: S.of(context).headingSmallVideo),
				……
              ],
            ),
          ),
        Expanded(
          child: Padding(
            child: TabBarView(
              controller: _tabController,
              children: <Widget>[
               //Tab实现
                SearchSmallVideoList(searchText: _searchText, textChanged: _notifyChange, stopRefresh: _resetFlag),
				……
              ],
            ),
          )),
        ],
      ),
    );
  }
  //触发一次搜索
  _handleSearch(BuildContext context, String searchText) {
    setState(() {
      _searchText = searchText;
      _notifyChange = true;
    });
  }
  //停止刷新界面
  _resetFlag() {
    setState(() {
      _notifyChange = false;
    });
  }

小视频搜索页的具体实现:

  @override
  Widget build(BuildContext context) {
    //输入框内容变化,发起一次新的搜索,重置请求参数及数据
    if(widget.textChanged) {
      _searchVideoList.clear();
      _pageNum = 1;
      _count = 0;
      ……
    }
  }
  ……
  _loadFromServer(onSuccess, onFail) {
    //请求成功回调
    RequestSuccess onRequestSuccess = (result) {
	  //通知父Widget停止刷新
      widget.stopRefresh();
      if (searchVideoListResult.count > 0) {
        if(mounted) {
          setState(() {
            ……
          });
        }
      }
    };
	//请求失败回调
    RequestFailure onRequestFailure = (code, desc) {
      //通知父Widget停止刷新
      widget.stopRefresh();
	  ……
    };

    Request.getVideoSearchList(
        SearchTypeListParam(keywords: _searchText, type: SEARCH_SMALL_VIDEO, pageNum: _pageNum, pageSize: _pageSize),
        onRequestSuccess, onRequestFailure);
  }

不同于搜索首界面的交互,搜索结果页的交互变得复杂起来。输入框有内容输入时,触发重建UI;在建立子Widget的时候,将停止刷新作为一个参数传递widget.stopRefresh(),请求完成时,停止刷新。再来看一下BLoC的实现。

3.2 BLoC实现

采用setstate的方法,对于父Widget和子Widget的状态交互管理变得有些复杂,那采用BLoC是否能解决这个问题呢?仔细分析,搜索结果的触发条件应该是搜索内容,搜索内容的改变引起搜索结果的改变。那应该可以给搜索内容设置一个BLoC,这个BLoC的改变触发搜索结果的更新。给出代码:

  @override
  Widget build(BuildContext context) {
    final SearchMainBloc bloc = BlocProvider.of<SearchMainBloc>(context);
	//有内容输入
    _handleSearch(BuildContext context, String searchText) {
      _searchText = searchText;
      //触发UI刷新
      bloc.searchTextSink.add(searchText);
    }

    return Scaffold(
      appBar: PreferredSize(
        child: AppBar(
          bottom: PreferredSize(
              child: SearchBarInSearchPage(
                handleSearch: _handleSearch,
                textController: _setTextController,
              ),
            )),
          ),
        ),
      ),
      body: StreamBuilder<String>(
        //为搜索内容设置BLoC,以此为触发刷新的依据
        stream: bloc.searchTextStream,
        builder: (BuildContext context, AsyncSnapshot<String> snapshot) {
            return Column(
              children: <Widget>[
                Container(
                  height: ScreenUtils.px2dp(267),
                  child: TabBar(
					……
                    tabs: <Widget>[
                      Tab(text: S.of(context).headingSmallVideo),
					  ……
                    ],
                  ),
                ),
             Expanded(
                child: Padding(
                  child: TabBarView(
                    controller: _tabController,
                    children: <Widget>[
                      SearchSmallVideoList(searchText: snapshot.data),
					  ……
                    ],
                  ),
                )),
              ],
            );
        },
      ),
    );
  }

页签的具体实现:

    _loadFromStream(onSuccess, onFail) {
	  //请求成功回调
      RequestSuccess onRequestSuccess = (result) {
        VideoListResult searchVideoListResult = result as VideoListResult;

        if(searchVideoListResult.data != null) {
          _smallVideoList.addAll(searchVideoListResult.data);
        }
		//触发界面刷新
        bloc.searchSmallVideoSink.add(UnmodifiableListView<VideoInfo>(_smallVideoList));
        ……
      };
	  //请求失败回调
      RequestFailure onRequestFailure = (code, desc) {
		……
      };

      Request.getVideoSearchList(
          SearchTypeListParam(keywords: _searchText, type: SEARCH_SMALL_VIDEO, pageNum: _pageNum, pageSize: _pageSize),
          onRequestSuccess, onRequestFailure);
    }

看上去,采用BLoC之后,省去了各种回调接口,代码变得简单清晰了。这里设置了两个BLoC,一个是主BLoC即输入框的BLoC,另一个是数据的BLoC,用于显示页面。

4、实践3——更复杂的交互

添加好友
这是添加好友界面的设计稿,依然有一个搜索框。除此之外,列表支持下拉刷新,ListView里的Item还有三种状态:常态、已发送过好友申请、申请被拒。

4.1 setstate实现

给出请求关键代码:

  _loadFromServer(onSuccess, onFail) {
    String searchStr = "";
    //请求成功回调
    RequestSuccess onRequestSuccess = (result) {
	  ……
      setState(() {
	    ……
      });
    };
	//请求失败回调
    RequestFailure onRequestFailure = (code, desc) {
	……
    };

    if(_textController.text != null && _textController.text != "") {
      searchStr = _textController.text;
    }

    Request.getStrangerList(
      AddFriendParam(
        keywords: searchStr,
        pagenum: _pageNum,
        pagesize: _pageSize
      ),
      onRequestSuccess,
      onRequestFailure
    );
  }

给输入框设置TextEditingController,以此来作为搜索条件,初次进入时,设置搜索条件为“”初始化界面。ListItem还有专门的点击事件,用于区别当前推荐好友是否已经发送过申请,于是需要设置GestureDetector监听点击事件:

  void _sendMessageDialog(BuildContext context, StrangerInfo info) {
    showDialog<Null>(
      context: context,
      builder: (BuildContext context) {
        return TextFieldDialog(
		  ……
          sendMessage:(String content) {
		  ……

            Request.addFriend(
              param,
              //请求成功回调
              (result) {
                setState(() {
                  ……
                });
              },
              //请求失败回调
              (code, desc) {
			    ……
              }
            );
          }
        );
      }
    );
  }

发生过点击事件服务器响应成功之后,刷新界面。

4.2 BLoC实现

有了搜索结果页的实践之后,这个界面的实现就有了思路:搜索框设置一个BLoC,数据展示设置一个BLoC,而这个页面还需给ListItem设置一个BLoC用于ListItem的状态更新:

  @override
  Widget build(BuildContext context) {
    final FriendBloc bloc = BlocProvider.of<FriendBloc>(context);

    return Scaffold(
      body: RefreshIndicator(
        //搜索框设置的BLoC
        child: StreamBuilder<String>(
          stream: bloc.searchFriendStream,
          builder: (BuildContext context,
            AsyncSnapshot<String> snapshot) {
              return _getBody(bloc, snapshot.data);
            }
        ),
        onRefresh: _refreshList
      )
    );
  }

  Future<void> _refreshList() async{
    await Future.delayed(Duration(seconds: 0), () {
	  ……
    });

    return Future.value(true);
  }

  Widget _getBody(FriendBloc bloc, String searchCondition) {
	//加载数据设置的BLoC
    return StreamBuilder<List<StrangerInfo>>(
      stream: bloc.strangerListStream,
      builder: (BuildContext context,
          AsyncSnapshot<List<StrangerInfo>> snapshot) {
        if(snapshot.hasData) {
          return SliverList(
            ……
            delegate: SliverChildBuilderDelegate(
                (BuildContext context, int i) {
                  //构建ListItem
                  return _getListItem(context, snapshot.data[index], bloc);
                },
                childCount: snapshot.data.length
           ));
        }
        return EmptyView();
      },
    );
  }

加载数据关键代码:

	//将搜索框BLoC的snapshot.data作为搜索条件searchCondition传入
  _loadFromStream(LoadMoreOnSuccess onSuccess, LoadMoreOnFailure onFail, FriendBloc bloc, String searchCondition) {
	//请求成功回调
    RequestSuccess onRequestSuccess = (result) {
	  ……
      bloc.initList(_strangerList);
      bloc.strangerListSink.add(UnmodifiableListView<StrangerInfo>(_strangerList));
    };
	//请求失败回调
    RequestFailure onRequestFailure = (code, desc) {
	  ……
    };

    Request.getStrangerList(
        AddFriendParam(
            keywords: searchCondition != null ? searchCondition : "",
            pagenum: _pageNum,
            pagesize: _pageSize
        ),
        onRequestSuccess,
        onRequestFailure
    );
  }

发送好友请求关键代码:

  void _sendMessageDialog(BuildContext context, StrangerInfo info, FriendBloc bloc) {
    showDialog(
      builder: (BuildContext context) {
        return TextFieldDialog(
          ……
          sendMessage:(String content) {
			……
            Request.addFriend(
              param,
              (result) {
                ……
                //更新ListItem状态,触发这个被点击的Item更新
                info.status = 0;
                bloc.sendApplySink.add(info);
              },
              (code, desc) {
				……
              }
            );
          }
        );
      }
    );
  }

再来看一下FriendBloc,这个BLoC的作用就是好友模块单独的BLoC:

class FriendBloc implements BlocBase {
  List<StrangerInfo> _strangerList = [];

  /// search stranger controller
  BehaviorSubject<String> _searchFriendController =
    BehaviorSubject<String>(seedValue: "");
  Sink<String> get searchFriendSink => _searchFriendController.sink;
  Stream<String> get searchFriendStream => _searchFriendController.stream;

  /// stranger list controller
  BehaviorSubject<List<StrangerInfo>> _strangerController =
    BehaviorSubject<List<StrangerInfo>>();
  Sink<List<StrangerInfo>> get strangerListSink => _strangerController.sink;
  Stream<List<StrangerInfo>> get strangerListStream => _strangerController.stream;

  ……

  FriendBloc () {
    _sendApplyController.listen(_handleSendApply);
  }
  //点击发送申请按钮时触发界面刷新
  void _handleSendApply(StrangerInfo info) {
    for(StrangerInfo strangerInfo in _strangerList) {
      if(strangerInfo.uid == info.uid) {
        strangerInfo.status = info.status;
      }
    }
    strangerListSink.add(UnmodifiableListView<StrangerInfo>(_strangerList));
  }

  void initList(List<StrangerInfo> list) {
    _strangerList.addAll(list);
  }
}

这样,添加好友界面也实现了BLoC。仔细分析,由于发送申请,导致ListItem刷新这个功能的加入,导致代码量也跟着增多起来。这套代码虽然没有setstate的调用,复杂度确上升了不少。BLoC也设置了三个,有没有更简洁的代码呢?

4.3 BLoC的其他尝试

4.3.1 搜索BLoC和数据BLoC合一

添加好友列表页数据展示其实是以搜索框的输入内容为准,即搜索框的输入内容决定了页面的展示内容。那给搜索框设置的BLoC和数据的BLoC是否能合二为一呢?答案是肯定的。
页面实现:

  @override
  Widget build(BuildContext context) {
    final FriendBlocTest bloc = FriendBlocTest();

    return Scaffold(
      body: StreamBuilder<List<StrangerInfo>>(
        stream: bloc.strangerList,
        builder: (BuildContext context,
            AsyncSnapshot<List<StrangerInfo>> snapshot) {
          return _getBody(bloc, snapshot.data);
        }
      )
    );
  }

  Widget _getBody(FriendBlocTest bloc, List<StrangerInfo> strangerList) {
    if(strangerList == null) {
      return Center(
        child: EmptyView(),
      );
    } else {
      return ListView.builder(
        itemCount: strangerList.length,
        itemBuilder: (context, index) {
          return _getListItem(context, strangerList[index], bloc);
        },
      );
    }
  }

搜索框触发搜索的代码:

onSubmitted: (searchStr) => bloc.onTextChanged.add(searchStr),

BLoC实现:

class FriendBlocTest implements BlocBase {
  static List<StrangerInfo> _strangerList = [];
  final Sink<String> onTextChanged;
  final Stream<List<StrangerInfo>> strangerList;

  factory FriendBlocTest () {
    final onTextChanged = PublishSubject<String>();

    final strangerList = onTextChanged
      .distinct()
      .switchMap((String term) => _search(term))
      .startWith(null);

    return FriendBlocTest._(onTextChanged, strangerList);
  }

  FriendBlocTest._(this.onTextChanged, this.strangerList);

  static Stream<List<StrangerInfo>> _search(String term) async* {
    try {
      Dio dio = new Dio(BaseOptions(
		……
      ));

      Response<RequestResult> response = await dio.post(
		  ……
          queryParameters: AddFriendParam(
			……
          ).toJson()
      );
	  //请求成功
      if(response.data.resultInfo.resultCode == "200") {
        var result = StrangerListResult.fromJson(response.data.result);
        StrangerListResult strangerListResult = result as StrangerListResult;
        _strangerList.addAll(strangerListResult.data);
      }
	  //返回请求结果
      yield _strangerList;
    } on DioError catch(e) {
      ……
    }
  }
}

通过这种方式就是实现了只设置一个BLoC来达到搜索框输入改变触发界面刷新,代码简洁了不少。不过,这中方式必须等待网络请求的结果,返回数据来到达刷新界面的效果,而不能通过回调的方式。项目中封装的网络请求都是通过回调的方式来做的,为了维持代码风格的统一,这种方案未提交进代码而作为了一种有益尝试。

4.3.1 为ListItem设置子BLoC

在4.2中,我们为了刷新ListItem,在FriendBloc中设置了一个BLoC用于Item刷新。这部分代码跟界面数据显示关系不大,根据大神的示范可以设置一个子BLoC。这样也带来一个问题,需要把ListItem单独封装成一个类,代码复杂度也上升了。关于怎么给ListItem里面的Widget设置BLoC以更新界面,我在网上查了很多资料,并没有一个简单的方法可以做到,至少我没有找到。这里就直接贴出大神的代码。
ListItem关键代码:

FavoriteMovieBloc _bloc;
  void _createBloc() {
    //初始化ListItem子BLoC
    _bloc = FavoriteMovieBloc(widget.movieCard);
    //依赖注入,初始化ListItem子BLoC里的所有数据的数据流
    _subscription = widget.favoritesStream.listen(_bloc.inFavorites.add);
  }
  
  @override
  Widget build(BuildContext context) {
    final FavoriteBloc bloc = BlocProvider.of<FavoriteBloc>(context);
    List<Widget> children = <Widget>[
	  ……
    ];

    children.add(
      StreamBuilder<bool>(
          stream: _bloc.outIsFavorite,
          initialData: false,
          builder: (BuildContext context, AsyncSnapshot<bool> snapshot) {
            if (snapshot.data == true) {
              return Positioned(
				……
                child: Container(
                    child: InkWell(
   					  ……
                      onTap: () {
                        //点击触发刷新
                        bloc.inRemoveFavorite.add(widget.movieCard);
                      },
                    )),
              );
            }
            return Container();
          }),
    );

    return InkWell(
		……
      ),
    );
  }

ListItem子BLoC关键代码:

class FavoriteMovieBloc implements BlocBase {
  //子BLoC主体
  final BehaviorSubject<bool> _isFavoriteController = BehaviorSubject<bool>();
  Stream<bool> get outIsFavorite => _isFavoriteController.stream;
  //所有数据的数据流,用于传入数据
  final StreamController<List<MovieCard>> _favoritesController = StreamController<List<MovieCard>>();
  Sink<List<MovieCard>> get inFavorites => _favoritesController.sink;

  FavoriteMovieBloc(MovieCard movieCard){
    _favoritesController.stream
						//遍历所有数据,得出当前Item是否被点击
                        .map((list) => list.any((MovieCard item) => item.id == movieCard.id))
                        //当前Item被点击,触发界面更新
                        .listen((isFavorite) => _isFavoriteController.add(isFavorite));
  }
……
}

5、总结

经过以上一些尝试,对Flutter下的BLoC有了一定程度的认识。MVVM模式可以使开发者专注于业务流程的开发,比较好用。当然了,Flutter下的BLoC还有其他封装,如果感兴趣可以自行阅读源码。

相关参考:https://www.jianshu.com/p/e7e1bced6890
相关参考:https://www.didierboelens.com/2018/08/reactive-programming---streams---bloc/
相关参考:https://felangel.github.io/bloc/#/gettingstarted
相关参考:https://www.didierboelens.com/2018/12/reactive-programming---streams---bloc---practical-use-cases/
相关参考:https://qiita.com/sensuikan1973/items/64f1a6235bd8ecaf9067
相关参考:https://www.jianshu.com/p/024b19dea138
相关参考:https://juejin.im/post/5be42e9e5188256ccc192c68
相关参考:https://javascript.ctolib.com/Sky24n-flutter_wanandroid.html

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值