[Flutter翻译]用Flutter Bloc构建一个管理列表的包

222 阅读13分钟

原文地址:developer.school/flutter-mob…

原文作者:developer.school/author/paul…

发布时间:2020年7月22日 - 10分钟阅读

照片:Émile Perron on Unsplash

目标

我在业余时间做的应用有一个消耗大数据列表的需求,其中一个关键的要求是让列表很容易根据列表中项目的属性进行筛选。另外,列表应该是可以搜索的,以进一步缩小结果范围。

有好几个地方我都会需要这个功能,所以它需要尽可能的通用。我正在大量使用flutter_bloc包进行状态管理,所以它最好能够直接插入现有的应用架构中。反正需要通用,这似乎是一个完美的机会来创建一个可以对大家有所帮助的Flutter包!

总结一下,我们的最终目标是一个满足以下标准的包:

  • 遵循flutter_bloc模式。
  • 接受数据源
  • 公开从数据源项的指定属性中导出的状态,这些属性可用于呈现管理可用过滤器选项的UI。
  • 能够通过用户激活的选项过滤该数据源。
  • 能够通过搜索提供的属性来缩小数据源的范围。
  • 暴露出可用于渲染列表的状态。
  • 是尽可能的通用

初步计划

这个包的上手应该不难。为此,我知道我只想要一个需要集成的主要入口点。任何其余的功能都应该暴露在那个widget下面,但自动连接并准备使用。

作为与包接口的人,你应该能够以你喜欢的方式(以及任何地方)渲染你的UI。我们将与flutter_bloc集成,flutter_bloc已经有一个很好的方法来通过BlocBuilder部件来实现这一点......这意味着我们主要的状态交流应该是一个bloc。 那么,一切都可以像下图所示的那样挂在一起,这是合理的。

粗略的初步计划

入口小部件

它是唯一一个由应用程序直接渲染的小组件,并提供了:

  • 你想渲染的widget将可以访问它的构建上下文中的包注入的块。
  • 过滤/搜索时使用的属性(对应于源集团提供的项目)。
  • 提供基础数据的源集团

过滤/搜索状态

一个组,它将源组和过滤/搜索属性和:

  • 使用它们来生成与所有输入数据相匹配的值组。
  • 将状态(以流的形式)显示给列表中的状态组。
  • 可用于子构建上下文,以呈现适当的过滤UI,并允许该UI将过滤条件切换为活动或非活动。

列表状态

从过滤区块中获取源区块和状态的区块到:

  • 根据任何被应用程序选择为活动的过滤条件来过滤输入的数据。
  • 可供子建构上下文渲染适当的列表UI。

开始

我们可以想象,合理的起点应该是入口小部件......毕竟,这将是与软件包的整合点。是的,这将是一个好主意。不过当时我还不确定到底要怎么把所有的东西绑在一起。是的,我知道上面的图看起来真的很炫,但它来自于一切完成后试图将这条时间线拼凑起来。(下一个我接手的指南,我打算在经历这个过程的时候真正写出来,这应该是一个很大的改变)。

也许下一个最好的开始是列表状态?听起来真的很不错,尤其是这里的整个要点是将过滤后的数据列表呈现给我们的UI。然而,我的大脑真的只是准备开始解决过滤部分的问题,所以这就是我最终开始的地方。

由于之前从未创建过独立的包,我决定谨慎的第一步是在我当前的项目中开始开发,直到我能够证明大部分的功能。当我在处理过滤块时(下文深入探讨),我不喜欢我计划在同一个地方同时处理过滤状态和搜索状态。处理潜在的过滤条件列表和活动的过滤条件,对于一个组块来说已经够多了。此外,它还充当了目标搜索属性从入口部件的直接传递,这也不是很理想。

在重新审视计划之前,我决定是时候把所有的代码(基本上是工作中的过滤条件bloc和数据类)迁移到一个单独的存储库中。然而,当我开始这个过程时,出现了一个巨大的小插曲! 你可以在这里阅读所有关于这个冒险的故事。

新计划

事后看来,新计划与旧计划并无大的区别。只是把关注点更分散了一些。

最后计划

名单管理员

使用该包的主要入口点。所需的child可以访问下面所有的块,以便在构建UI时使用。至少,你还必须提供一个要传递给过滤条件块的键列表。

这个小部件其实没什么用,它只是设置了其他的blocs并提供了要渲染的child

