在构建大型Flutter应用程序时,我们首先应该决定的是如何构建我们的项目。
这可以确保整个团队可以遵循一个明确的惯例,并以一致的方式添加功能。
因此,在这篇文章中,我们将探讨两种常见的项目结构化方法:功能优先和层优先。
我们将了解它们的权衡,并找出在现实世界的应用中实施它们时的常见陷阱。而且,我们将以一个清晰的步骤指南来总结你如何构建你的项目,避免沿途出现代价高昂的错误。
项目结构与应用程序架构的关系
在实践中,我们只有在决定使用何种应用架构后才能选择项目结构。
这是因为应用程序架构迫使我们定义具有明确边界的独立层。而这些层会在我们的项目中以文件夹的形式显示出来。
所以在本文的其余部分,我们将使用我的Riverpod应用架构作为参考。
Flutter应用架构使用数据层、领域层、应用层和表现层。
这个架构由四个不同的层组成,每个层都包含了我们的应用程序需要的组件。
- 表现形式:小部件、状态和控制器
- 应用:服务
- 领域:模型
- 数据:存储库、数据源和DTO(数据传输对象)。
当然,如果我们只是建立一个单页的应用程序,我们可以把所有的文件放在一个文件夹里,然后就可以了。😎
但是,一旦我们开始添加更多的页面,并且有各种数据模型需要处理,我们如何能以一种一致的方式组织所有的文件呢?
在实践中,经常使用功能优先 或层优先的方法。
因此,让我们更详细地探讨这两种惯例,并了解它们的权衡。
图层优先(图层内的特征)
为了简单起见,假设我们的应用程序中只有两个功能。
如果我们采用层优先的方法,我们的项目结构可能看起来像这样。
‣ lib
‣ src
‣ presentation
‣ feature1
‣ feature2
‣ application
‣ feature1
‣ feature2
‣ domain
‣ feature1
‣ feature2
‣ data
‣ feature1
‣ feature2
严格来说,这是一种**"层内功能 "**的方法,因为我们不把Dart文件直接放在每个层内,而是在层内创建文件夹。
通过这种方法,我们可以在每个特性文件夹内添加所有相关的Dart文件,确保它们属于正确的层(widgets和控制器在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 <--
以层为先。缺点
这种方法在实践中很容易使用,但随着应用程序的增长,它的扩展性不是很好。
对于任何给定的功能,属于不同层的文件都是相互远离的。而这使得我们更难在单个功能上工作,因为我们必须不断跳转到项目的不同部分。
而且,如果我们决定要删除一个特征,就很容易忘记某些文件,因为它们都是按层组织的。
由于这些原因,在构建中型/大型应用程序时,功能优先的方法通常是一个更好的选择。
功能优先(功能内的层)
功能优先的方法要求我们为每一个添加到我们应用程序的新功能创建一个新的文件夹。在这个文件夹中,我们可以将图层本身作为子文件夹添加。
使用上述相同的例子,我们将这样组织我们的项目。
‣ lib
‣ src
‣ features
‣ feature1
‣ presentation
‣ application
‣ domain
‣ data
‣ feature2
‣ presentation
‣ application
‣ domain
‣ data
我觉得这种方法更有逻辑性,因为我们可以很容易地看到属于某个特征的所有文件,按层分组。
与 "层优先 "的方法相比,有一些优势。
- 每当我们想添加一个新的特征或修改一个现有的特征时,我们可以只关注一个文件夹。
- 如果我们想删除一个特征,只有一个文件夹需要删除(如果算上相应的
tests文件夹,则有两个)。
这样看来,功能优先的方法胜券在握!🙌
然而,在现实世界中,事情并不那么容易。
共享代码怎么办?
当然,在构建真正的应用程序时,你会发现你的代码并不总是按照你的意图整齐地放在特定的文件夹里。
如果两个或多个独立的功能需要共享一些部件或模型类,该怎么办?
在这种情况下,很容易出现名为shared 或common ,或utils 的文件夹。
但是这些文件夹本身应该如何组织?你如何防止它们成为各种文件的垃圾场?
如果你的应用程序有20个功能,并且有一些代码只需要被其中的两个人共享,那么它真的应该属于一个顶级的shared 文件夹?
如果它是由5个功能共享的呢?或者10个?
在这种情况下,没有正确或错误的答案,你必须在个案的基础上使用你的最佳判断。
除此以外,还有一个非常常见的错误,我们应该避免。
功能优先不是关于用户界面的!
当我们关注用户界面时,我们很可能会把一个功能看作是应用程序中的一个单独的页面或屏幕。
我自己在为我即将开始的Flutter课程构建电子商务应用时就犯了这个错误。
而我最终得到的是一个有点像这样的项目结构。
‣ lib
‣ src
‣ features
‣ account
‣ admin
‣ checkout
‣ leave_review_page
‣ orders_list
‣ product_page
‣ products_list
‣ shopping_cart
‣ sign_in
上面的所有功能都代表了电子商务应用中的实际屏幕。
但是当我要把展示层、应用层、领域层和数据层放在其中时,我遇到了麻烦,因为有些模型和资源库是由多个页面共享的(比如product_page 和product_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 文件夹中采用了功能优先的方法,它代表了我的整个表现层。但我把自己逼到了其余层的 "**层优先 "**方法中,这就以一种意想不到的方式影响了我的项目结构。
不要试图通过查看用户界面来应用功能优先的方法。这将导致一个 "不平衡 "的项目结构,以后会咬你一口。
什么是 "功能"?
所以我退一步问自己。"什么是功能"?
然后我意识到这不是关于用户看到什么,而是用户做什么。
- 认证
- 管理购物车
- 结账
- 查看所有过去的订单
- 留下评论
换句话说,特征是一个功能需求,它可以帮助用户完成一个特定的任务。
从领域驱动的设计中得到一些提示,我决定围绕领域层组织项目结构。
一旦我想通了这一点,一切就都水到渠成了。我最终得到了七个功能区。
‣ lib
‣ src
‣ features
‣ address
‣ application
‣ data
‣ domain
‣ presentation
‣ authentication
...
‣ cart
...
‣ checkout
...
‣ orders
...
‣ products
‣ application
‣ data
‣ domain
‣ presentation
‣ admin
‣ product_screen
‣ products_list
‣ reviews
...
请注意,在这种方法下,一个给定的功能中的代码仍然有可能依赖于不同功能中的代码。比如说。
- 产品页面显示一个评论列表
- 订单页显示一些产品信息
- 结账流程要求用户先进行认证
但我们最终在所有功能中共享的文件要少得多,而且整个结构也更加平衡。
如何以正确的方式实现功能优先
综上所述,功能优先的方法让我们可以围绕应用程序的功能需求来构建我们的项目。
因此,下面是如何在你自己的应用程序中正确使用这个方法。
- 从领域层开始,确定模型类和操作它们的业务逻辑
- 为每一个模型(或一组模型)创建一个文件夹,这些模型属于一起。
- 在该文件夹中,根据需要创建
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 文件夹来说,遵循与lib 文件夹相同的项目结构是非常有意义的。
而这一点通过使用VSCode中的 "转到测试 "选项就可以非常容易地做到。
VSCode选项可以从 "lib "文件夹中的任何文件转到测试。
对于lib 中的任何指定文件,这将在test 中的相应位置创建一个_test.dart 文件。👍
结论
如果做得好,功能优先 比层优先有很多好处。
在用它构建了一个10K LOC的中型电子商务应用后,我相信这是一个可扩展的方法,对更大的代码库应该很有效。
当然,在构建非常大的应用程序时,我们将面临额外的限制。在某些时候,我们可能需要混合和匹配不同的方法,甚至将代码库分解成多个包,这些包生活在一个单一的monorepo中。
但是,如果我们从一开始就采用领域驱动设计,我们最终会在应用程序的不同层和组件之间建立清晰的界限。这将使以后的依赖关系更容易管理。
如果你想了解更多关于应用程序架构和每个单独层的作用,请查看本系列中的其他文章。