查看原文
其他

迈向 Android 架构师:模块化设计原则

fundroid AndroidPub
2024-08-24

前言:组件与模块

“If the SOLID principles tell us how to arrange the bricks into walls and rooms, then the component principles tell us how to arrange the rooms into buildings.” ~ Robert C. Martin, Clean Architecture

SOLID 指导我们如何写出高质量代码,而组件设计原则(Component Priciples)指导我们如何合理地组织代码,实现代码目录的高内聚和低耦合。

组件(Component)这个词现在用得比较泛滥,组件设计原则的“组件”的定义来自《 Clean Architecture》,代表是一组业务相关的文件集合,在 Android 工程中,一个组件可以等价理解为一个模块( Gradle Module)。所以本文讨论的就是如何更好的组织这些模块,让 Android 工程架构的模块化更合理,可以为我们带来以下好处:

  • 更合理的代码目录
  • 更快的编译速度
  • 更好的开发效率

How can we say that modules are highly cohesive?
And how can we say that modules are lowly coupled?

好的模块化设计,其本质就是不断追求高内聚和低耦合。整洁架构中提供了一系列方法论指导我们达成这一目标。包括:

  • 内聚性原则
  • 耦合性原则
  • 如何划分模块
  • 如何封装模块
  • 如何集成模块

接下来,让我们对上述原则进行逐一讲解。

注意:本文后面“模块”和“组件”两个词可能会混用,你知道是一回事儿就好了。

1. 内聚性原则

组件内聚性原则(Component Cohesion Principles)由三个子原则组成

  • CCP - Common Closure Principle 共同闭包原则
  • CRP - Common Reuse Principle 共同复用原则
  • REP -  Reuse/Release Equivalency Principle 复用发布等同原则

组件设计原则很大程度受到了 SOLID 原则影响, 这是整洁架构的核心,除了可以组织代码也可以用来组织模块,后文中会将组件设计原则与 SOLID 原则做类比。顺便回顾一下 SOLID 的含

  • SRP  - Single  Responsibility Principle 单一职责原则
  • OCP - Open/Closed Principle 开闭原则
  • LSP - Liskov Substitution Principle  里氏替换原则
  • ISP - Interface Segregation Principle  接口隔离原则
  • DIP - Dependency Inversion Principle  依赖倒置原则
更多 SOLID 的内容可以参考我的上一篇文章:通俗易懂讲解 KISS/DRY/YANGI/SOLID 等程序设计原则

CCP - 共同闭包原则

“Gather into components those classes that change for the same reasons and at the same times. Separate into different components those classes that change at different times and for different reasons.”

当一个需求或变化发生时,应该只有一个类或模块受到影响,而其他的类或模块应该保持不变。这意味着与特定需求相关的代码应该在同一个组件中,并且不应该分散在不同的组件中。

CCP(Common Closure Principle)其实就是 SRP 模块级别的演变。一个类的职责应该单一,同样一个模块内的所有文件功能也应该单一,它们都服务于同一类业务。业务相关的代码归属同一模块,反之放到不同模块。

显然,将每个可能的变化都集中在单个模块中并不现实,所以该原则的目标是最小化需要更改的模块数量。降低对其他模块的影响有利于提升整体架构的可维护性,但是矛盾的是,这个原则让我们倾向于将尽可能多的代码放到同一模块中,以减低需要更改的模块数量。

但是模块内容太多也不合理,需要根据具体情况进行权衡和设计取舍。

CRP - 共同复用原则

“Don’t force users of a component to depend on things they don’t need.”

CPR (Common Reuse Principle)强调在设计可重用组件时应该考虑到它们的协作关系和共同重用性。一个 Android Module 可能处于底层被其他模块复用。CPR 从可复用性角度考虑,相关的类放置在同一个模块中, 构成一个完整的可重用抽象,便于共同重用。

CRP 还指出,不应该将不需要一起重用的类或模块放在同一个组件中。这是为了避免将不相关的功能耦合在一起,使得在重用某个功能时,不必同时引入不需要的功能或依赖关系。通过这样做,对这些类的更新不会触发未使用它们的模块的重新编译、重新部署或发布。

