Flutter BLoC 入门指南
我必须承认,我对 Flutter 的初次体验并不好。刚开始使用时,它非常不稳定,而让我望而却步的是缺乏架构模式。我很难轻松地构建应用程序的结构,不得不编写自定义逻辑来实现组件间的良好通信。因此,我放弃了 Flutter 项目,静观其变。
最近我需要开发一个跨平台应用,所以要在 Flutter 和 React Native 之间做选择。由于我的 Web 开发技能仅限于用 HTML 写“Hello World”,所以我决定再给 Flutter 一次机会。我发现 Flutter 已经发生了很大的变化,涌现出了许多新的架构模式。我在一些简单的项目中测试了其中的一些,而我立刻就爱上了 BLoC(块级组件)模式。
国界线介绍
BLoC 代表业务逻辑控制器(Business Logic Controller)。它由谷歌创建,并在2018 年 DartConf 大会上推出。它基于 Streams 和响应式编程构建。
如果你想开始使用 BLoC 架构创建应用,我强烈推荐两个库,它们能大大简化你的开发工作:bloc和flutter_bloc。同时,我也推荐你阅读这两个库的官方文档。文档编写得非常出色,提供了大量适用于大多数用例的示例。我会简要介绍 BLoC 的所有组件,但如果你想深入了解,官方文档是最佳的参考资料。
在BLoC模式中,我们可以区分出四个主要的应用层:
UI(用户界面)层包含了应用程序的所有组件,这些组件对用户可见并可与之交互。由于在 Flutter 中,用户界面的所有部分都是 Widget,因此我们可以说它们都属于这一层。
BLoC——这些类充当数据和UI组件之间的中间层。它监听传入的事件,并在收到响应后发出相应的状态。
存储库- 它负责从单个或多个数据源获取信息,并将其处理以供 UI 类使用。
数据源- 这些类为应用程序提供数据,数据来源包括数据库、网络、共享首选项等所有数据源。
现在,既然我们已经了解了所有基本结构,接下来就应该理解这些层是如何相互通信的。BLoC 模式依赖于两个主要组成部分:
从 UI 传递的事件,其中包含有关必须由代码块处理的特定操作的信息。
这些状态描述了用户界面 (UI) 如何响应数据变化。每个 BLoC 都有其初始状态,该状态在创建时定义。
例如,如果我们要实现一个登录界面,则需要在用户点击相应按钮时传递包含登录信息的LoginEvent 事件。收到响应后,BLoC 应显示SuccessState(表示登录成功)或ErrorState(表示用户输入了错误的凭据或发生了其他错误)。
应用规范
让我们通过一个例子来探讨 BLoC。我创建了一个简单的应用程序,用于获取歌词。它应该允许用户从Genius API 搜索歌词。我还决定允许用户创建、更新和删除歌词,以测试 BLoC 模式如何与多个数据源协同工作。项目源代码可以在这里找到。由于我只会介绍一些 BLoC 特有的组件,您可以查看我是如何实现数据源层和存储库层的。
从 Genius 获取的歌词会显示在 Webview 中,用户添加的歌词则会显示在自定义屏幕上,并可进行编辑。要删除歌词,只需将其从列表中滑动即可。
入门
要开始使用 Flutter BLoC 库,我需要在 pubspec.yaml 文件中添加两个依赖项。
bloc: ^2.0.0
flutter_bloc: ^2.0.1
我最初创建这个应用的方法是参考TODOs 的示例。它看起来和我的应用非常相似,功能也类似。按照这个示例,我创建了一个 BLoC 类来处理歌词数据的所有操作,并设计了三个屏幕。我的项目结构如下:
事实证明,这并非最佳方案。很难妥善处理屏幕状态的变化,并根据其他屏幕的状态进行更新。
我发现构建 BLoC 应用的最佳方案是为每个屏幕创建一个 BLoC。这样,你只需通过分配给 UI 组件的 BLoC,就能始终了解该组件当前处于什么状态。
考虑到这一点,我对我的项目进行了重构,重构后的结构如下所示:
您可以看到并非所有屏幕都分配了 BLoC。例如,歌曲详情屏幕只会显示传递给它的歌曲信息,因此无需跟踪此屏幕的状态信息。
在使用 BLoC 时,您可以自行决定每个屏幕何时需要包含 BLoC 组件。一些复杂的屏幕甚至可以包含多个相互通信的 BLoC。
事件和状态
现在,当我知道项目的结构后,我就可以定义每个屏幕可以处于的状态,以及它将发送哪些事件。
我将通过一个能够搜索歌词的搜索页面来演示 BLoC 架构的实现过程。首先,我需要定义该页面将发送的事件:
TextChanged -表示搜索字段中的输入已更改,应获取新的歌曲列表。
现在我需要定义这个屏幕可能处于的状态:
StateEmpty -当搜索栏中没有用户输入时,它应该处于活动状态,这将是 BLoC 的初始状态。
StateError -当出现错误时,应传递包含错误消息的状态。
StateLyricsLoaded -从存储库成功获取歌曲后,将传递此状态以及歌曲列表。
StateLoading -定义存储库正在等待服务器的响应,或者正在处理数据。
把所有东西整合起来
我现在知道应用程序主界面的运行方式了,可以开始实现应用程序的功能了。我们先从最基本的功能——歌词搜索——开始。
在使用 BLoC 模式时,应该始终从底层开始,然后根据数据流向上层构建。因此,在实现数据源和存储库之后,下一步就是创建 BLoC。
为了保存应用程序主屏幕的状态,我需要创建文件song_search_state。它定义了搜索屏幕可能处于的所有状态。
abstract class SongsSearchState extends Equatable {
SongsSearchState([List props = const []]) : super(props);
}
如您所见,此类继承自 Equatable 类。它有助于检查新状态是否与当前状态不同。它允许我们通过构造函数中传入的 props 列表来比较对象。
但为什么需要检查新状态是否与当前状态不同呢?如果传递的对象与上一个对象相同,我们就不需要重新构建屏幕,而这个解决方案可以帮我们做到这一点。例如,如果`StateLoading`连续传递两次,监听它的 UI 组件只会接收到一次。
class SearchStateEmpty extends SongsSearchState {
@override
String toString() => 'SearchStateEmpty';
}
class SearchStateLoading extends SongsSearchState {
@override
String toString() => 'SearchStateLoading';
}
class SearchStateSuccess extends SongsSearchState {
final List<SongBase> songs;
final String query;
SearchStateSuccess(this.songs, this.query) : super([songs]);
@override
String toString() => 'SearchStateSuccess { songs: ${songs.length} }';
}
class SearchStateError extends SongsSearchState {
final String error;
SearchStateError(this.error) : super([error]);
@override
String toString() => 'SearchStateError { error: $error }';
}
如您所见,我已经创建了之前描述过的状态。每个状态都可以保存和传递不同的对象。例如,ErrorState保存错误消息,而SuccessState保存已获取歌曲的列表。
重写toString方法也是一个很好的实践。它能更好地描述状态,并在状态转换后打印出来。稍后会详细介绍。
abstract class SongAddEditEvent extends Equatable{
SongAddEditEvent([List props = const []]) : super(props);
}
Event 类也继承自Equatable。这并非必要,因为 BLoC 库默认不使用 Equatable。但是,了解何时传递的事件与当前事件不同,可以方便地操作 BLoC 中的事件流,从而实现诸如防抖、去重等功能。
class TextChanged extends SongSearchEvent {
final String query;
TextChanged({this.query}) : super([query]);
@override
String toString() => "SongSearchTextChanged { query: $query }";
}
现在我可以创建一个事件,通知 BLoC 用户已更改搜索查询。事件类与状态类非常相似,并且适用类似的规则。
当状态和事件创建完成后,我终于可以开始实现song_search_bloc了。
class SongsSearchBloc extends Bloc<SongSearchEvent, SongsSearchState> {
final LyricsRepository lyricsRepository;
SongsSearchBloc({
@required this.lyricsRepository,
@required this.songAddEditBloc})
@override
SongsSearchState get initialState => SearchStateEmpty();
@override
void onTransition(Transition<SongSearchEvent, SongsSearchState> transition) {
print(transition);
} @override
Stream mapEventToState(SongSearchEvent event) async* {
if (event is TextChanged) {
yield* _mapSongSearchTextChangedToState(event);
}
}
}
歌曲搜索 BLoC 包含LyricsRepository的一个实例,该实例负责合并网络和本地数据源,并对获取的数据执行所有操作。
如前所述,我必须重写字段initialState的 getter ,以显示 BLoC 创建后将处于什么状态。
还记得我建议过在每个状态和事件中重写 toString 方法吗?它在onTransition 事件中会非常有用。每当 BLoC 改变状态时,都会调用 onTransition 方法。多亏了每个状态的 toString 方法,每次状态转换后终端都会输出漂亮的字符串。
I/flutter (22988): Transition { currentState: SearchStateEmpty, event: SongSearchTextChanged { query: never gonna give }, nextState: SearchStateLoading }
I/flutter (22988): Transition { currentState: SearchStateLoading, event: SongSearchTextChanged { query: never gonna give }, nextState: SearchStateSuccess { songs: 10 } }
I/flutter (22988): Transition { currentState: SearchStateSuccess { songs: 10 }, event: SongSearchTextChanged { query: }, nextState: SearchStateEmpty }
接下来需要在 BLoC 中重写的是mapEventToState方法。每次向 BLoC 添加新事件时都会调用此方法,其作用正如其名称所示——对特定事件做出响应,赋予其特定的状态。
每个事件都应该有对应的方法。因此,如前所述,TextChanged 事件应该根据搜索结果生成相应的状态。
Stream _mapSongSearchTextChangedToState(TextChanged event) async* {
final String searchQuery = event.query;
if (searchQuery.isEmpty) {
yield SearchStateEmpty();
} else {
yield SearchStateLoading();
try {
final result = await lyricsRepository.searchSongs(searchQuery);
yield SearchStateSuccess(result, searchQuery);
} catch (error) {
yield error is SearchResultError
? SearchStateError(error.message)
: SearchStateError("Default error");
}
}
}
对我来说,yield 关键字是一个新概念。它为 yield 调用的流增添了价值。你可以把它理解为类似 return 语句,但它不会停止后续代码的执行,因此可以在同一个方法中将状态更改为多个值。
最后一步是提供已创建的 BLoC,以便 UI 组件可以访问它。为此,需要修改MaterialApp主文件。
@override
Widget build(BuildContext context) {
return BlocProvider<SongAddEditBloc>(
builder: (context)
SongAddEditBloc(lyricsRepository: lyricsRepository),
child: MaterialApp(
//main app code
));
}
通过 UI 使用 BLoC
BLoC实现完成后,下一步就是在UI中使用它。首先,我需要根据状态显示相应的组件:
@override
Widget build(BuildContext context) {
return BlocBuilder<SongsSearchBloc, SongsSearchState>
bloc: BlocProvider.of(context),
builder: (BuildContext context, SongsSearchState state) {
if (state is SearchStateLoading) {
return CircularProgressIndicator();
}
if (state is SearchStateError) {
return Text(state.error);
}
if (state is SearchStateSuccess) {
return state.songs.isEmpty
? Text(AppLocalizations.of(context).tr(S.EMPTY_LIST))
: Expanded(
child: _SongsSearchResults(
songsList: state.songs,
),
);
} else {
return Text(AppLocalizations.of(context).tr(S.ENTER_SONG_TITLE));
}
},
);
}
然后,在用户输入歌曲搜索查询的文本框中,我需要向已创建的 BLoC 发送一个新事件。我们可以通过调用add(Event) 方法来实现这一点。
TextField(
onChanged: (text) {
_songSearchBloc.add(TextChanged(query: text));
}
)
如果您查看项目源代码,就会发现这些函数被放置在不同的文件中。而这正是 BLoC 的强大之处。您可以在任何 Widget 中使用同一个 BLoC 实例。
变革性事件
现在搜索功能运行正常。但有一点可以改进。目前,每次用户更改文本输入时,都会发送一个新的请求。因此,如果用户快速输入歌曲名称,请求次数将与歌曲名称的字母数相同。在这种情况下,最佳实践是等待一小段时间,并在发送新请求时取消之前的请求。这种方法称为防抖(debounce),您可以在ReactiveX 文档中找到更多相关信息。
@override
Stream<SongsSearchState> transformEvents(Stream<SongSearchEvent> events,
Stream<SongsSearchState> Function(SongSearchEvent event) next) {
return super.transformEvents(
(events as Observable<SongSearchEvent>).debounceTime(
Duration(milliseconds: DEFAULT_SEARCH_DEBOUNCE),
),
next,
);
}
正如我之前提到的,我们可以扩展 BLoC 的 events 类中的 Equatable,以便了解状态何时变为 new。这使我们能够重写BLoC 中的transformEvents函数并操作传入的数据流。
各业务部门之间的通信
让我们暂时跳过一些时间。我已经实现了第二个 BLoC,可以添加和编辑歌曲。就像我之前演示的那样,我添加了事件状态并将其分配给了屏幕。
一切都很顺利,直到我添加了一首歌并返回搜索页面。当我输入新添加歌曲应该包含的搜索词时,它却没有出现在列表中。
我的第一个解决方案是在添加或删除歌曲后更新列表。但这会产生不必要的 API 调用。我找到了一个更好的解决方案,即监听第二个 BLoC 的状态变化,并将此变化传递给第一个 BLoC。
首先,我需要在SongSearchBloc中添加新的事件——SongAdded 和 SongUpdated——它们会传递已添加或已更改歌曲的实例。然后创建一个名为StreamSubscription 的组件,它负责监听来自其他 BLoC 的更改。
final SongAddEditBloc songAddEditBloc;
StreamSubscription addEditBlocSubscription;
SongsSearchBloc(
{@required this.lyricsRepository, @required this.songAddEditBloc}) {
songAddEditBloc.listen((songAddEditState) {
if (state is SearchStateSuccess) {
if (songAddEditState is EditSongStateSuccess) {
add(SongUpdated(song: songAddEditState.song));
} else if (songAddEditState is AddSongStateSuccess) {
add(SongAdded(song: songAddEditState.song));
}
}
});
}
务必记住,在不再需要订阅时取消订阅。每个 BLoC 都可以重写 close 方法,该方法会在 BLoC 不再使用后调用。
@override
Future<void> close() {
addEditBlocSubscription.cancel(); return super.close();
}
由于我已经创建了第二个 BLoC,而第一个 BLoC 又依赖于它,因此主文件需要再次修改。
@override
Widget build(BuildContext context) {
return MultiBlocProvider(
providers: [
BlocProvider<SongAddEditBloc>(
builder: (context) =>
SongAddEditBloc(lyricsRepository: lyricsRepository),
),
BlocProvider<SongsSearchBloc>(
builder: (context) => SongsSearchBloc(
lyricsRepository: lyricsRepository,
songAddEditBloc: BlocProvider.of<SongAddEditBloc>(context)),
),
],
child: MaterialApp(
//main app code
),
);
}
使用 BLoC 进行测试
正如我之前提到的,BLoC 可以帮助您轻松创建测试。由于这个话题非常广泛,足以另写一篇文章,所以我将展示一个简单的示例。如果您想查看更多示例,可以随时查看源代码,我在那里编写了一些测试。
首先应该准备好模拟类,这些类将在测试中使用。
class MockLyricsRepository extends Mock implements LyricsRepository {}
class MockSongBase extends Mock implements SongBase {}
接下来,我们可以开始实现测试的主要功能。需要注意的是,要在 setUp 方法中初始化 BLoC,并在 tearDown 方法中将其关闭。
void main() {
SongsSearchBloc songsSearchBloc;
MockLyricsRepository lyricsRepository;
String query = "query.test";
List<SongBase> songsList = List();
setUp(() {
lyricsRepository = MockLyricsRepository();
songsSearchBloc = SongsSearchBloc(lyricsRepository: lyricsRepository);
});
tearDown(() {
songsSearchBloc?.close();
});}
然后我们可以编写简单的测试,检查 BLoC 的初始状态是否正确,以及它在关闭后是否发出任何状态。
test('after initialization bloc state is correct', () {
expect(SearchStateEmpty(), songsSearchBloc.initialState);
});
test('after closing bloc does not emit any states', () {
expectLater(songsSearchBloc, emitsInOrder([SearchStateEmpty(), emitsDone]));
songsSearchBloc.close();
});
编写 BLoC 测试时,必须知道 BLoC 在特定事件发生后应处于哪些状态。以搜索功能为例,用户输入文本后,状态应从空变为加载中;获取歌曲列表后,状态应变为成功。这些状态应按顺序定义在数组中,并作为参数传递给 `expectsLater` 函数,该函数会检查 BLoC 的状态是否已相应改变。
test('emits success state after insering lyrics search query', () {
List<SongBase> songsList = List();
songsList.add(MockSongBase());
final expectedResponse = [
SearchStateEmpty(),
SearchStateLoading(),
SearchStateSuccess(songsList, query)
];
expectLater(songsSearchBloc, emitsInOrder(expectedResponse));
when(lyricsRepository.searchSongs(query))
.thenAnswer((_) => Future.value(songsList));
songsSearchBloc.add(TextChanged(query: query));
});
然后我们只需要告诉模拟的 LyricsRepository 实例返回模拟歌曲的列表,这样当我们的 BLoC 调用此函数时,它就能按预期工作。
最后一步是在我们的 BLoC 中添加一个事件,该事件将产生所需的状态,这样就完成了。现在我们有了一个可运行的测试用例,用于验证已实现的功能。
概括
我认为 BLoC 是一种很棒的模式,可以应用于各种类型的应用程序。它有助于提高代码质量,并使编写代码成为一种真正的享受。
由于它使用了流式处理和响应式编程等高级技术,我认为初学者可能难以上手。但只要掌握了基础知识,使用这种架构创建一个简单的应用程序就非常容易了。
文章来源:https://dev.to/netguru/getting-started-with-flutter-bloc-1pkm


