发布于 2026-01-06 2 阅读
0

Flutter BLoC 入门指南

Flutter BLoC 入门指南

我必须承认,我对 Flutter 的初次体验并不好。刚开始使用时,它非常不稳定,而让我望而却步的是缺乏架构模式。我很难轻松地构建应用程序的结构,不得不编写自定义逻辑来实现组件间的良好通信。因此,我放弃了 Flutter 项目,静观其变。

最近我需要开发一个跨平台应用,所以要在 Flutter 和 React Native 之间做选择。由于我的 Web 开发技能仅限于用 HTML 写“Hello World”,所以我决定再给 Flutter 一次机会。我发现 Flutter 已经发生了很大的变化,涌现出了许多新的架构模式。我在一些简单的项目中测试了其中的一些,而我立刻就爱上了 BLoC(块级组件)模式。

国界线介绍

BLoC 代表业务逻辑控制器(Business Logic Controller)。它由谷歌创建,并在2018 年 DartConf 大会上推出。它基于 Streams 和响应式编程构建。

如果你想开始使用 BLoC 架构创建应用,我强烈推荐两个库,它们能大大简化你的开发工作:blocflutter_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
Enter fullscreen mode Exit fullscreen mode

我最初创建这个应用的方法是参考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);
}
Enter fullscreen mode Exit fullscreen mode

如您所见,此类继承自 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 }';
}

Enter fullscreen mode Exit fullscreen mode

如您所见,我已经创建了之前描述过的状态。每个状态都可以保存和传递不同的对象。例如,ErrorState保存错误消息,而SuccessState保存已获取歌曲的列表。

重写toString方法也是一个很好的实践。它能更好地描述状态,并在状态转换后打印出来。稍后会详细介绍。

abstract class SongAddEditEvent extends Equatable{
  SongAddEditEvent([List props = const []]) : super(props);
}

Enter fullscreen mode Exit fullscreen mode

Event 类也继承自Equatable。这并非必要,因为 BLoC 库默认不使用 Equatable。但是,了解何时传递的事件与当前事件不同,可以方便地操作 BLoC 中的事件流,从而实现诸如防抖、去重等功能。

class TextChanged extends SongSearchEvent {
  final String query;

  TextChanged({this.query}) : super([query]);

  @override
  String toString() => "SongSearchTextChanged { query: $query }";
}
Enter fullscreen mode Exit fullscreen mode

现在我可以创建一个事件,通知 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);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

歌曲搜索 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 }

Enter fullscreen mode Exit fullscreen mode

接下来需要在 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");
    }
  }
}  
Enter fullscreen mode Exit fullscreen mode

对我来说,yield 关键字是一个新概念。它为 yield 调用的流增添了价值。你可以把它理解为类似 return 语句,但它不会停止后续代码的执行,因此可以在同一个方法中将状态更改为多个值。

最后一步是提供已创建的 BLoC,以便 UI 组件可以访问它。为此,需要修改MaterialApp主文件。

  @override
  Widget build(BuildContext context) {
    return BlocProvider<SongAddEditBloc>(
        builder: (context) 
            SongAddEditBloc(lyricsRepository: lyricsRepository),
        child: MaterialApp(
          //main app code
        ));
  }
Enter fullscreen mode Exit fullscreen mode

通过 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));
      }
    },
  );
}
Enter fullscreen mode Exit fullscreen mode

然后,在用户输入歌曲搜索查询的文本框中,我需要向已创建的 BLoC 发送一个新事件。我们可以通过调用add(Event) 方法来实现这一点。

TextField(
  onChanged: (text) {
     _songSearchBloc.add(TextChanged(query: text));
  }
)
Enter fullscreen mode Exit fullscreen mode

如果您查看项目源代码,就会发现这些函数被放置在不同的文件中。而这正是 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,
  );
}
Enter fullscreen mode Exit fullscreen mode

正如我之前提到的,我们可以扩展 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));
      }
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

务必记住,在不再需要订阅时取消订阅。每个 BLoC 都可以重写 close 方法,该方法会在 BLoC 不再使用后调用。

@override
Future<void> close() {
  addEditBlocSubscription.cancel();  return super.close();
}
Enter fullscreen mode Exit fullscreen mode

由于我已经创建了第二个 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
          ),
    );
  }
Enter fullscreen mode Exit fullscreen mode

使用 BLoC 进行测试

正如我之前提到的,BLoC 可以帮助您轻松创建测试。由于这个话题非常广泛,足以另写一篇文章,所以我将展示一个简单的示例。如果您想查看更多示例,可以随时查看源代码,我在那里编写了一些测试。

首先应该准备好模拟类,这些类将在测试中使用。

class MockLyricsRepository extends Mock implements LyricsRepository {}
class MockSongBase extends Mock implements SongBase {}
Enter fullscreen mode Exit fullscreen mode

接下来,我们可以开始实现测试的主要功能。需要注意的是,要在 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();
  });}
Enter fullscreen mode Exit fullscreen mode

然后我们可以编写简单的测试,检查 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();
  });
Enter fullscreen mode Exit fullscreen mode

编写 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));
  });
Enter fullscreen mode Exit fullscreen mode

然后我们只需要告诉模拟的 LyricsRepository 实例返回模拟歌曲的列表,这样当我们的 BLoC 调用此函数时,它就能按预期工作。

最后一步是在我们的 BLoC 中添加一个事件,该事件将产生所需的状态,这样就完成了。现在我们有了一个可运行的测试用例,用于验证已实现的功能。

概括

我认为 BLoC 是一种很棒的模式,可以应用于各种类型的应用程序。它有助于提高代码质量,并使编写代码成为一种真正的享受。

由于它使用了流式处理和响应式编程等高级技术,我认为初学者可能难以上手。但只要掌握了基础知识,使用这种架构创建一个简单的应用程序就非常容易了。


照片由David PisnoyUnsplash上拍摄

文章来源:https://dev.to/netguru/getting-started-with-flutter-bloc-1pkm