[Flutter翻译]eBay汽车与状态管理

322 阅读13分钟

原文地址:tech.ebayinc.com/engineering…

原文作者:tech.ebayinc.com/authors/lar…

发布时间:2021年4月1日

了解我们在构建eBay Motors应用时如何避免状态管理的争论。

image.png

当我们讨论eBay Motors应用时,最常被问到的问题是:"eBay Motors使用哪种状态管理方案?" 简单的回答是,我们主要是在StatefulWidgets中管理状态,使用Provider或InheritedWidgets将这个状态暴露在widget树下。

然而,我们认为应该问一个更有趣的问题。"eBay Motors是如何设计他们的代码库,使状态管理工具的选择不重要?"

我们认为,选择合适的工具,或者应用单一的设计模式,远不如在应用程序中的独特功能和组件之间建立明确的合同和边界重要。如果没有边界,就太容易写出不可维护的代码,而这些代码又不能随着应用的复杂性增长而扩展。

"不可维护的代码 "可以从几个方面来定义。

  • 一个领域的变化会在看似不相关的领域中产生错误。
  • 难以推理的代码。
  • 难以编写自动测试的代码;或
  • 代码有意外行为。

代码中的任何这些问题都会减慢你的速度,并使你更难向用户提供价值。

在代码的不同区域之间建立明确的边界可以减少这些问题。这种方法鼓励你将大型的、复杂的问题分解成更小的、更容易管理的碎片。它鼓励不同的领域通过抽象进行交流,并允许封装私人的实现细节。它还减少了意外的耦合和副作用,最终导致更灵活的设计,更容易改变。最重要的是,建立这些合同迫使工程师真正理解问题空间和他们正在构建的功能,这总是能产生更好的结果。

我们团队中的大多数人已经在我们继承的代码库上合作了好几年,从经验来看,我们知道从一开始就添加清晰的领域边界是很重要的。

作为一个团队,我们同意开始编纂我们的边界的最佳方式是为我们应用中的每个主要屏幕创建单独的Flutter包。这是一个强制功能,有多种目的。首先,它允许我们的团队成员在不同的屏幕上独立工作,而不会踩到对方的脚趾。我们希望提供实验空间,让工程师发现在新的技术堆栈中,哪种模式最适合我们。其次,它支持我们团队的目标,即确保所有行为都被测试覆盖。为了通过持续集成(CI)检查,每个包都需要被自动化测试完全覆盖。我们的边界迫使每个包都是独立的可测试的,这增加了我们开发时的信心。

这些包的API通常是简单而直接的。每个包都暴露了一个代表整个屏幕的widget,它将定义实现其目的所需的依赖关系。包中的其他所有东西都是私有的。因此,屏幕的外观和感觉、用户交互和状态管理都是可以自由发展的实现细节,而不会影响到应用程序的其他部分。

当我们开始编码的时候,我们把代表我们第一套功能的几个包打了出来。这包括制作一个主屏幕、一个搜索屏幕和一个车辆详细信息屏幕的骨架,用于查看更多的列表信息。我们的顶层应用包的重点是将这些包适当地拼接在一起,以创建一个功能性的用户流。有了这些,我们就可以分工协作,轻松并行。

在这一点上,我们的团队成员继续测试和学习,在使用Flutter时确定什么对我们来说是最好的。我们开始在每个包中几乎只使用BLOC模式,并探索加入我们从传统的本地开发中习惯的其他设计模式。在整个早期阶段,唯一不变的是我们的包边界和通过每个包的公共API实现100%测试覆盖率的重点。

几周后,我们对Flutter的widget树的理解不断加深,我们开始认识到,我们应用于状态管理的模式并没有为我们提供良好的服务。它们迫使我们创建额外的抽象层,不必要的模板,并使代码库过于复杂。在许多情况下,我们了解到,我们可以用一个简单的StatefulWidget和少得多的代码来解决同样的问题。这时,我们的测试策略的价值就显现出来了。因为我们是通过包的公共API进行测试的,所以我们的测试并没有与实现细节相结合,而是专注于对包的行为进行断言。这使得我们可以无情地重构和交换代码层,往往不需要改变一行测试代码。

随着应用规模和复杂度的增长,我们创建的包的数量也越来越多。今天,经过两年的开发,我们的monorepo由大约24万行Dart代码和5500个测试组成,分布在80个包中。在过去的24个月里,我们在状态管理方面出现了一些模式。

在应用程序初始化过程中,有相当数量的状态被创建,需要在应用程序的生命周期中继续存在。我们的Application Package负责初始化并持有对这些状态的引用,通常在Widget Tree的顶端有一个StatefulWidget。然后,它通过Provider或InheritedWidgets将这些类或行为依赖性注入到widget树中。

