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

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

前言

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

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

image.png

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

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 可能因为组件的能力太过于垂直而牺牲了底层能力的可复用性

image.png

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 之间出现环形依赖会扩大组件变更带来的影响范围,增加整体编译成本。

image.png

比如 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 实现这些接口,依赖关系发生反转

image.png

  1. 增加组件

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

image.png

当然,这种方式如果滥用会导致工程的组件膨胀,所以是否真的要从 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),本质是一回事: en.wikipedia.org/wiki/Softwa…

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

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

image.png

  • 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 代表组件只有抽象没有任何实现。

image.png

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

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

image.png

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

不稳定度与抽象度的关系

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

image.png

纵轴为 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)。一般这种代码都是历史原因造成的,例如我们经常看到某个角落里遗留了一些没有被实现的抽象类,像这样的无用代码应该被移除。

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


总结

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

image.png

除了前文叙述过的通过新增 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. 组件的稳定性和抽象度应该保持一致,越稳定的组织抽象度越高
目录
相关文章
|
1月前
|
消息中间件 存储 缓存
十万订单每秒热点数据架构优化实践深度解析
【11月更文挑战第20天】随着互联网技术的飞速发展,电子商务平台在高峰时段需要处理海量订单,这对系统的性能、稳定性和扩展性提出了极高的要求。尤其是在“双十一”、“618”等大型促销活动中,每秒需要处理数万甚至数十万笔订单,这对系统的热点数据处理能力构成了严峻挑战。本文将深入探讨如何优化架构以应对每秒十万订单级别的热点数据处理,从历史背景、功能点、业务场景、底层原理以及使用Java模拟示例等多个维度进行剖析。
54 8
|
10天前
|
弹性计算 运维 监控
阿里云云服务诊断工具:合作伙伴架构师的深度洞察与优化建议
作为阿里云的合作伙伴架构师,我深入体验了其云服务诊断工具,该工具通过实时监控与历史趋势分析,自动化检查并提供详细的诊断报告,极大提升了运维效率和系统稳定性,特别在处理ECS实例资源不可用等问题时表现突出。此外,它支持预防性维护,帮助识别潜在问题,减少业务中断。尽管如此,仍建议增强诊断效能、扩大云产品覆盖范围、提供自定义诊断选项、加强教育与培训资源、集成第三方工具,以进一步提升用户体验。
656 243
|
4天前
|
机器学习/深度学习 算法 数据可视化
基于深度混合架构的智能量化交易系统研究: 融合SSDA与LSTM自编码器的特征提取与决策优化方法
本文探讨了在量化交易中结合时序特征和静态特征的混合建模方法。通过整合堆叠稀疏降噪自编码器(SSDA)和基于LSTM的自编码器(LSTM-AE),构建了一个能够全面捕捉市场动态特性的交易系统。SSDA通过降噪技术提取股票数据的鲁棒表示,LSTM-AE则专注于捕捉市场的时序依赖关系。系统采用A2C算法进行强化学习,通过多维度的奖励计算机制,实现了在可接受的风险水平下最大化收益的目标。实验结果显示,该系统在不同波动特征的股票上表现出差异化的适应能力,特别是在存在明确市场趋势的情况下,决策准确性较高。
21 5
基于深度混合架构的智能量化交易系统研究: 融合SSDA与LSTM自编码器的特征提取与决策优化方法
|
15天前
|
存储 机器学习/深度学习 人工智能
【AI系统】计算图优化架构
本文介绍了推理引擎转换中的图优化模块,涵盖算子融合、布局转换、算子替换及内存优化等技术,旨在提升模型推理效率。计算图优化技术通过减少计算冗余、提高计算效率和减少内存占用,显著改善模型在资源受限设备上的运行表现。文中详细探讨了离线优化模块面临的挑战及解决方案,包括结构冗余、精度冗余、算法冗余和读写冗余的处理方法。此外,文章还介绍了ONNX Runtime的图优化机制及其在实际应用中的实现,展示了如何通过图优化提高模型推理性能的具体示例。
45 4
【AI系统】计算图优化架构
|
5天前
|
机器学习/深度学习 前端开发 算法
婚恋交友系统平台 相亲交友平台系统 婚恋交友系统APP 婚恋系统源码 婚恋交友平台开发流程 婚恋交友系统架构设计 婚恋交友系统前端/后端开发 婚恋交友系统匹配推荐算法优化
婚恋交友系统平台通过线上互动帮助单身男女找到合适伴侣,提供用户注册、个人资料填写、匹配推荐、实时聊天、社区互动等功能。开发流程包括需求分析、技术选型、系统架构设计、功能实现、测试优化和上线运维。匹配推荐算法优化是核心,通过用户行为数据分析和机器学习提高匹配准确性。
26 3
|
1月前
|
监控
SMoA: 基于稀疏混合架构的大语言模型协同优化框架
通过引入稀疏化和角色多样性,SMoA为大语言模型多代理系统的发展开辟了新的方向。
44 6
SMoA: 基于稀疏混合架构的大语言模型协同优化框架
|
23天前
|
监控 Serverless 云计算
探索Serverless架构:开发实践与优化策略
本文深入探讨了Serverless架构的核心概念、开发实践及优化策略。Serverless让开发者无需管理服务器即可运行代码,具有成本效益、高可扩展性和提升开发效率等优势。文章还详细介绍了函数设计、安全性、监控及性能和成本优化的最佳实践。
|
26天前
|
弹性计算 运维 开发者
后端架构优化:微服务与容器化的协同进化
在现代软件开发中,后端架构的优化是提高系统性能和可维护性的关键。本文探讨了微服务架构与容器化技术如何相辅相成,共同推动后端系统的高效运行。通过分析两者的优势和挑战,我们提出了一系列最佳实践策略,旨在帮助开发者构建更加灵活、可扩展的后端服务。
|
26天前
|
消息中间件 运维 Cloud Native
云原生架构下的微服务优化策略####
本文深入探讨了云原生环境下微服务架构的优化路径,针对服务拆分、通信效率、资源管理及自动化运维等核心环节提出了具体的优化策略。通过案例分析与最佳实践分享,旨在为开发者提供一套系统性的解决方案,以应对日益复杂的业务需求和快速变化的技术挑战,助力企业在云端实现更高效、更稳定的服务部署与运营。 ####
|
1月前
|
存储 负载均衡 监控
如何利用Go语言的高效性、并发支持、简洁性和跨平台性等优势,通过合理设计架构、实现负载均衡、构建容错机制、建立监控体系、优化数据存储及实施服务治理等步骤,打造稳定可靠的服务架构。
在数字化时代,构建高可靠性服务架构至关重要。本文探讨了如何利用Go语言的高效性、并发支持、简洁性和跨平台性等优势,通过合理设计架构、实现负载均衡、构建容错机制、建立监控体系、优化数据存储及实施服务治理等步骤,打造稳定可靠的服务架构。
33 1