【转】CODE WITH ANDREA:Flutter 项目结构:功能优先还是层级优先?

2024/08/18 Flutter 共 4418 字,约 13 分钟
Bob.Zhu

当构建大型 Flutter 应用程序时,我们应该首先决定如何构建和组织我们的项目。 这样可以确保整个团队都能遵循明确的约定,并以统一的方式进行功能迭代。 所以在本文中,我们将探讨两种常见的项目结构方法:功能优先层级优先。 学习在实际应用开发中,两者的权衡取舍和各自的优缺点。 另外,我们还将提供一个详细的操作指南,指导您如何构建项目结构,避免一些常见的错误。

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

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

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

因此,在本文的其余部分,我们将使用我的 Riverpod 应用架构 作为参考:

Flutter App 架构使用数据层、域层、应用层和展示层。

flutter-app-architecture @2x

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

  • 展示层: Widget, state, and Controller
  • 应用层: Service
  • 域层: model
  • 数据层: Repository, Data Source, and DTOs (data transfer objects)

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

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

在实践中,通常使用功能优先或层级优先的方法。

因此,让我们更详细地探讨这两个模式并了解它们的优缺点。

层级优先 (features inside layers)

为了简单起见,假设我们的应用程序中只有两个功能。

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

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

通过这种方法,我们可以将所有相关的 Dart 文件添加到每个功能文件夹中,确保它们属于正确的层(展示层中的Widget和Controller,领域中的Model等)。

如果我们想要添加 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 <--

先分层:缺点

这种方法在实践中很容易使用,但随着应用程序的增长,它的扩展性并不是很好。

随着功能增加,属于不同层的文件相距甚远。这使得在单个功能上工作变得更加困难,因为我们必须不断跳转到项目的不同部分。

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

综合以上原因,当构建中大型应用程序时,功能优先的方法通常是更好的选择。

功能优先 (layers inside features)

功能优先方法要求我们为我们应用程序中添加的每个新功能创建一个新文件夹。 在功能文件夹中,再创建层文件夹作为子文件夹。

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

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

这种方式更加合理,因为我们可以轻松地看到所有属于某个功能的文件,按层分组。

相较于层级优先方法,有一些优点:

  • 无论我们想要添加新功能还是修改现有功能,我们只需专注于一个文件夹。
  • 如果我们想要删除一个功能,只需删除一个文件夹(如果算上功能对应的测试文件夹,则有两个文件夹)

所以,功能优先的方法似乎是胜出的!🙌

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

共享代码怎么办?

当然,在构建真实应用程序时,您会发现您的代码并不总是按照您打算的那样整洁地放在特定文件夹中。

当两个或多个独立功能需要共享一些 Widget 或 Model 类时怎么办?

这时,就需要创建一个名为 sharedcommonutils 的文件夹。

但是,这些文件夹本身应该如何组织呢?如何防止它们成为各种文件的垃圾场?

当你的应用程序有 20 个功能,并且有一些代码只需要其中2个共享,它真的应该属于顶级共享文件夹吗?

当它需要在5个功能之间共享时呢?或者10个呢?

这种场景下,没有正确或错误的答案,您必须根据具体情况进行判断。

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

功能优先与 UI 无关!

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

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

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

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

All the features above represented actual screens in the eCommerce app. 所有上面的功能都代表了电子商务应用程序中的实际界面。

但是,当我尝试将展示层、应用层、领域层和数据层放在这些功能中时,我遇到了麻烦,因为一些模型和存储库被多个页面共享(例如 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 来应用功能优先的方法。这将导致“不平衡”的项目结构,并在以后给您带来麻烦。

什么是“功能”(feature)

所以,我停下来,问自己:“什么是功能”?

然后我意识到,这不是用户看到的内容,而是用户所做的事情:

  • 认证鉴权
  • 管理购物车
  • 结账
  • 查看过去所有订单
  • 留下评论

换句话说,功能是帮助用户 完成给定任务功能需求

In other words, a feature is a functional requirement that helps the user complete a given task.

根据领域驱动设计的一些启发,我决定围绕领域层组织项目结构。

Once I figured that out, everything fell into place. And I ended up with seven functional areas: 一旦我弄清楚了这一点,一切都水到渠成。最终,我得到了七个功能区域:

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

注意:使用这种方法,仍然可以让给定功能内的代码依赖于来自不同功能的代码。例如:

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

但是,我们最终会得到更少的文件,这些文件在所有功能之间共享,并且整个结构更加平衡。

如何正确地使用功能优先方法

总之,功能优先方法让我们围绕应用程序的功能需求来构建项目结构。

因此,以下是如何在您自己的应用程序中正确使用此方法:

  • 从领域层开始,识别模型类和操作它们的业务逻辑
  • 为每个模型(或一组模型)创建一个文件夹
  • 在该文件夹中,根据需要创建展示层、应用层、领域层、数据层等子文件夹
  • 在每个子文件夹中,添加所有需要的文件

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

供参考,这是我的最终项目结构:

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

即便没有查看 common_widgets、constants、exceptions、localization、routing 和 utils 等文件夹中的内容, 我们也可以猜到它们都包含真正跨功能共享的代码,或者因某种重要原因而需要集中(例如本地化和路由)。

And these folders all contain relatively little code. 这些文件夹都包含相对较少的代码。

扩展: 测试文件夹

我直到现在才谈到这一点。但是,比较合理的做法是,测试文件夹遵循与 lib 文件夹相同的项目结构。

通过在 VSCode 中使用“转到测试”选项,这很容易实现:

go-to-tests

对于 lib 中的任何给定文件,这将在 test 中的相应位置创建一个 _test.dart 文件。

总结

如果正确使用,功能优先方法比层级优先方法有很多优点。

我已经用它构建了一个10K LOC 的中型电子商务应用程序,我相信这是一种可扩展的方法,可以很好地适用于更大的代码库。

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

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

参考资料

文档信息

Search

    Table of Contents