在每个域的包中,经常会有一些状态被限定在特定的屏幕上。我们在这里有意不采用一致的模式。每个包都已经发展到使用任何一种适合工作的状态管理方案。我们已经应用了许多成功的模式(也有不成功的!),包括BLOC、InheritedModel和通过InheritedWidgets暴露Streams和ValueListenables。在许多情况下,我们已经换掉了状态管理工具,在更多的情况下,我们计划这样做。我们的方法是倾听代码,并选择适合该特定领域需求的最佳工具。这与其说是一个关键的架构决策,不如说是一个风格问题。

为了更好地理解我们对包结构所采取的方法,让我们来看一个例子。

在我们的购买流程中,其中一个关键功能是能够在我们的搜索屏幕上搜索车辆,并导航到车辆详细信息屏幕以了解更多关于车辆的信息并购买它。

image.png

如果我们把这些屏幕分解成最简单的要求,它们看起来像这样。

搜索屏幕

  • 与搜索API集成
  • 提供无限滚动的列表
  • 提供过滤和排序结果的机制
  • 在点击一个列表时,需要导航到另一个屏幕。

汽车美容屏

  • 与列表详细信息API集成
  • 提供有关特定列表的丰富内容
  • 需要导航到其他屏幕,以便在列表上进行交易(聊天、购买、出价等)。

这两个屏幕给人的感觉是截然不同的,并且有非常不同的改变原因。它们是理想的候选者,需要通过明确的边界来分离。

让我们先为搜索屏幕的合同建模。为了使这个屏幕能够独立测试,应该注入两个依赖关系。在这个例子中,我们选择将这些依赖注入到一个InheritedWidget中,这个InheritedWidget比我们的Search Screen小组件更靠近小组件树的根部。

class SearchDependencies extends InheritedWidget {
 const SearchDependencies({
   @required this.searchApi,
   @required this.onSearchResultTapped,
   @required Widget child,
 }) : super(child: child);
 
 final Iterable<SearchResult> Function(SearchParameters, int offset, int limit) searchApi;
 final void Function(BuildContext context, String listingId) onSearchResultTapped;
 
 static SearchDependencies of(BuildContext context) =>
     context.dependOnInheritedWidgetOfExactType<SearchDependencies>();
 
 @override
 bool updateShouldNotify(covariant SearchDependencies oldWidget) =>
     oldWidget.searchApi != searchApi || oldWidget.onSearchResultTapped != onSearchResultTapped;
}

注意:在这个例子中,我们只注入了一些依赖关系。在我们的应用程序中,我们将许多依赖项注入到我们的领域包中,如分析报告的API、功能标志、平台API等。

这使得Search包中的任何widget都可以使用BuildContext来访问这些依赖项。SearchDependencies.of(context)。这在概念上与访问Theme.of(context)或任何其他内置的InheritedWidgets没有区别。

class SearchResultCard extends StatelessWidget {
 const SearchResultCard({@required this.listingId});
 
 final String listingId;
 
 @override
 Widget build(BuildContext context) {
   return Card(
     child: InkWell(
       onTap: () => SearchDependencies.of(context).onSearchResultTapped(context, listingId),
       child: Column(
         children: [
           // some UI here
         ],
       ),
     ),
   );
 }
}

从测试的角度来看,我们可以简单地注入给定测试用例所需要的任何假造实现,并且可以完全测试Search Screen包的行为。

return SearchDependencies(
   searchApi: (searchParams, offset, pageSize) => [ /* Stub data here */ ],
   onSearchResultTapped: (context, listingId) => { /* Stubbed implementation here */},
   child: SearchResultsScreen(),
 );

虽然我们有时会使用这种策略来对单个widget进行单元测试,但我们通常会测试包的顶层公共widget。这有助于确保我们的测试验证整体行为,而不是耦合到实现细节上。我们甚至使用这种策略来提供模拟数据来执行全屏截图测试。你可以在我们之前的博文中阅读更多的内容。用Flutter进行截图测试

这种依赖性管理的方法还有其他好处。我们在车辆细节屏幕上也使用了同样的方法。虽然主要的用例是渲染一个实时的eBay列表,但我们的销售流程中有一个功能,允许卖家在发布之前预览他们的列表。通过为该用例包装具有不同依赖性的Vehicle Detail widget,可以轻松实现该预览功能。

现在我们已经有了搜索屏幕包的公共API,让我们看看如何在我们的应用程序中集成它。

class _AppState extends State<App> {
 ApiClient apiClient = EbayApiClient();
 AppNavigator navigator = AppNavigator();
 
 @override
 Widget build(BuildContext context) {
   return dependencies(
     [
       (app) => SearchDependencies(
             searchApi: apiClient.search,
             onSearchResultTapped: navigator.goToVehicleDetails,
             child: app,
           ),
       (app) => VehicleDetailDependencies(
             vehicleApi: apiClient.getVehicleDetails,
             child: app,
           ),
     ],
     app: MaterialApp(
       localizationsDelegates: [
         GlobalMaterialLocalizations.delegate,
         GlobalWidgetsLocalizations.delegate,
         SearchResultsLocalizations.delegate,
         VehicleDetailsLocalizations.delegate,
       ],
       home: SearchResultsScreen(),
     ),
   );
 }
}

