Flutter 项目结构:首选特性或首选层次?

更新时间:2023-10-30 21:28

在构建大型Flutter应用程序时,我们首先需要决定如何组织项目结构。这确保整个团队都能遵循清晰的约定,并以一致的方式添加功能。

因此,在本文中,我们将探讨两种常见的项目结构方法:以功能为先和以层为先。

我们将了解它们的权衡以及在尝试在实际应用程序中实施它们时常见的陷阱。最后,我们将提供一个清晰的分步指南,指导您如何组织项目结构,避免昂贵的错误。

与应用程序架构相关的项目结构

实际上,我们只能在决定使用什么应用程序架构之后才能选择项目结构。

这是因为应用程序架构迫使我们定义具有明确边界的单独层。而这些层将在我们的项目中以某种形式显示为文件夹。

因此,在本文的其余部分,我们将使用我的 Riverpod应用程序架构 作为参考: Flutter App Architecture using data, domain, application, and presentation layers.

Flutter App Architecture使用数据、领域、应用和展示层

这种架构由四个不同的层组成,每个层包含了我们的应用程序所需的组件:

  • 展示层:小部件、状态和控制器
  • 应用层:服务
  • 领域:模型
  • 数据:存储库、数据源和DTO(数据传输对象)

当然,如果我们只是构建一个单页面应用程序,我们可以把所有文件放在一个文件夹中,然后完成。😎

但是,一旦我们开始添加更多页面并处理各种数据模型,我们该如何以一致的方式组织所有文件呢?

实际上,通常使用一种以特性为先或以层为先的方法。

因此,让我们更详细地探讨这两种约定并了解它们的权衡。

以层为先(将特性放在层内)

为了保持简单,假设我们的应用程序只有两个特性。

如果我们采用以层为先的方法,我们的项目结构可能如下所示:


‣ lib
  ‣ src
    ‣ presentation
      ‣ feature1
      ‣ feature2
    ‣ application
      ‣ feature1
      ‣ feature2
    ‣ domain
      ‣ feature1
      ‣ feature2
    ‣ data
      ‣ feature1
      ‣ feature2

严格来说,这是一种“层内特性”方法,因为我们不会直接将Dart文件放在每个层内,而是在其中创建文件夹。

通过这种方法,我们可以将所有相关的Dart文件添加到每个特性文件夹中,确保它们属于正确的层(presentation中的小部件和控制器,domain中的模型等)。

如果我们想要添加feature3,我们需要在每个层内添加一个feature3文件夹并重复这个过程:


‣ lib
  ‣ src
    ‣ presentation
      ‣ feature1
      ‣ feature2
      ‣ feature3 <--
    ‣ application
      ‣ feature1
      ‣ feature2
      ‣ feature3 <-- only create this when needed
    ‣ domain
      ‣ feature1
      ‣ feature2
      ‣ feature3 <--
    ‣ data
      ‣ feature1
      ‣ feature2
      ‣ feature3 <--

Layer-first: Drawbacks

这种方法在实践中易于使用,但随着应用程序的增长,不太适用。

对于任何给定的功能,属于不同层的文件相距甚远。这使得在单独的功能上工作变得更加困难,因为我们必须不断地跳到项目的不同部分。

而且,如果我们决定要删除一个功能,很容易忘记某些文件,因为它们都是按层组织的。

因此,当构建中等/大型应用程序时,通常更好的选择是采用功能优先的方法。

Feature-first (layers inside features)

功能优先方法要求我们为每个添加到应用程序中的新功能创建一个新的文件夹。在该文件夹内,我们可以将层本身作为子文件夹添加。

使用上面的相同示例,我们将组织我们的项目如下:


‣ lib
  ‣ src
    ‣ features
      ‣ feature1
        ‣ presentation
        ‣ application
        ‣ domain
        ‣ data
      ‣ feature2
        ‣ presentation
        ‣ application
        ‣ domain
        ‣ data

我认为这种方法更合乎逻辑,因为我们可以轻松地看到所有属于某个特定功能的文件,按层分组。

与首先考虑层面的方法相比,有一些优势:

  • 每当我们想要添加新功能或修改现有功能时,我们只需要关注一个文件夹。
  • 如果我们想要删除一个功能,只需删除一个文件夹(如果计算对应的tests文件夹,那就是两个文件夹)。

因此,看起来以特性为先的方法毫无疑问胜出!🙌

然而,在现实世界中,情况并不是那么简单。

那么共享代码呢?

当构建真正的应用程序时,您会发现您的代码并不总是按您预期的方式整齐地放在特定文件夹中。

如果两个或更多独立的特性需要共享一些小部件或模型类,该怎么办?

在这些情况下,很容易出现名为sharedcommonutils的文件夹,但这些文件夹应该如何组织?如何防止它们变成各种文件的倾倒地?

如果您的应用程序有20个特性,并且有一些代码只需被其中两个特性共享,它真的应该属于顶层的shared文件夹吗?

如果它在5个特性之间共享?或者是10个?

在这种情况下,没有对与错的答案,您必须根据具体情况谨慎判断。


除此之外,有一个非常常见的错误是我们应该避免的。

特性为先不是关于UI!

当我们关注UI时,我们很可能会将一个特性视为应用程序中的一个单个页面或屏幕。

