如何选择项目结构:功能优先还是层级优先?
在构建大型 Flutter 应用时,首要考虑的问题之一是如何 组织项目结构。
合理的项目结构能够确保整个团队遵循清晰的规范,并以一致的方式添加新功能。
本文将探讨两种常见的项目结构方法:功能优先 和 层级优先。
我们将学习它们的优缺点,识别在实际应用中实施它们时可能遇到的常见问题,避免在开发过程中出现代价高昂的错误。
项目结构与应用架构的关系
实际上,只有在确定了要使用的应用架构之后,我们才能选择项目结构。
这是因为应用架构迫使我们定义 独立的层级,并设置 清晰的边界。而这些层级最终将在项目中以文件夹的形式体现。
因此,本文将以我认为比较好的应用架构为参考:
这种架构由四个不同的层级(layer)组成,每个层级包含应用所需的组件:
- Presentation: widgets、state、controller
- Application: service
- Domain: model
- Data: repository、data sources、DTO
当然,如果我们只是构建一个单页应用,可以将所有文件放在一个文件夹中,然后结束工作。 😎
但是,一旦我们开始添加更多页面,并需要处理各种数据model,就需要以 一致的方式 组织所有文件。
在实际应用中,通常使用 功能优先 或 层级优先 的方法。
接下来,让我们更详细地探讨这两种规范,并了解它们的优缺点。
层级优先(层级内包含功能)
为了简化说明,假设我们的应用只有两个功能。
如果采用 层级优先 的方法,项目结构可能如下所示:
‣ lib
‣ src
‣ presentation
‣ feature1
‣ feature2
‣ application
‣ feature1
‣ feature2
‣ domain
‣ feature1
‣ feature2
‣ data
‣ feature1
‣ feature2
严格来说,这是一种 “层级内包含功能” 的方法,因为我们没有将 Dart 文件直接放在每个层级中,而是创建了层级内的文件夹。
通过这种方法,我们可以将所有相关的 Dart 文件添加到每个功能文件夹中,确保它们属于正确的层级(小部件和控制器在 Presentation
中,model在 Domain
中,等等)。
如果要添加 feature3
,则需要在 每个层级 中添加一个 feature3
文件夹,并重复上述过程:
‣ lib
‣ src
‣ presentation
‣ feature1
‣ feature2
‣ feature3 <--
‣ application
‣ feature1
‣ feature2
‣ feature3 <-- 仅在需要时创建
‣ domain
‣ feature1
‣ feature2
‣ feature3 <--
‣ data
‣ feature1
‣ feature2
‣ feature3 <--
层级优先:缺点
这种方法在实践中易于使用,但在应用规模扩大时难以扩展。
对于任何给定的功能,属于不同层级的文件彼此相隔很远。这使得难以开发单个功能,因为我们必须 不断跳转 到项目的不同部分。
如果我们决定删除一个功能,很容易忘记某些文件,因为它们都是按层级组织的。
由于这些原因,在构建中小型应用时,功能优先的方法通常是更好的选择。
功能优先(功能内包含层级)
功能优先的方法要求我们为添加到应用中的每个新功能创建一个新的文件夹。在该文件夹中,我们可以添加层级本身作为子文件夹。
使用与上面相同的示例,我们将这样组织项目:
‣ lib
‣ src
‣ features
‣ feature1
‣ presentation
‣ application
‣ domain
‣ data
‣ feature2
‣ presentation
‣ application
‣ domain
‣ data
我发现这种方法更合乎逻辑,因为我们可以轻松地看到属于某个功能的所有文件,按层级分组。
与层级优先方法相比,它有一些优势:
- 无论何时要添加新功能或修改现有功能,我们都 可以专注于一个文件夹。
- 如果要删除一个功能,只需要删除一个文件夹(如果算上对应的
tests
文件夹,则需要删除两个)。
因此,功能优先 方法似乎胜券在握!🙌
然而,现实情况并非如此简单。
共享代码怎么办?
当然,在构建实际应用时,您会发现代码并不总是像预期的那样整齐地放在特定的文件夹中。
如果两个或多个独立的功能需要共享一些小部件或model类怎么办?
在这种情况下,很容易出现名为 shared
或 common
或 utils
的文件夹。
但这些文件夹本身应该如何组织?如何防止它们成为各种文件的垃圾场?
如果您的应用有 20 个功能,并且有一些代码需要由其中两个功能共享,它真的应该属于顶层的 shared
文件夹吗?
如果它在 5 个功能之间共享呢?或者 10 个?
在这种情况下,没有正确或错误的答案,您必须根据具体情况做出最佳判断。
除此之外,还有一个我们应该避免的非常常见的错误。
功能优先不等于 UI 优先!
当我们关注 UI 时,很可能会将一个功能 视为应用中的单个页面 或屏幕。
我在曾经开发的一个在线商城应用时犯了这个错误。
我最终得到的项目结构如下所示:
‣ lib
‣ src
‣ features
‣ account
‣ admin
‣ checkout
‣ leave_review_page
‣ orders_list
‣ product_page
‣ products_list
‣ shopping_cart
‣ sign_in
上面所有功能都代表在线商城应用中的实际屏幕。
但是,当要把 Presentation、Application、Domain 和 Data 放入它们时,我遇到了麻烦,因为一些model和repository被多个页面共享(例如 product_page
和 product_list
)。
结果,我最终为 service、model 和 repository 创建了顶层文件夹:
‣ lib
‣ src
‣ features
‣ account
‣ admin
‣ checkout
‣ leave_review_page
‣ orders_list
‣ product_page
‣ products_list
‣ shopping_cart
‣ sign_in
‣ models <-- 应该放在这里吗?
‣ repositories <-- 应该放在这里吗?
‣ services <-- 应该放在这里吗?
换句话说,我对 features
文件夹(代表我的整个Presentation)应用了 功能优先 方法。但是,我陷入了对剩余层级的 层级优先 方法的困境,这以一种意想不到的方式影响了我的项目结构。
不要试图通过查看 UI 来应用 功能优先 方法。这会导致项目结构“不平衡”,并在以后给你带来麻烦。
什么是“功能”?
因此,我退后一步,问自己:“什么是功能?”
我意识到,它不是关于用户 看到 什么,而是关于用户 做什么:
- 身份验证
- 管理购物车
- 结账
- 查看所有过去订单
- 留下评论
换句话说,功能是帮助用户 完成特定任务 的 功能需求。
从 领域驱动设计(DDD) 中获得一些启发,我决定围绕 Domain 组织项目结构。
一旦我弄清楚了这一点,一切都变得井然有序。我最终得到了七个功能区域:
‣ lib
‣ src
‣ features
‣ address
‣ application
‣ data
‣ domain
‣ presentation
‣ authentication
...
‣ cart
...
‣ checkout
...
‣ orders
...
‣ products
‣ application
‣ data
‣ domain
‣ presentation
‣ admin
‣ product_screen
‣ products_list
‣ reviews
...
请注意,使用这种方法,给定功能内的代码仍然可能依赖于 来自不同功能的代码。例如:
- 产品页面显示 评论 列表。
- 订单页面显示一些 产品 信息。
- 结账流程要求用户首先进行 身份验证。
但是,我们最终将有更少的跨所有功能共享的文件,整个结构也更加平衡。
如何正确地进行功能优先
总之,功能优先方法使我们能够围绕应用的 功能需求 来构建项目结构。
以下是如何在您自己的应用中正确使用它:
- 从 Domain 开始,识别model类和用于操作它们的业务逻辑。
- 为 属于一起 的每个model(或model组)创建一个文件夹。
- 在该文件夹中,根据需要创建
presentation
、application
、domain
、data
子文件夹。 - 在每个子文件夹中,添加您需要的全部文件。
在构建 Flutter 应用时,UI 代码与业务逻辑的比例通常为 5:1(或更高)。如果您的
presentation
文件夹最终包含许多文件,不要害怕将它们分组到代表较小的“子功能”的子文件夹中。
作为参考,这是我的最终项目结构:
‣ lib
‣ src
‣ common_widgets
‣ constants
‣ exceptions
‣ features
‣ address
‣ authentication
‣ cart
‣ checkout
‣ orders
‣ products
‣ reviews
‣ localization
‣ routing
‣ utils
即使不查看 common_widgets
、constants
、exceptions
、localization
、routing
和 utils
等文件夹,我们也可以猜测它们都包含 真正跨功能共享 的代码,或者由于某种原因需要 集中管理(例如本地化和路由)。
而这些文件夹都包含相对较少的代码。
test 文件夹
我之前没有谈到这一点,但是,test
文件夹遵循与 lib
文件夹相同的项目结构非常有意义。
这可以通过使用 VSCode 右键源码文件中的“Go to Tests”选项轻松实现:
对于 lib
中的任何给定文件,这将在 test
中的对应位置创建一个 _test.dart
文件。👍
总结
如果做得好,功能优先 比 层级优先 有很多优势。
我已经使用它构建了多个应用,我相信这是一种可扩展的方法,应该适用于更大的代码库。
当然,在构建非常大的应用时,我们将面临额外的限制。在某些时候,我们可能需要混合使用不同的方法,甚至将代码库分解成多个位于单个单仓中的包。
但是,如果我们从一开始就应用 DDD,最终将在应用的不同层级和组件之间建立清晰的边界,这将使依赖项在以后更容易管理。