过滤条件组块

现在,过滤条件集团不再负责任何搜索状态,这就更容易推理了。

源初始化

在初始化时,我们需要订阅sourceBloc,以便在我们的源信息发生变化时随时再生availableConditions。我们还想确保没有activeConditions被悬空。

_sourceSubscription = _sourceBloc.listen((sourceState) {
  if (sourceState is! T) {
    return;
  }

  final availableConditions = _generateFilterPropertiesMap();
  final availableConditionKeys = <String>{};

  for (final item in sourceState.items) {
    for (final property in _filterProperties) {
      final value = item[property];

      if (value is String && value.isNotEmpty) {
        final conditionKey = generateConditionKey(property, value);

        availableConditions[property].add(value);
        availableConditionKeys.add(conditionKey);
      }
    }
  }

  final currentState = state;
  final activeConditions = currentState is ConditionsInitialized
      ? currentState.activeConditions
      : <String>{};

  for (final property in _filterProperties) {
    // Ensure only unique entries are present and that entries are sorted.
    // Removing duplicates before sorting will save a few cycles.
    availableConditions[property] =
        availableConditions[property].toSet().toList()..sort();
  }

  add(RefreshConditions(
    activeConditions: activeConditions.intersection(availableConditionKeys),
    availableConditions: availableConditions,
  ));
});

如果sourceBloc不在其加载状态(由父部件提供的类型决定),我们希望暂时跳过解析数据。

对于源状态中的每个项目,我们需要跟踪每个filterProperty的对应值。为了减少通过源项的迭代次数,我们还需要提前去存储所有潜在的传入条件键,这样我们就可以缩小任何已经从源状态中删除的activeConditions

说到generateConditionKey,为什么我们不使用和availableConditions一样的存储格式呢?最初的计划是这样的! 然而,availableConditions的用例--渲染每个过滤器属性键的可用值--非常适合嵌套迭代。在activeConditions的情况下,我们总是希望能够直接和方便地访问该列表。过滤器属性键和其值的连接可以作为一个足够独特的标识符。目前我们过滤掉了除字符串值以外的所有内容,但增加数字/布尔值的支持会相当容易。

最后但并非最不重要的一点是,我们要确保availableConditions的稳定(和唯一)排序,这样过滤UI就不会随着源状态的更新而改变。

添加和删除条件

这两个函数只有两行不同,所以我们一起讨论。

FilterConditionsState _addConditionToActiveConditions(
  AddCondition event,
) {
  if (state is ConditionsUninitialized) {
    return state;
  }

  final currentState = (state as ConditionsInitialized);
  final conditionKey = generateConditionKey(event.property, event.value);

  if (currentState.activeConditions.contains(conditionKey)) {
    return currentState;
  }

  final activeConditions = Set<String>.from(currentState.activeConditions);
  activeConditions.add(conditionKey);

  return ConditionsInitialized(
    activeConditions: activeConditions,
    availableConditions: currentState.availableConditions,
  );
}

如果我们还没有进入初始化状态,我们就没有办法准确地将一个条件设置为active,也不想修改状态。

如果activeConditions的集合已经有了匹配的条目(或者在RemoveCondition的情况下,如果还没有匹配的条目),就不需要修改状态了。

当使用Bloc模式时,状态突变是不可以的(参见下面的'突变问题'),所以我们在操作activeConditions时需要创建一个新的Set

搜索查询bloc

其实什么都没有。

清除searchQuery会将其设置回一个空字符串。设置searchQuery会将所提供的值存储为小写,以使过滤源项目更加可靠。

项目列表区块

ItemListBloc是这个包的面包和黄油。它接收源项、来自 FilterConditionsBlocactiveConditions、来自 SearchQueryBlocsearchQuery、来自基础 widget 的 searchProperties,并将它们提炼成一个完整的项列表,可以按照您的意愿进行渲染。

初始化

ItemListBloc({
  @required FilterConditionsBloc filterConditionsBloc,
  @required SearchQueryBloc searchQueryBloc,
  @required Bloc sourceBloc,
  List<String> searchProperties,
})  : assert(filterConditionsBloc != null),
      assert(searchQueryBloc != null),
      assert(sourceBloc != null),
      _filterConditionsBloc = filterConditionsBloc,
      _searchQueryBloc = searchQueryBloc,
      _sourceBloc = sourceBloc,
      _searchProperties = searchProperties {
  _filterConditionsSubscription = _filterConditionsBloc.listen((_) {
    add(_itemListEvent.filterConditionsUpdated);
  });

  _searchQuerySubscription = _searchQueryBloc.listen((_) {
    add(_itemListEvent.searchQueryUpdated);
  });

  _sourceSubscription = _sourceBloc.listen((_) {
    add(_itemListEvent.sourceUpdated);
  });
}