CRP 本质上是接口隔离原则(ISP)在模块级别的演变。当接口变得小而精的时候,你就不会依赖于不需要的方法,当模块变得小而精的时候,你就不会依赖于不需要的文件。

可见,CRP 模块尽可能小。否则,作为模块使用者,可能会受到你不关心的变化的影响。这会造成整个架构中出现和处理更多的模块。这也是一种权衡,需要根据具体情况进行权衡和设计取舍。

REP - 复用/发布等同原则

“The granule of reuse is the granule of release.”

一个 Module 随时可能发布成一个二进制库。当我们的可复用模块的颗粒度应该与发布单位颗粒度保持一致,REP(Reuse/Release Equivalency Principle)对于库开发人员来说非常重要的原则。

模块与库保持一致,有利于我们对模块维护升级后,低成本地发布新库,服务库的更多使用者。同时也可以在本地进行二进制库和源码模块的无缝切换。

为了达到这一目的,我们一般为库制定版本号,模块中的所有类都具有相同的发布版本号,更新单个类将需要发布该模块下的所有类的新版本。

有时,库会以一组库的形式提供,形式提供,比如 Gradle 通过 group id 和 artifect id 表示组的 id 和 库的 id。当使用一组库时,为了确保子组件之间的兼容性和维护性,我们可能期望所有这些模块一起被重用,所以它们经常具有相同的发布版本号。具有相同的发布版本号意味着更新一个模块时,需要发布所有其他模块的版本。

例如以 Retrofit 为例,它的子模块总是保持版本一致。

implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-moshi:2.9.0'

但是,有时候保持一致的模块版本号,会导致没有变化的模块不必要的升级,特别有时候各个模块的负责团队不在一起,引起额外的沟通成本。Jetpakc Compose 的各个子库曾经需要时刻保持版本一致,但是最近借助 Gradle 5.0 对 Maven  BOM 的支持,允许各个子库出现差异。

BOM 全称为 Bill Of Materials,BOM的意义旨在通过指定的 BOM 版本来管理所有库版本。以 Compose 为例,比如我们要添加 compose.material3 和 compose.ui 的依赖,在未使用 BOM 的前提下,我们需要找到对应的版本,然后再添加依赖;然而在使用 BOM 的情况下,我们可以不用再去查找依赖对应的版本,直接在 dependencies{} 中添加他们就行。

图中标红的就是添加 BOM 的方式。知晓了BOM的添加方式之后,compose.ui 依赖是直接通过 implementation("androidx.compose.ui:ui") 添加,这一行并没有涉及到版本信息,却也能正常引入 compose.ui 相关依赖

REP 的优势是扩大了模块的复用范围,可以库的形式服务更多接入方,且库的维护成本没有比模块上升。这里同样要对模块的粒度作取舍,模块越多,库的发布流程和版本管理的成本要大,模块越少,灵活性越差,单个库的能力变得越臃肿。

组件内聚性原则三角关系

前面通过分析可以看到 CCP、CRP 和 REP 对模块粒度的划分上都要有取舍,各个原则之间互相制约,如上面图中的三角关系。

CCP 和 REP 是包容性原则;它们倾向于使模块变得更大,而 CRP 是一项排他性原则,因为它倾向于使模块变得更小。

CRP 和 REP 是关注复用的原则。它们倾向于面向使用方优化模块,而 CCP 则侧重于维护,因为它倾向于面向开发者优化模块。很难同时平衡这三个原则,所以需要根据自己的项目需要有所权衡,放弃或更少关注其中之一。

项目根据产物不同,可能是一个应用(App),亦或者是一个库(Lib)。

当你构建一个应用程序时,你的主要目标是快速构建,并且拥有一个能够快速编译且最小化不必要重新编译的项目。如果你属于这个类别,你应该始终关注CCP和CRP。

