使用整洁架构优化你的 Gradle Module

语言: CN / TW / HK

theme: orange highlight: vs


前言

现代的 Android 项目都是 Gradle 工程,所以大家都习惯于用 Gradle Module 来划分和组织代码,Module 的大量使用也带来一个问题,一个大项目往往几十上百的 Module,但是当数量众多的 Module 之间的依赖关系不合理时,仍然会严重拖慢工程的编译速度,如何更科学地组织 Gradle Module 是 Android 开发领域的普遍需求。

从事 Android 开发的同学可能都听说过 Clean Architecture,即所谓整洁架构。Google 推荐大家使用它对 MVVM 进行更合理的分层。整洁架构的概念出自以下这本书(国内译本:代码整洁之道),关于这本书以及作者 Bob 大叔的大名这里就不多介绍了,说这是软件架构方面的圣经也不为过。

除了优化 MVVM 这样的业务架构,这本书在组件设计方面也产出了不少最佳实践和方法论,可用来优化 Gradle 这样的工程架构。本文就来讨论如何基于整洁架构中的各种设计原则来设计我们的 Gradle Module。

文章目录如下:

  • Module 粒度划分
  • 复用发布等价原则(REP)
  • 共同封闭原则(CCP)
  • 共同复用原则(CRP)
  • 三原则的权衡
  • Module 依赖关系
  • 无环依赖原则(ADP)
  • 稳定依赖原则(SDP)
    • 稳定度公式
  • 稳定抽象原则(SAP)
    • 抽象度公式
  • 不稳定度与抽象度的关系
    • 痛苦区与无用区
  • 总结


Module 粒度划分

参考 Clean Architecture 中对组件的定义:

组件是软件的部署单元,是整个软件系统在部署过程中可以独立完成部署的最小实体。例如,对于 Java 来说,它的组件是 jar 文件。而在 Ruby 中,它们是 gem 文件。在 .Net 中,它们则是 DLL 文件。

Android 中 Gradle Module 是发布 JAR 或者 AAR 的基本单元,因此 Module 可以看作是一个组件,在 Module 粒度划分上,我们套用书中关于组件划分的三个原则:

  1. 复用发布等价原则(Release Reuse Equivalency Principle)
  2. 共同封闭原则(The Common Closure Principle)
  3. 共同复用原则(The Common Reuse Principle)

复用发布等价原则(REP)

软件复用的最小粒度应等同于其发布的最小粒度

REP 告诉我们 Module 划分的一个基本原则就是代码的可复用性,当一些代码有被复用的价值时,它们就应该被考虑拆分为 Module,可用来独立发布。此外 REP 还要求我们注意 Module 不能拆分过度。当我们发布 AAR 时都需要为其设定发布版本号,其中一个重要原因是如果不设定版本号就无法保证被组件之间能够彼此兼容。这在 androidx 系列组件中尤为突出,我们经常遇到因为版本不一致造成的运行时问题,产生这种不一致的一个重要原因就是,组件的拆分过度。

如果两个可以独立发布的组件,它们总是作为一个整体被复用,就会出现可复用的粒度大于可发布粒度的问题,也就增大了版本冲突的概率, 此时可以考虑将它们合二为一,同时发布、避免版本不一致。

在小团队中这个问题不突出,因为通过人为约定可以保证所以组件同时发布,但是在跨团队的大型项目中,如果一个功能的升级总是要多个团队一起配合,那沟通成本是难以忍受的。

共同封闭原则(CCP)

组件中的所有类对于同一种性质的变化应该是共同封闭的,即一个变化的影响应该尽量局限在单个组件内部,而避免同时影响多个组件,我们应该将那些会因为相同目的而同时修改的类放到同一个组件中,而将不会为了相同目的同时修改的那些类放到不同的组件中。

相对于 REP 关注的可复用性,CCP 强调的是可维护性,很多场景下可维护性相对于可复用性更加重要。CCP 要求我们将所有可能被一起修改的类集中在一起,两个总是被一起修改的类应该放入同一组件。这和大家熟知的 SOLID 设计原则中的 SRP (单一职责)很类似,SRP 要求总是一起修改的函数应该放在同一个类,CCP 可以看作是组件版本的 SRP。

顺便一提:SOLID 设计原则也是出自 Clean Architecture 一书,SOLID 针对的是 OOP 的类和接口,而本文讨论的是更大粒度的组件。

有的人在 Android 项目中喜欢按照代码的功能属性划分 Module,比如一个 MVVM 架构的工程,目录可能如下划分: + UI + Logic + Repository + API + DB