@override
Future<void> close() async {
  await _filterConditionsSubscription?.cancel();
  await _searchQuerySubscription?.cancel();
  await _sourceSubscription?.cancel();

  return super.close();
}

由于我们依赖于其他三个块的数据,我们需要设置我们的监听器。我们可以在这些回调中进行工作(就像我们在FilterConditionsBloc中所做的那样),但让一切都简单化并在事件系统中进行更有意义。

当Bloc关闭时,不要忘了取消监听器 我最初试图将这三个都管到一个Future.wait中,但这与null aware语法玩得并不愉快,因为在订阅不存在的情况下,没有提供承诺。唯一的其他选择是大量的条件逻辑或提供空承诺作为默认值。我做了一个决定,现在不做优化,因为关闭ItemListBloc应该是很少发生的事情。

映射事件

我一般都会尽量不在mapEventToState中加入逻辑,然而,无论新的源项进来,新的activeConditions进来,还是新的searchQuery进来,这个块都需要以同样的方式做出响应。每次都需要重新生成整个列表。

@override
Stream<ItemListState> mapEventToState(
  _itemListEvent event,
) async* {
  if (_filterConditionsBloc.state is! ConditionsInitialized ||
      _sourceBloc.state is! T) {
    yield NoSourceItems();
    return;
  }

  if (event != _itemListEvent.sourceUpdated &&
      event != _itemListEvent.filterConditionsUpdated &&
      event != _itemListEvent.searchQueryUpdated) {
    return;
  }

  final items = (_sourceBloc.state as T).items;
  final filterResults = _filterSource(items);
  final searchResults = _searchSource(_searchQueryBloc.state, filterResults);

  if (searchResults.isEmpty) {
    yield ItemEmptyState();
  } else {
    yield ItemResults(searchResults.toList());
  }
}

如果条件还没有被初始化,或者如果源项没有处于加载状态(应该不可能在源项没有加载的情况下同时初始化过滤条件......但防止这种情况也无妨),我们有一个特殊的状态,我们希望为此发出,以区别于标准的空状态。

然后,我们通过过滤和搜索(见下文)发送源项,并派发一个事件让调用者知道没有结果(空状态),或者派发一个带有过滤和搜索结果的事件。

过滤源项

和其他的实现一样,没有一个部分是非常复杂的......那是设计好的。过滤源项是非常直接的。

Iterable<I> _filterSource(List<I> items) {
  final activeConditions =
      (_filterConditionsBloc.state as ConditionsInitialized).activeConditions;

  if (activeConditions.isEmpty) {
    return items;
  }

  // If any active condition matches we can immediately return that item.
  return items.where((item) => activeConditions.any((conditionKey) {
        final conditionKeyValue = splitConditionKey(conditionKey);
        return item[conditionKeyValue[0]] == conditionKeyValue[1];
      }));
}

如果没有activeConditions,我们可以短路一些逻辑并立即返回源项。

然后我们继续检查源项是否符合所有的activeConditions。我们也可以使用any......来短路这个逻辑,我们不关心项目是否符合每一个活动条件,它只需要符合一个条件。

搜索源项

Iterable<I> _searchSource(String searchQuery, Iterable<I> items) {
  if (searchQuery.isEmpty) {
    return items;
  }

  // Search queries are stored lowercase, so we want to match
  // against a lowercase value as well.
  return items.where((item) => _searchProperties.any((property) {
        final value = item[property];
        return value is String
            ? value.toLowerCase().contains(searchQuery)
            : false;
      }));
}

类似于过滤源项,我们可以绕过一些逻辑,如果没有searchPropery,我们可以立即返回源项。

然后,我们继续对每一个提供的searchPropery根据searchQuery检查源项。我们也可以使用any......来短路部分逻辑,只有一个搜索属性需要正向匹配。

毫无疑问,这是一个非常基本的搜索算法(如果你甚至可以这样称呼它的话)。它能完成工作,但仅此而已。更多的信息在下面,但一个建议的更新是提供一个可插拔的搜索回调,这样你就可以实现任何适合你的搜索(我主要是考虑模糊搜索)。