在为我的即将推出的Flutter课程构建电子商务应用程序时,我自己也犯了这个错误。

最终,我的项目结构看起来有点像这样:


‣ lib
  ‣ src
    ‣ features
      ‣ account
      ‣ admin
      ‣ checkout
      ‣ leave_review_page
      ‣ orders_list
      ‣ product_page
      ‣ products_list
      ‣ shopping_cart
      ‣ sign_in

上面提到的所有功能都在电子商务应用程序中以实际屏幕的形式呈现。

但是,当涉及将演示、应用程序、领域和数据层嵌入其中时,我遇到了问题,因为一些模型和存储库被多个页面共享(例如 product_pageproduct_list)。

因此,我最终创建了顶层文件夹来组织服务、模型和存储库:


‣ lib
  ‣ src
    ‣ features
      ‣ account
      ‣ admin
      ‣ checkout
      ‣ leave_review_page
      ‣ orders_list
      ‣ product_page
      ‣ products_list
      ‣ shopping_cart
      ‣ sign_in
    ‣ models <-- should this go here?
    ‣ repositories <-- should this go here?
    ‣ services <-- should this go here?

换句话说,我在features文件夹上采用了一种以特性为先的方法,该文件夹代表了我的整个展示层。但是在其余的层面上,我陷入了以层为先的方法,这对我的项目结构产生了意外的影响。

不要试图通过查看UI来采用以特性为先的方法。这将导致一个"不平衡"的项目结构,以后会让你疲于奔命。

什么是"特性"?

所以我退后一步,问自己:"什么是特性"?

然后我意识到这不是关于用户看到什么,而是关于用户做什么:

  • 进行身份验证
  • 管理购物车
  • 结账
  • 查看所有过去的订单
  • 留下评论

换句话说,一个特性是帮助用户完成特定任务的功能要求。

而从领域驱动设计中汲取一些灵感后,我决定围绕领域层组织项目结构。

一旦我明白了这一点,一切都水到渠成了。最终,我得到了七个功能领域:


‣ lib
  ‣ src
    ‣ features
      ‣ address
        ‣ application
        ‣ data
        ‣ domain
        ‣ presentation
      ‣ authentication
        ...
      ‣ cart
        ...
      ‣ checkout
        ...
      ‣ orders
        ...
      ‣ products
        ‣ application
        ‣ data
        ‣ domain
        ‣ presentation
          ‣ admin
          ‣ product_screen
          ‣ products_list
      ‣ reviews
        ...

请注意,采用这种方法,仍然有可能使一个功能内部的代码依赖于来自不同功能的代码。例如:

  • 产品页面显示评论列表
  • 订单页面显示一些产品信息
  • 结账流程要求用户首先进行身份验证

但是,我们最终会减少跨所有功能共享的文件数量,并且整个结构更加平衡。

如何正确实施功能优先方法

总之,功能优先方法允让我们将项目结构围绕着应用的功能需求进行构建。

以下是在您自己的应用程序中正确使用此方法的步骤:

  • 从领域层开始,识别模型类和操作它们的业务逻辑
  • 为每个模型(或一组相关模型)创建一个文件夹
  • 在该文件夹内,根据需要创建 presentationapplicationdomaindata 子文件夹
  • 在每个子文件夹内添加所需的所有文件

在构建Flutter应用程序时,UI代码和业务逻辑之间很常见的比例是5:1(或更多)。如果您的 presentation 文件夹中有很多文件,不要害怕将它们分组到代表较小“子功能”的子文件夹中。

供参考,以下是我的最终项目结构的示例:


‣ lib
  ‣ src
    ‣ common_widgets
    ‣ constants
    ‣ exceptions
    ‣ features
      ‣ address
      ‣ authentication
      ‣ cart
      ‣ checkout
      ‣ orders
      ‣ products
      ‣ reviews
    ‣ localization
    ‣ routing
    ‣ utils

不需要查看像 common_widgetsconstantsexceptionslocalizationroutingutils 这样的文件夹的内容,我们可以猜测它们都包含真正跨功能共享的代码,或者出于很好的原因需要集中管理(比如本地化和路由)。

而且,这些文件夹都包含相对较少的代码。

额外内容:测试文件夹

我直到现在才提到这一点。但是,test 文件夹遵循与 lib 文件夹相同的项目结构是非常有道理的。

通过在VSCode中使用“转到测试”选项,这很容易实现: VSCode option to Go to Tests from any file in the VSCode中的选项,可以从“lib”文件夹中的任何文件跳转到测试文件。对于“lib”文件夹中的任何文件,这将在“test”文件夹中的相应位置创建一个 _test.dart 文件。👍

结论

如果做得当,从功能出发比从层出发有许多优点。

我已经使用它构建了一个包含1万行代码的中等规模电子商务应用,我相信这是一个可扩展的方法,对于更大的代码库应该也适用。

当然,在构建非常大的应用程序时,我们将面临额外的约束。在某些时候,我们可能需要混合和匹配不同的方法,甚至将代码库拆分为存在于单一monorepo中的多个包。

但如果我们从一开始就应用领域驱动设计,我们将在应用程序的不同层和组件之间建立明确的边界。这将使后来的依赖关系更容易管理。