但实际开发中,很少只修改 UI 或者只修改 Logic,大多是围绕某个 feature 进行垂直修改,这种跨 Module 修改显然违反了 CCP 原则,因此以业务属性为单位来进行 Module 划分可能更加合理,比如一个短视频应用的目录结构应该是 + VideoPlay + ui + data + ... + VideoCreation + Account + ... 这样,我们的修改可以在单个组件中闭环完成,在 Gradle 编译中,减少受影响的模块,提升编译速度。

共同复用原则(CRP)

组件中的类应该同时被复用,即组件中不要依赖不参与复用的类

REP 要求我们将紧密相关的类放在一个组件中一同发布,而 CRP 要求我们强调的是不要把不相关的类放进来,要用大家就一起用。要注意所谓“共同”复用并不意味着所有的类都能被外部访问,有些类可能是服务于内部其他类的,但也是必不可少的。我们虽然不希望组件过度拆分,但是同时要求组件的类不能过度冗余,不应该出现别人只需要依赖它的某几个类而不需要其他类的情况。

+ VideoPlay + VideoCreation + Account + ...

比如 VideoCreation 承载了短视频创作相关的功能,短视频创作链路分为拍摄和编辑两部分,某些场景下用户通过相册选取素材后直接进入编辑,此时可能不需要拍摄模块,所以拍摄和编辑两个模块共存一个组件不符合 CRP 原则,当拍摄部分代码发生变动时,会连累那些只依赖编辑模块的组件一同参与编译。此时应该考虑拆分 VideoCreationVideoRecordVideoEdit 两个 Module

CRP 与 SOLID 的 ISP(接口隔离原则)有点像,ISP 指的是对外不暴露不需要的接口,从这点来看,CRP 可以称为组件版的 ISP。

三原则的权衡

上述三个原则有着互斥关系,REP 和 CCP 是粘合性原则,告诉我们哪些类要放在一起,这会让组件变得更大。CRP 是排除性原则,不需要的类要从组件中移除出去,这会使组件变小。组件设计的重要任务就是在这三个原则之间做出均衡

REP,CCP,CRP 的中心思想都是追求组件内部合理的内聚性,但是它们的侧重点不同,三者很难同时兼顾,如果考虑不周会落入按下葫芦浮起瓢的窘境。如果只遵守 REP、CCP 而忽略 CRP ,就会依赖了太多没有用到的组件和类,而这些组件或类的变动会导致你自己的组件进行太多不必要的发布;遵守 REP 、CRP 而忽略 CCP,因为组件拆分的太细了,一个需求变更可能要改 n 个组件,带来的成本也是巨大的,如果只遵守 CCP 和 CRP 而忽略 REP 可能因为组件的能力太过于垂直而牺牲了底层能力的可复用性

Gradle Module 粒度如何划分很难找到一个普适的结论,应该综合项目类型、项目阶段等各种因素,在三原则中做出取舍和权衡。例如在项目早期我们更加关注业务开发和维护效率,所以 CCP 比 REP 更重要,但随着项目的发展,可能就要考虑底层能力的可复用性,REP 变得重要起来,随着项目的持续迭代, 组件能力越发臃肿,此时需要借助 CRP 对组件进行合理的拆分和重构。


Module 依赖关系

在粒度划分上我们追求的是组件如何保持合理的内聚性,组件间依赖关系的梳理有助于更好地维持外部的耦合。 Clean Architecture 中关于组件耦合设计也有三个原则:

  1. 无环依赖原则(The Acyclic Dependencies Principle)
  2. 稳定依赖原则(The Stable Dependencies Principle)
  3. 稳定抽象原则(The Stable Abstractions Principle)

无环依赖原则(ADP)

组件依赖关系图中不应该出现环,关系图应该必须是一个有向无环图(DAG) Module 之间出现环形依赖会扩大组件变更带来的影响范围,增加整体编译成本。

比如 A -> B -> C -> A 这样的环形依赖中,由于 C 依赖了 A ,B 又依赖了 C,A 的变化对 B ,C 都会带来影响,依赖环中的任何一点发生变更都会影响环上的其他节点。设想一下如果没有 C -> A 的依赖,C 的变化只会影响 B,B 只会影响 A,A 的变化将不再影响任何人。

所幸我们不必担心 Gradle 中出现环形依赖的 Module。Gradle 需要根据 Module 依赖关系决策编译顺序,如果 Module 之间有环存在,Gradle 在编译期会报错提醒,因此我们需要关心的是发现环形依赖后该如何解决,消除环形依赖一般有两种方法:

  1. 依赖倒置