在这个简单的例子中,一个有状态的widget存在于我们的Application包中,并构造了我们API客户端的具体实现。这是我们应用程序的根部件之一,负责构造我们的 MaterialApp。注意,我们正在配置依赖关系,并将它们放在MaterialApp的上方。这是至关重要的,因为MaterialApp提供了我们的根导航器。这意味着当我们导航到新的路线时,这些相同的依赖关系仍然可以从上下文中获得,因为它们在widget树的主干上。

然后,我们将在我们的应用包中添加一个简单的集成测试,以验证我们已经正确连接了我们的包。

testWidgets('Should navigate from Search Results to Vehicle Details when I click on a result', harness((given, when, then) async {
   final app = AppPageObject();
   // Pump the App and assert we are on the Search Screen
   await given.appIsPumped();
 
   // Assert the Search Results is on screen
   then.findsOneWidget(app.searchResults);
   // Assert no Vehicle Details is on screen
   then.findsNothing(app.vehicleDetails);
 
   // Tap on the first search result to see its details
   await when.userTaps(app.searchResults.resultAt(0));
 
   // Assert no Search Results is on screen
   then.findsNothing(app.searchResults);
   // Assert the Vehicle Details is on screen   
   then.findsOneWidget(app.vehicleDetails);
}));

你可能还注意到,每个包都暴露了自己的本地化委托。为了使每个包能够独立地进行测试,包需要完全拥有它的所有资源:图像、字体和本地化字符串。

很明显,这个例子已经被严重简化了。如果从表面上看,这种包结构可能会显得过分。然而,在我们的代码库中,我们的Search Screen包已经发展到17000行代码和500多个测试--它已经足够大了,以至于我们正在积极努力将其分解成更小的、更容易管理的部分。在实践中,这个包的边界使得从事其他功能的开发人员可以完全忽略所有这些复杂性。同样地,当有人确实需要进行搜索时,他们能够只在搜索屏幕包中工作,而忽略整个应用程序的其他部分。

这种方法为我们以可管理的方式扩展代码库提供了一个基础。我们可以轻松地同时开发多个大规模的功能,而且摩擦最小。在一个较小的包中工作,可以集中精力,提高开发人员的周转时间。如果开发人员做了改变,他们只需要在受影响的包中重新运行测试,如果他们的改变影响到公共API,则偶尔在应用包中运行测试。

我们也完全避免了单子和全局状态,总是通过widget树来管理我们的状态。正因为如此,Flutter的有状态的热重载在整个应用程序中始终如一地工作。它使我们能够在应用内开发者菜单中添加选项,以切换到我们的QA环境--迫使所有依赖API的状态被丢弃,并使整个应用无缝切换环境而无需重新编译。这使得我们能够避免添加特定环境的构建风味。我们唯一的编译时变化是一个可选的构建参数,以包含开发者菜单。

有了解耦的包也给我们的CI管道带来了巨大的好处。如果我们要对仓库中的所有代码进行构建、分析和测试,需要花费20多分钟。然而,由于每个包都是独立的可测试的,我们已经优化了我们的CI管道,以便在我们的构建服务器上并行构建和测试包。我们更进一步,对于我们的拉取请求(PR),我们只构建和测试那些受受影响文件影响的包。我们通过评估哪些包是依赖于变化后的包来实现的。这意味着对于大多数拉取请求,我们的CI周转时间通常在5分钟以内。然而,这并不是免费的,它需要不断的迭代和优化。如果你计划拥有一个具有多个包的单体,你应该计划在开发者工具和CI自动化上投入一些时间。

获得正确的包边界并不总是容易的。我们走过的例子是切入式的,但如果是跨越多个包的复杂功能,问题就会变得更加微妙。考虑一个功能,用户可以在搜索结果屏幕和车辆详细信息屏幕上 "喜欢 "或 "收藏 "一辆车。有时我们直到功能开发后期才发现正确的包边界。重新设计这些边界需要时间和精力,而且很容易把事情踢到一边,推迟清理。把可重用的代码塞进通用、共享或实用程序包中也是很有诱惑力的。然而,"简单 "的方式几乎总是导致技术债务的累积。长期以来,我们一直拒绝创建单一用途包,因为我们错误地认为增加更多的包是不好的。我们终于超越了这一假设,此后几乎完成了对最后一个垃圾站包的分解,简直不能再高兴了。

对于我们团队来说,将领域建模分解并应用到我们的应用中,远比选择合适的状态管理工具更重要。状态管理的潮流来来去去,但如果你的应用要生存下去,建模是永远的。


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