转载注明出处: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