借助 SOLID 中的 依赖倒置原则(DIP),把 C > A 的依赖内容,抽象为 C 中的接口,C 面向接口编程,然后让 A 实现这些接口,依赖关系发生反转

  1. 增加组件

新增 D 组件,C > A 的依赖部分下沉到 D ,让 C 和 A 共同依赖 D ,类似于中介者设计模式。

当然,这种方式如果滥用会导致工程的组件膨胀,所以是否真的要从 A 中下沉一个 D 组件,还要结合前文介绍的 REP 和 CCP 原则综合考虑。

稳定依赖原则(SDP)

依赖关系要趋于稳定的方向,例如 A 依赖 B,则被依赖方 B 应该比依赖方 A 更稳定。

SDP 原则很好理解,如果 A 是一个公共组件需要保持较高稳定度,而它如果依赖一个经常变更的组件 B,则会因为 B 的变更变得不稳定,若要保证 A 的稳定 B 的修改就会变得畏手畏脚,难以进行。 一个预期会经常变更的组件是一个不稳定的组件,这个定义过于太主观,如何客观衡量一个组件的稳定性呢?

稳定度公式

稳定度的衡量方式可以看一个组件依赖了多少组件(入向依赖度)和被多少组件所依赖(出向依赖度)这两个指标: - 入向(Fan-in):依赖这个组件的反向依赖的数量,这个值越大,说明这个组件的职责越大。 - 出向(Fan-out):这个组件正向依赖的其他组件的数量,这个值越大,说明这个组件越不独立,自然越不稳定。 - 不稳定度:I(Instability) = Fan-out / (Fan-in+Fan-out)

这个值越小,说明这个组件越稳定: - 当 Fan-out == 0 时,这个组件不依赖其他任何组件,但是有其他组件依赖它。此时它的 I = 0,是最稳定的组件,我们不希望轻易地改动它,因为它一旦改动了,那么依赖它的其他组件也会受到影响。 - 当 Fan-in == 0 时,这个组件不被其他任何组件依赖,但是会依赖其他组件。此时它的 I = 1,是最不稳定的组件,它所依赖的组件的改动都可能影响到自身,但是它自身可以自由地改动,不对其他组件造成影响

注意:入向、出向有时也被称为反向依赖度(Ca)和正向依赖度(Ce),本质是一回事: https://en.wikipedia.org/wiki/Software_package_metrics

仍然以短视频应用的工程结构为例:

+ app #宿主 + play #视频播放 + ui + data + creation #视频创作 + ui + data + common #公共能力 + db + net + camera + cache + infra #基础框架等

  • infra 的不稳定度 ```
  • Fan-in = 5
  • Fan-out = 0
  • I = 0/(5+0) = 0 ```

不稳定度 0 ,infra 是一个极为稳定的 Module,它不能擅自改动

  • app 的不稳定度 ```
  • Fan-in = 0
  • Fan-out = 3
  • I = 3 / ( 0 + 3 ) = 1 `` 不稳定度 1,app` 是一个极度不稳定的 Module,任何一个 Module 的变动都会影响它。

一个相对健康的工程结构,箭头的走势一定是符合 SDP 的原则,从不稳定的组件流向稳定的组件,app 和 infra 在整体结构中符合这一原则。从 Module 内部来看 :ui 的不稳定度高于 :data 也符合 UI 侧需求更容易变更的客观显示。

再分析一下 VideoPlayVideoCreation 这两个 Module 的依赖关系, 假设此应用为了鼓励用户创作在视频播放时增加了创作入口,所以 VideoPlayVideoCreation 产生依赖

  • VideoPlay 不稳定度 ```
  • Fan-in = 1
  • Fan-out = 2
  • I = 2 / (1+2) = 0.66 ```
  • VideoCreation 不稳定度 ```
  • Fan-in = 2
  • Fan-out = 5
  • I = 5 / ( 2+5) = 0.7 ``VideoCreatioin的不稳定度反而略高于VideoPlay,这是与 SDP 原则相违背的。在产品层面为了迎合需求我们让VideoPlay直接依赖了VideoCreation` ,但是在工程层面这并非一个好设计,关于解决方案可以参考后文内容。

稳定抽象原则(SAP)

一个组件的抽象化程度应该与其稳定性保持一致,越稳定的组件应该越抽象。

SOLID 中最核心的当属开闭原则(OCP):代码应该对扩展开放,对修改关闭。我们常常通过面向抽象类/接口编程的方法去实践 OCP,即用抽象构建框架,用实现扩展细节。抽象层包含程序的基础协议和顶层设计等,这些代码不应该经常变更,所以应该放在稳定组件(I=0)中,而不稳定组件(I=1)中适合放那些能够快速和方便修改的实现部分。