测试

我不会在这里涵盖每一行测试代码,我认为这没有什么价值。相反,我将重点介绍几个值得注意的测试案例。

在可能的情况下,我已经转移到bloc_test包中进行测试。它节省了麻烦,提供了几乎所有你需要的助手,并鼓励隔离测试状态(即使这确实使事情在最后变得更啰嗦一些)。非常强烈推荐。

突变问题

就像大多数好的测试一样,这个特殊的案例帮助验证了我围绕Bloc模式的底层状态管理的假设。测试错误很快就突出了我的错误之处,以及应该在哪里进行修复。

更具体地说,我曾希望在更新活动过滤器条件时能够摆脱一点状态突变。在目前的单Set迭代中,这一点非常容易实现。在之前使用嵌套数据的实现中(导致上面链接的测试用例抛出错误),这需要更多的锅炉模板来完成。

FilterConditionsState _addConditionToActiveConditions(
  AddCondition event,
) {
  if (state is ConditionsUninitialized) {
    return this.state;
  }

  final currentState = (this.state as ConditionsInitialized);

  if ((currentState.activeConditions[event.property] ?? [])
      .contains(event.value)) {
    return currentState;
  }

  final activeConditions =
      Map.fromEntries(currentState.activeConditions.entries);

  activeConditions.update(
    event.property,
    (activeValues) => List.from([...activeValues, event.value]),
    ifAbsent: () => [event.value],
  );

  return ConditionsInitialized(
    availableConditions: currentState.availableConditions,
    activeConditions: activeConditions,
  );
}

由于数据被大量嵌套,我们首先要检查所提供的属性是否存在一个值的数组,以及该数组是否已经有一个所提供的值的条目。

如果没有值存在,我们需要创建一个新的地图引用来存放现有的条目。然后我们需要更新所提供的属性的数组(同样是通过创建一个新的数组引用,并对其进行适当修改)。

流问题

如上所述,bloc_test包在测试一个bloc时确实很有帮助。在几乎所有的情况下,whenListen助手的功能都很强大,然而,它并不能灵活地将所提供的项目何时接入流中。如果你想测试外部bloc(或流)和被测bloc的内部行为之间的交互,你需要寻找另一种解决方案。

下面的简单设置允许您在实际需要测试的任何时间点将项目添加到源流中,而不是在第一次监听时一次性全部添加。

setUp(() {
  _sourceBloc = MockSourceBloc();
  _sourceStreamController = StreamController();

  when(_sourceBloc.listen(any)).thenAnswer((invocation) {
    return _sourceStreamController.stream.listen(invocation
        .positionalArguments.first as Function(MockSourceBlocState));
  });
});

tearDown(() {
  _sourceStreamController.close();
});

发布

把这个包发布到pub.dev上是非常容易的,为这个团队点赞! 我不想再重复那些已经很好的文档,我只想提供最终的软件包......看看吧!

今后的方向

对于完成的实施,我绝对有不喜欢的地方,而且鉴于我现在知道的情况,我还会做一些不同的事情。

来源Bloc

我觉得不需要提供一个源块,而是提供一个存储库给widget会更好。由于包提供了渲染列表的UI所需的所有状态,让一个源块来管理这些是相当多余的。这也使得包中的内部状态检查变得更加简单。

可插拔的搜索

我喜欢模糊搜索带来的灵活性。然而,我觉得它不应该成为每个人的默认值,如果它永远不会被使用,也不应该无谓地增加包的大小。为此,应该有某种形式的可插拔的搜索提供者或回调,调用者可以用它来替代当前(天真的)搜索实现。

非字符串过滤条件

并非所有的数据都是由字符串构成的。过滤条件也应该能够支持数字布尔值

布尔运算需要特殊的处理,因为它们的值不是可显示的。数字需要特殊处理,因为我们可能想把它们的值作为一个范围的一部分,而不是不同的值。

增强的过滤器选项

目前唯一的过滤选项是加法(意味着符合任何一个活动条件的项目都会被淘汰)。更高级的过滤选项将是一个好处。

我希望您能对已经提出的改进建议提出反馈意见,以及对改进包的其他方法的想法。

www.twitter.com/FlutterComm

通过www.DeepL.com/Translator(免费版)翻译


通过( www.DeepL.com/Translator )(免费版)翻译