当你构建一个库时,你的目标不会是静态的。相反,它会随着时间而变化。在开始开发库时,你的主要关注点应该是快速构建库。然后,随着时间的推移,你的关注点应该转移到库的可重用性上,并在一定程度上牺牲其维护性。如果你属于这个类别,你应该在你的项目成熟之前专注于三角形的右侧,然后随着时间的推移逐渐移向左侧,因为现在你将对库的用户负有越来越多的责任。

大多数项目的模块化失败是因为工程师们将错误的原则置于项目的性质之上。其他项目的模块化失败是因为组件结构是静态的,而不是随着需求变化而发展。

2. 耦合性原则

组件内聚性原则着眼于模块如何划分,组件耦合性原(Components Coupling Principles)则关注划分好的组件之间如何依赖。

它也由三个子原则构成:

  • ADP -  Acyclic Dependencies Principle  无环依赖原则
  • SDP - Stable Dependencies Principle 稳定依赖原则
  • SAP - Stable Abstractions Principle 稳定抽象原则

ADP - 无环依赖原则

“Allow no cycles in the components dependency graph.”

ADP( Acyclic Dependencies Principle ) 提倡模块间的依赖不应成环。如果 A 直接或间接依赖 B,则不应该再有 B 依赖 A 的链路。环形依赖会造成各个模块互相影响,很难形成自下而上的稳定层级。Gradle 这方面做得很好,Module 的环形依赖会在编译期被发现。

如果发现了环形依赖,我们可以通过两种方式解环

  • Solution1 :抽取新模块作为公共依赖
  • Solution2:DIP 依赖倒置(又一次使用了 SOLID)

Solution1 中,抽取 Module D 作为公共依赖,解除了其他 Module 之间的环形依赖。Solution2 通过 A 实现 C 的 interface,解放了 C 对 A 的依赖。

SDP -  稳定依赖原则

“Depend in the direction of stability.”

根据 SDP(Stable Dependencies Principle),处于底层的模块应该更加稳定。依赖应该从高向低,稳定的组件应该处于被依赖方。稳定性指的是一个组件的变更频率和变更的影响范围。如果一个组件经常发生变更,或者它的变更会对其他组件产生广泛的影响,那么它就是不稳定的。

设想一下 Kotlin 的 String 类,他很少随着语言版本升级而变动,否则会为整个语言带来破坏性变化。通过将依赖关系从不稳定的模块指向稳定的模块,有助于减少软件系统中的不稳定性传播。

如何定义一个模块的稳定性呢? 稳定度的衡量可以看一个模块的扇入依赖度和扇出依赖度两个指标:

  • 扇入(Fan-in):依赖这个模块的反向依赖的数量,这个值越大,说明这个模块的职责越大。
  • 扇出(Fan-out):这个模块正向依赖的其他模块的数量,这个值越大,说明这个模块越不独立,自然越不稳定。
  • 不稳定度:I(Instability) = Fan-out / (Fan-in+Fan-out)

不稳定度越小,说明这个模块越稳定:

  • 当 Fan-out == 0 时,不依赖其他任何模块,但是会被其它更多模块依赖。此时它的 I = 0,是最稳定的模块,我们不希望轻易地改动它,因为它一旦改动了,那么依赖它的其他模块也会受到影响。
  • 当 Fan-in == 0 时,模块不被其他任何模块依赖,但是会依赖其他模块。此时它的 I = 1,是最不稳定的模块,它所依赖的模块的改动都可能影响到自身,但是它自身可以自由地改动。

SAP - 稳定抽象原则

SDP 要求稳定组件不能被频繁修改。那当我们需要对稳定组件增加功能时怎么办呢? 基于 OCP 开闭原则,我们可以通过扩展来避免直接修改。

如何提升模块的可扩展性?当一个模块主要由接口和抽象类构成时,它更容易扩展。

当一个模块充满了接口时,当你需要添加新的功能时,你只需要为其中一个抽象提供一个新的具体实现即可。这样做可以避免你修改稳定模块的源代码,只是为了适应你的模块,并潜在地破坏其他依赖模块。

SAP(Stable Abstractions Principle )告诉我们:稳定模块应该比具体模块更抽象,以提供更大的灵活性,而不稳定模块应该比抽象模块更具体,以便于代码的变更。