SAP 为组件的稳定性和抽象化程度建立了一种关联,稳定组件需要变更时应该避免修改自己,而是通过其派生类的扩展来实现变更,这就要求稳定组件具备良好的抽象能力。而至于抽象类的实现部分,应该从稳定组件中剥离,放到不稳定组件中,这样可以无压力的对其代码进行修改而不必担心影响他人。

抽象度公式

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

A 的取值范围从 0 到 1,值越大表示组件内的抽象化程度越高,0 代表组件中没有任何抽象类,1 代表组件只有抽象没有任何实现。

如上图中,由于 infra 处于极度稳定状态,它应该有与之匹配的抽象化程度。以数据层的能力为例,我们将 infra 中所有的关于数据层的实现抽离到 Common,只留下抽象接口,其他 Module 的 :data 只依赖依赖稳定的 infra,而 app 负责全局注入 dbnetcache 等具体实现。

此外,由于 VideoCreation 中没有剥离抽象和实现,对 VideoCreation 实现的修改可能会破坏其应有的稳定性。

基于 SAP 原则,我们新增一个高度抽象化的 creation:api,它具有高稳定性和高抽象度,而 VideoCreation 的稳定性降低,负责同时为 api 提供具体实现。

不稳定度与抽象度的关系

组件的不稳定度(I)和抽象度(A)关系可见下图:

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

痛苦区与无用区

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

比如之前例子中的 Common 部分,如果作为公共模块被直接依赖、需要具备极高的稳定性,但是由于其内部充满具体实现,当我们要升级 db 或者 net 等公共库时由于影响范围太大往往需要对程序进行全面回归测试。所以我们不不允许 Common 被过多地依赖,降低其稳定性,也降低了其发生变更时的负担,当发生变更时,只要针对其依赖的 infra 接口完成单元测试即可,避免了回归测试成本。

位于坐标右上角的组件,不稳定度很高意味着没有被其他组件依赖,所以在这部分做任何抽象都是无意义的,因此也被称为无用区(Zone Of Useless)。一般这种代码都是历史原因造成的,例如我们经常看到某个角落里遗留了一些没有被实现的抽象类,像这样的无用代码应该被移除。

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


总结

最终总结之前,再看一下我们这个短视频应用经过整洁架构优化之后的效果

除了前文叙述过的通过新增 creation:api,让 VideoPlay 的稳定性和抽象度趋于一致以外,我们还对 camera 的位置做了调整,调整前的 camera 处于 Common 中,但它的修改比较独立且仅仅被 VideoCreation 所依赖,首先这不符合 CRP 原则,其次 camera 经常伴随 VideoCreation 的需求而升级,也不符合 CCP 的要求,因此我们把 cameraCommon 抽出并移动到 VideoCreation

最后我们还新增了一个 creation:common。由于 VideoCreation 中有多处对 infra 的依赖,虽然 infra 是极度稳定的组件,但是作为外部组件仍然不可信赖,一旦 infra 发生变动对 VideoCreation 的稳定性造成影响,我们新增 creation:common 收敛对 infra 的依赖,提升 VideoCreation 的稳定性。 优化之后各组件的不稳定度如下表:

app - Fan-in = 0 - Fan-out = 7 - I = 7 / (0 + 7) = 1 VideoCreation - Fan-in = 1 - Fan-out = 2 - I = 2 / (1 + 2)= 0.66 VideoPaly - Fan-in = 1 - Fan -out = 1 - I = 1 / (1 + 1) = 0.5 Common - Fan-in = 3 - Fan-out = 3 - I = 3 / (3 + 3) = 0.5 infra - Fan-in = 6 - Fan-out = 0 - I = 0 / (0 + 6) = 0

不稳定度(I)逐层递减,抽象度也随之逐渐递增。文章中的例子十分简单,肯定有人会觉得这种程度的优化仅凭直觉就可完成,没必要套用公式。但是实际项目往往要复杂得多,了解这些公式能够在复杂场景中发挥引导作用,避免我们迷失方向。

最后做一个总结,Gradle Module 作为 Android 工程的组件单元,我们可以基于整洁架构中关于组件设计的原则对其进行治理: 1. 所有且仅有紧密相关的类或模块应该放入同一组件 2. 因为同样目的需要同时修改的组件应该尽量放到一起 3. 组件粒度应该如何划分,需要根据实际情况进行权衡 4. 组件之间不应该存在循环依赖,可以通过依赖倒置或者增加组件的方式解决 5. 被依赖的组件总是比依赖它的组件更稳定 6. 组件的稳定性和抽象度应该保持一致,越稳定的组织抽象度越高