虽然稳定模块可以非常抽象以允许灵活性,但一个完全抽象的模块是无用的,因为它没有实际的逻辑可以重用。这是通常需要一个包含完全具体定义的模块作为实现,例如 Android 的组件化架构中,经常有单独的 interface 层作为前天模块的公共依赖,提供模块间通信。

我们可以用抽象度公式量化模块的抽象程度

  • Nc:模块中类的数量
  • Na:模块中抽象类和接口的数量
  • A:抽象程度, A = Na / Nc

A 的取值范围从 0 到 1,值越大表示模块的抽象化程度越高。

痛苦区与无用区

模块的不稳定度(I)和抽象度(A)存在下图这样的关系:

纵轴为 A 值(数值越大越抽象),横轴为 I 值(数值越大越不稳定)。一个合理的组件应该尽量靠近主序列(Main Sequence),我们用 Distance from the Main Sequence (D) 来评价组件的抽象度与稳定性之间的平衡关系:D = abs((A+I) - 1)。这个值越小,说明这个组件的抽象度与稳定性是越平衡的。位于(A = 1, I = 0)的组件是极度稳定并完全抽象,位于(A = 0,I = 1)的组件是完全具象且极度不稳定的组件。

位于坐标左下角的组件由于其稳定性要求高不能被随意修改,但是由于其代码抽象度很低又无法通过扩展进行修改,一旦有升级要求只能修改自身。这种既需要修改又不能修改的矛盾使得这个区域被称为痛苦区(Zone Of Pain)

位于坐标右上角的组件,不稳定度很高意味着没有被其他组件依赖,所以在这部分做任何抽象都是无意义的,因此也被称为无用区(Zone Of Useless)

一个健康的组件应该尽量远离痛苦区和无用区,并尽量靠近主序列。

3. 如何模块划分

上面介绍了一些理论知识,指导我们达成模块的高内聚低耦合。下面我们落地到 Android 项目,看一些具体实践。首先一个常见的课题是如何组织我们工程目录。常见的流派有:基于层级划分、基于业务划分,以及基于组件划分等

基于层级划分

In package by layer, you split the codebase into three broad modules, one for each layer.

按照表现层-领域层-数据层这样的层级划分目录,虽然很容易实施,但违反了上面提到的大部分原则。

一个功能可能横跨多个层级,当你开发新功能时,很可能需要修改所有的模块。而且模块也容易变得非常庞大,因为它们将包含应用程序中所有功能的层逻辑。

基于层级的划分方式曾经非常流行,因为市面上有很多介绍架构的文章对这种方式倍加推崇,认为层次结构(表示层-领域层-数据层)应该决定项目中模块的结构。但这些文章的作者很可能没读过或者没读懂《整洁架构》。

有人会反驳,如果我想将一个数据库替换为另一个数据库怎么办?放在同一模块中,不是更好修改吗?但实际上这并不见得更友好。

首先,更改数据库并不是你日常工作的一部分。这种情况可能发生多年,但绝对不会每周发生一次。对于 Android 可能有的团队会将 Sqlite 更换为 Realm,又将 Realm 更换为 Room。但整体而言仍然是非常低频和罕见的。而功能迭代才是我们的日常,从二八原则出发,我们肯定首先服务于百分之八十的场景需要。

其次,一次性完全更换数据库是一个糟糕的想法。更好的方法是逐步将数据迁移到新的数据库,这样你可以逐步发布迁移并限制可能出现的错误数量。

基于业务划分

In package by feature, you split the codebase into feature modules, one for each feature.

基于业务划分有很多优势,是近年来一直是最受推荐的方法,有许多好处:

  • 当你开发一个业务功能时,只需要改变一个模块,这对于维护来说是最优化的。
  • 当你打开项目工程时,你可以通过项目目录清楚地知道这个项目在做什么的
  • 每个跨职能团队都可以独立地开发一个功能,而不会干扰其他团队。
  • 独立的团队也意味着独立的模块,所以你可以充分利用 Gradle 的并行编译,这将减少你的整体编译时间,而不仅仅要求你重新编译那个发生变化的功能。
  • 每个模块下依然可以按照层级进行划分,所以可以兼具层级划分的优势

这种方式在代码维护方面看起来是最优的,但其实也有问题,业务模块缺乏可复用性:

  • 业务代码本身并不稳定,如果一个业务模块需要复用另一个业务模块的一些代码,这违反稳定依赖原则(SDP),而且业务模块往往包含大量 UI 代码,而 UI 代码是非常具体的,这也违反了稳定抽象原则(SAP)。
  • 业务模块包含表示层、领域层和数据层的逻辑,导致模块变得庞大(共同重用原则违规),其中包含的代码经常变化(UI)以及很少变化的代码(业务逻辑)。
  • 业务与业务之间很难有明确的稳定性划分,导致依赖容易成环。为了解环,很容易催生超大的业务模块,逐渐让项目变为一个单体架构。

所以按业务划分的方式在非 UI 密集型的项目中可能效果比较好。但无法完全在 Android 这样的 UI 密集型项目中套用。例如一个电商应用,在商品列表页中允许将产品添加到购物车或者愿望清单。商品模块所依赖的愿望清单模块以及购物车模块都有自己的 UI 和自己的页面

基于领域划分

In package by component, you split the codebase into UI modules and component modules (domain + data layer of a feature).

我们知道 UI 的存在是阻碍基于业务划分方式的主要问题,而剥离 UI 的业务可以形成比较稳定的领域服务。基于领域划分,要求对代码库进行垂直和水平两个方向的拆分,以弥补按业务划分方式的可复用性。

领域层可以被表现层复用,领域层也起到隔离表现层和数据层的目的,进一步实现 UI 和 Data 的关注的点分离。像前面提到的该数据库之类的事情,不会影响到 UI 层。

回到前面产品详情页的例子。如果我有一个购物车组件模块、一个愿望清单组件模块和一个产品详情页面(PDP)UI 模块,我现在可以在不依赖于任何 UI 细节的情况下重用购物车和愿望清单的代码。

如果产品团队希望在愿望清单页面中引入“添加到购物车”功能,我们只需将购物车组件模块作为依赖添加到愿望清单 UI 模块即可。

按领域划分,UI 模块之间不再需要相互依赖,而 UI 模块往往是最不稳定的,这可以最大程度地减少重新参与编译的数量。从而改善整体的编译时间。

如果 UI 层有共享的代码,例如一些公共 UI 组件,或者领域层之间有一些共享代码,例如网络请求的工具等,这些共享内容放在哪里呢?

我们可以像上图这样,设置专门的 shared 目录,存放这些公共代码。

如果我想在页面之间实现导航,这可能涉及多个 UI 模块的互相依赖。这是我们在各个模块定义所需的导航接口,如下

interface PDPNavigator {
    // you can adapt for fragments, navigation component, compose....
    fun navigateToCart(activity: Activity) 
    fun navigateToWishlist(activity: Activity)
}

然后在 app 模块实现这些接口,完成导航逻辑。因为 App 模块可以依赖各个 UI 模块的具体跳转方法

class AppNavigator: PDPNavigator, WishlistNavigator, CartNavigator.... {
    override fun navigateToCart(activity: Activity) {
        //...
    }

    override fun navigateToWishlist(activity: Activity) {
        //...
    }

}

各个 UI 模块等待 app 注入 XXXNavigator 即可,这相当于底层的 UI 模块依赖上层的 App 模块提供导航,也是一种 DIP 的体现。

4. 如何封装模块

OOP 有封装性,同理,如果我们模块中的代码,每个类或接口都是公开的,那么就破坏了模块整体的封装性。public 作为修饰符,只应该用于那些意图在模块外部使用的类或接口,其他所有内容都应该是 internal

有些意识好的开发人员,会使用 IDE 的 “find usage” 来检查这些类是否在模块外部使用。但很多人如果不注意,就会添加不必要的 public,容易让其他人员误以为这些代码都需要被其他模块复用,并且会害怕修改代码。

好的封装让我们可以降低模块之间不必要的耦合。例如如果我们将数据层和领域层放在一个模块中,数据层的代码就不能是 public 的,这样才能达到领域层解耦 UI 和 Data 的目的。

封装问题并不仅体现在代码中的 public/inernal 修饰符,与 Gradle 传递依赖也有关。当你的一个依赖项使用 api 而不是 implementation 时,就会发生这种情况,导致传递依赖泄漏。理想情况下,你应该始终使用 implementation,这样可以避免依赖泄漏和额外的编译时间。

在按照领域划分模块的方式下,对封装的处理相当简单,因为可以 public 的文件数量可控,应该公开的只有 UseCase 的接口和需要在模块外部使用的 Model 类。

UseCase 实现、存储库接口、存储库的实现、DTO 等都应该是 internal 的,因为表现层不应该访问它们。

领域模块是稳定的,符合 SDP 原则。通过只公开 UseCase 的接口而非实现,我们也符合了 SAP 原则。

在 UI 模块中,可以公开的只有少量的页面级 UI ,用来在更大的页面中组合,例如 FragmentActivityComposable 等。需要注意 UI 模块是不稳定的,因此,我们应该尽量不将它们添加为依赖项,并且只在 App 模块中引入它们,构建更大的页面或者导航。

如果项目中使用 Dagger,那直接用提供依赖的 @Module 应该是 internal 的,这些 Dagger 的 Module 是辅助 Dagger 代码生成的,不需要跨模块访问。

// Dagger module for a Wishlist Component Module
@Module
@InstallIn(SingletonComponent::class) // Or any other scope
internal object WishlistComponentModule {

    @Provides
    fun provideAddToWishlistUseCase(
        addToWishlistUseCaseImpl: AddToWishlistUseCaseImpl
    )
: AddToWishlistUseCase = addToWishlistUseCaseImpl

    @Provides
    fun provideGetWishlistUseCase(
        getWishlistUseCaseImpl: GetWishlistUseCaseImpl
    )
: GetWishlistUseCase = getWishlistUseCaseImpl

    @Provides
    fun provideWishlistRepository(
        wishlistRepositoryImpl: WishlistRepositoryImpl
    )
: WishlistRepository = wishlistRepositoryImpl

    //...

}

// Dagger module for a Wishlist UI Module
@Module
@InstallIn(ActivityComponent::class) // Or any other scope
internal object WishlistUIModule {

    @Provides
    fun provideSomeDependency(
        someDependencyImpl: SomeDependencyImpl
    )
: SomeDependency = someDependencyImpl

    //...

}

如果使用非 Dagger 的依赖注入方式,也需要考虑依赖容器对外提供的 Provide 方法的返回类型最好是接口等可以 public 的,避免 internal 内容的泄露。

5. 如何集成模块

前面讲了我们需要在 app 模块做导航一类的是事情,每个工程都有 app 模块,它是整个应用到入口,可以依赖所以其他模块,因此可以作为集成层,用来创建、调用其他模块。

以下内容适合放在集成层:

  • 连接模块所需的所有“胶水代码”
  • 框架的初始化逻辑
  • 各个 UI 的导航代码
  • 各个模块所需的公共依赖注入

整洁架构中的洋葱图,外层依赖内层,内层稳定抽象,外层为内层提供实现。外层实现的注入就需要集成层来实现,比如提供平台相关的设备能力,提供数据库,网络连接等基本数据能力,提供 UI 用来组件页面或者导航。

尾声

本文用较大篇幅介绍了组件设计原则,因为学校不教授这些内容,但却是每个初阶工程师迈向高阶过程中的必须掌握的知识。Android 官方最新的架构,无论模块的划分、封装还是集成,推荐的做法中都深度参考了整洁架构的设计思想,推荐大家认真学习《整洁架构》一书,可以帮助我们更深入的理解 Android 最新的架构理念。


-- END --


推荐阅读


继续滑动看下一个
AndroidPub
向上滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存