我正在参加「启航方案」

Android“真实的”模块化

模块化背后的准则概述

Android“真正的”模块化

“假如说SOLID准则告知咱们怎么将砖块排列成墙和房间, 那么组件准则则告知咱们怎么将房间排列成建筑.” ~ Robert C. Martin, Clean Architecture

你应该分层打包仍是分特性打包?还有其他办法吗?

怎么提高项意图编译时刻?

你的工程师如安在跨功用的团队中独立作业?

经过这篇文章, 我旨在经过扩展我之前关于SOLID准则的文章来答复这些问题.

目录
组件内聚准则
组件耦合准则
封装规划解决方案
封装
主组件

简介

SOLID准则能够验证和检测类或接口的代码缺陷, 而组件准则能够验证和检测组件的代码缺陷.

什么是组件?

组件是一组文件(类, 接口, 函数文件, Android资源等), 运用以下战略之一进行分组:

  1. 源代码等级(单体结构):在Java/Kotlin中, 咱们运用包
  2. 二进制/部署层面:在Java/Kotlin中, 咱们运用生成”jars”或 “aars”的模块
  3. 服务层面:这将是一个服务或一个微服务, 通讯经过网络数据包进行.

一般状况下, 你不会只运用一种战略. 你根据你的需求混合这些战略.

*由于咱们在Android中没有服务, 这篇文章将专注于Java/Kotlin模块. *

什么是 “好的模块化”?

一个”好的模块化”是一个组件的结构, 其间的模块是高内聚和低耦合的.

咱们怎么说模块是高内聚的呢? 咱们又怎么说模块是低耦合的呢?

当模块遵守组件内聚的准则时, 它们便是高内聚的.

当模块遵守组件耦合准则时, 它们便是低耦合的.

组件内聚准则

SOLID准则是清洁架构的根底, 能够在模块层面进行调整, 从而构成一套新的准则(REP, CCP和CRP).

通用闭合准则(CCP)

“将那些因相同原因和相同时刻产生改动的类聚集到组件中.

将那些在不同时刻和不同原因产生改动的类分离成不同的组件”. – 其他全部引文由罗伯特-C-马丁(Robert C. Martin)撰写, Clean Architecture.

CCP是SRP在模块层面的演化, 正如我在之前的文章中解说的那样.

*一个类不该该由于不同的原因而改动 -> 一个组件不该该由于不同的原因而改动. *

因相同原因而改动的类应该被归入一个组件, 而因不同原因而改动的类应该被移出组件.

可保护性比可重用性更重要:每逢你做一个新的功用, 或许有一个需求改动时, 你宁可只碰一个模块, 也不碰许多模块.

当咱们只需求改动一个模块时, 咱们就不太或许影响到其他团队成员, 并且咱们需求从头编译, 从头验证和从头部署的组件也比较少.

总是把全部或许的改动都归入一个模块是不实践的(除非你在作业中运用单片机), 所以这个准则的方针是尽量削减需求改动的模块的数量.

  1. 长处:对保护来说是最抱负的, 由于改动的影响最小.
  2. 缺陷:开发和保护模块的最佳办法或许不是向图书馆用户发布模块的最佳办法. 其他, 模块往往会比较大, 以隔离需求改动的模块数量.

通用重复运用准则(CRP)

“不要强迫组件的用户依托他们不需求的东西”.

CRP是ISP在模块层面的演化, 我在之前的文章中解说过.

*当接口很小的时分, 你不会依托你不需求的办法—当模块很小的时分, 你不会依托你不需求的文件. *

类很少被孤登时重复运用. 更典型的是, 可重用的类与其他归于可重用的笼统的类协作. CRP指出, 这些类归于同一个组件中.

它还指出, 不被一起重用的类不该该被放在同一个组件中.

经过这样做, 对这些类的更新不会触发对不运用它们的模块的从头编译, 从头部署或发布.

  1. 长处:较小的模块, 作为一个模块用户, 你不太或许被你不关心的改动所影响.
  2. 缺陷:更多的模块需求在开发进程中进行处理.

重用/发布等价准则(REP)

“重复运用的颗粒便是发布的颗粒”.

你乐意重用的最小的东西便是你乐意开释的最小的东西.

这对库的开发者来说是一个十分重要的准则.

每逢你想把一个组件提供给别人时, 你就需求有一个发布进程, 为了使你的组件在一段时刻内不损坏你的库用户的代码, 你需求有发布号.

这样做, 库用户就不会有损坏性的改动, 除非他们升级到较新的库版别.

由于你的模块中的全部类都有相同的发布号, 一个单一的类的更新将需求同一模块下的全部类的新发布.

有时, 库是以单个库的形式呈现的, 有时是以一组库的形式呈现的(因而你能够决议导入什么, 扫除什么).

当运用一组库时, 你或许会想到, 由于全部这些模块都被重复运用, 它们都应该有相同的发布号以保证兼容性.

具有相同的版别号意味着当你需求更新一个模块时, 你也需求用更新的版别号发布全部其他的模块(即使这些模块没有改动).

让咱们以Retrofit为例.

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

当Retrofit开发者为Retrofit主库增加新的功用时, 他们很或许也需求使支撑的转换器库与这些新的集成相兼容, 从而提升全部库模块的版别名称.

*这不一定是最好的办法, 特别是当同一组库的模块不是很有凝集力的时分. *

现在让咱们以Firebase为例.

Firebase库在过去有匹配的发布号.

Firebase的问题是, 他们的库组十分不连贯. 想想长途配置库和存储库:这两个库是彻底独立的, 或许由不同的开发团队担任.

两者之一的新集成不该该要求Firebase团队发布另一个库的新版别而不进行修正.

Firebase团队最终做了什么?

他们利用Gradle 5.0对Maven BoM的支撑, 答应将不同库的版别作为一个单一版别来管理.

这样一来, 他们不是为每一个库发布一个新版别, 而是发布一个新版其他BoM.

 // BoM
 implementation platform('com.google.firebase:firebase-bom:$version')
 // modules import without version
 implementation 'com.google.firebase:firebase-core'
 implementation 'com.google.firebase:firebase-config'
 implementation 'com.google.firebase:firebase-storage'

假如不是由于BoM, 他们会运用Google Play Services的办法, 也便是一个无尽的库版别表(最不方便用户运用).

  1. 长处:对重用性来说是最抱负的, 你的模块对其他团队是可用的, 并且版别控制使新的更新更简单管理.
  2. 缺陷:保护代码库更加复杂, 由于现在你需求考虑模块的发布进程.

模块会趋于大型化, 由于这样做能够削减需求发布的模块的数量.

组件耦合张力三角

Android“真正的”模块化

组件耦合张力求

该图显示了当你抛弃一个准则而支撑其他两个准则时会产生什么.

假如还不清楚的话, 组件凝集准则与SOLID准则不同, 它们并不能彼此弥补, 需求你选择对你的项目更重要的东西.

尽管使类易于保护和重用是很简单的, 但对模块来说就不相同了.

CCP和REP是包容性准则;它们倾向于使模块更大, 而CRP是排他性准则, 由于它倾向于使模块更小.

CRP和REP是侧重于重用的准则. 它们倾向于为运用它们的人优化模块, 而CCP则侧重于保护, 由于它倾向于为开发它们的人优化模块.

你不太或许兼顾这三者, 所以你应该预备抛弃或削减对其间一个的重视.

一般状况下, 一个项目归于以下类别之一:

  1. 应用程序:当你正在构建一个应用程序时, 你的首要方针是快速构建东西, 并具有一个快速编译的项目, 将不需求的从头编译降到最低. 假如你归于这个类别, 你应该一向重视CCP和CRP.
  2. 库:当你在构建一个库时, 你的方针不会是静态的. 相反, 它将跟着时刻的推移而改动. 当你开端开发图书馆时, 你的首要重视点应该是快速树立库. 然后, 跟着时刻的推移, 你的重视点应该转移到库的可重用性上, 并对其保护进行退让. 假如你归于这种状况, 在你的项目成熟之前, 你应该把注意力放在三角形的右边, 然后跟着时刻的推移, 你应该转移到左边, 由于现在你会对你的库的用户越来越担任任. 大多数项意图模块化失利是由于工程师对项意图性质优先考虑了错误的准则.

其他项意图模块化失利是由于组件结构是静态的, 而不是跟着需求的改动而开展的.

参考文献

  • Clean Architecture, 第13章(组件耦合)

组件耦合准则

咱们评论了关于模块应该怎么按照耦合准则的理论.

现在咱们需求评论这些模块之间的联系应该是怎样的.

非循环依托准则(ADP)

Android“真正的”模块化

“在组件的依托联系图中不答应有任何循环”.

假如A依托于B, 那么B就不该该依托于A.

这不仅适用于依托联系, 也适用于传递性依托联系: 假如A依托于B, B依托于C, 那么C也不该该依托于A.

有些编译器答应模块中呈现循环, 有些编译器则企图保证这不会产生.

无论运用哪种编译器或言语, 作为开发者, 你需求知道如安在发现依托性循环后当即打破它.

依托性循环能够经过两种方式打破:

  1. 提取类在新模块中从头运用.
  2. 运用依托回转准则(DIP, 是的, 又是SOLID )来”回转”依托联系.

Android“真正的”模块化

当许多模块需求同享逻辑, 并且有许多东西需求同享时, 解决方案1是抱负的.

当只要一个模块需求同享逻辑, 并且没有多少东西需求同享时, 解决方案2是抱负的.

安稳依托准则(SDP)

“在安稳的方向上依托”.

你会让你的模块更依托什么?

一个常常改动的模块仍是一个从不改动的模块?

咱们期望咱们的模块能够依托那些永不改动的模块.

每逢咱们的模块中的依托联系产生改动时, 咱们的模块需求从头编译, 咱们或许不得不处理损坏性的改动.

哪些模块是安稳的?

安稳的模块是那些难以改动的模块.

想想Kotlin的String类, Kotlin团队有多大或许改动这样一个类?假如他们真的改动了它, 那么他们在整个Kotlin言语中会有多少损坏性的改动?这是一个没有头脑正常的开发者会改动的类.

对你来说, 最抱负的状况是你的模块依托于像这样安稳的东西.

不幸的是, 咱们生活在实践国际中, 而不是抱负国际.

咱们运用的大多数模块都不是100%安稳的, 这不一定是坏事.

一个不能改动的模块也不或许永远改进.

不仅如此, 假如模块彻底不能改动, 咱们就永远无法增加新的功用, 由于咱们无法改动代码.

那么, 咱们该怎么从头界说安稳性?

一个模块什么时分才够安稳?

当一个模块的简直不依托其他模块, 而依托于它的模块许多, 从而使它成为一个担任任的模块时, 它便是安稳的.

假如你看一下你的组件依托联系图, 你应该看到在底部是比较安稳的(担任任的)模块, 在顶部是比较不安稳的(依托的)模块.

由于在你的项目中, 你最终会有安稳的和不安稳的模块, 所以黄金法则是, 一个模块应该依托比自己更安稳的模块.

安稳笼统准则(SAP)

“一个组件应该像它的笼统性相同安稳”.

SDP界说了安稳的模块是很难”改动”的.

这意味着向安稳的模块增加新的功用是很难的, 由于你不能容易修正现有的代码…

可是”扩展”呢?我能够扩展安稳的模块吗?

开封准则(OCP, 是的, 又是SOLID )给咱们提供了对扩展敞开, 对修正封闭的类.

我如安在模块层面上移植这种可扩展性?

*当一个模块是笼统的时分, 它就很简单被扩展. 因而, 它首要由接口和笼统类组成. *

当一个模块充满了接口时, 每逢你需求增加新的东西时, 你所需求做的便是为其间的一个笼统提供一个新的详细完成.

这将防止你为了习惯你的模块而触及安稳模块的源代码, 并或许损坏其他依托模块.

安稳的模块应该是笼统的多于详细的, 以便有更多的灵活性, 而不安稳的模块应该是详细的多于笼统的, 以便于改动代码.

尽管你期望安稳的模块十分笼统, 以答应灵活性, 但一个100%笼统的模块是一个无用的模块, 由于没有实践的逻辑能够重复运用.

显然, 一个100%详细化的安稳模块是一个改动起来十分苦楚的模块.

这里的黄金法则是, 一个模块应该依托于它的依托联系的笼统, 而不是详细化.

假如你的类遵守了依托回转准则, 你就应该免费得到这个.

参考文献

  • Clean Architecture, 第14章(组件耦合)

包规划的解决方案

假如你看了上面的六条准则感到无聊, 不必忧虑, 由于现在我要进入风趣的部分了.

现在咱们知道了怎么完成高内聚和低耦合, 是时分评论哪些办法可行, 哪些不行行了.

由于应用程序开发人员是最难完成模块化的, 并且库开发人员一般不需求处理许多的模块, 所以我将只重视怎么完成应用程序的模块化(否则这篇文章会变得更长!).

分层打包

Android“真正的”模块化

在分层打包中, 你把代码库分红三个大模块, 每层一个.

这种办法适当简单做到, 但违反了上述的大部分准则.

每逢你从事一项新的功用时, 你很或许要修正全部的模块.

这样做, 你或许会损坏其他功用的代码, 踩到队友的脚, 并在任何新的迭代中从头编译整个依托图.

模块也会十分大, 由于它们将包括你的应用程序中全部功用的层逻辑.

为什么这种办法如此受欢迎?

假如你从广告炒作开端就一向在读Clean Architecture的文章, 你会注意到大多数作者在评论模块化时, 不断推进分层打包的办法, 以为层(体现-域-数据)应该决议他们项意图模块结构.

*假如这些作者最初读过Clean Architecture这本书, 他们就会知道这种办法是广告中最反对的办法. *

这个糟糕的主张之所以让我感到不安, 不仅仅是由于开发人员运用了错误的模块化办法的结果, 还由于它把公司引入了歧途, 由于他们用这些层来分隔开发团队.

假如我想把一个数据库换成另一个数据库怎么办?

难道改动一个模块不是更好吗?

我听过许多次支撑这种打包方式的说法, 简短的答复是:不, 这不是更好.

首要, 改动数据库不是你日常作业的一部分. 这或许会在几年内产生, 但绝对不是每周一次.

更不必说这对移动开发者来说是十分罕见的作业(一些不幸的开发者不得不必Realm替换Sqlite, 然后用Room回到Sqlite, 但这产生在许多年今后).

其次, 趁热打铁地交换数据库是个坏主意. 更好的做法是将你的数据逐一迁移到新的数据库中, 这样你就能够逐步开释你的迁移, 并限制或许呈现的潜在bug的数量.

分特性打包

Android“真正的”模块化

在分特性打包中, 你将代码库切割成特性模块, 每个特性都有一个

这种办法有许多长处, 并且几十年来一向是最值得引荐的办法:

  1. 当在一个特性上作业时, 你只改动一个模块, 这对保护来说是最抱负的.
  2. 当你翻开你的项目时, 你清楚地知道你的项目是做什么的, 由于它向你喊出了它的内容(尖叫架构).
  3. 每个跨功用的团队都能够独立完成一个功用, 而不会踩到其他团队的脚.
  4. 独立的团队也意味着独立的模块, 所以你能够充分利用Gradle的并行编译, 除了要求你只从头编译那个改动了的单一功用外, 它还会削减你的整体编译时刻.
  5. 你不会失去层, 由于层能够很简单地在特性模块内作为包来完成.

那么, 这便是我应该将我的应用程序模块化的方式, 对吗?

并非如此. 这种办法看起来对保护来说是最抱负的, 但彻底没有复用性!这种办法的缺陷是十分贵重!

这种办法的缺陷是十分贵重:

  1. 假如你的特性模块需求重用另一个特性模块的一些代码, 你就需求在一个十分不安稳的模块上树立依托联系, 这会损坏SDP, 同时由于功用会包括许多的UI代码, 而UI代码是十分详细的;你也会损坏SAP.
  2. 特性模块包括presentation, domain,和data逻辑, 这导致大模块(CRP违反)包括常常改动的代码(UI)以及很少改动的代码(业务逻辑).
  3. 依托于功用的特性往往会产生大的”中心功用”模块, 使你的项目逐步恢复到一个单体.

按功用打包在UI不重的项目中效果很好, 如后台或旧的前端应用程序.

在后端项目中, 控制器的代码(presentation层)一般很薄, 与domain层的用例或服务匹配.

后台也能够依托服务(或微服务)而不是模块, 所以组件内的通讯不需求一个服务知道另一个服务的内部结构. 相反, 公共API的协议使得整个组件的结构在编译时是独立的.

在移动或网络前端项目中, “屏幕”是一些特性的集群.

想想一个电子商务的产品详情页, 它答应你把产品增加到购物车和用户的期望清单中.

这些相同的操作能够在产品列表页或购物车页, 或期望清单页进行.

在这种状况下, 你怎么能按功用划分呢?

你要把全部的东西都归入一个单一的功用吗?

你计划创立一个大的同享功用模块来同享公共代码吗?

你要重复许多的代码, 以便有独立的模块吗?

任何这些解决方案都是次优的, 都不是问题的答案.

分特性打包不适合重度UI项目, 所以不要在专业的Android项目中运用它(相同适用于iOS和Web).

分组件打包

Android“真正的”模块化

按组件打包的PDP方案示意图

在分组件打包中, 你把代码库分红UI模块和组件模块(特性的domain+data层).

谁应该辅导你的应用程序的各个模块?

当然不是咱们在分特性打包中看到的UI逻辑. 用例应该辅导你的模块化, 就像它们辅导你的开发相同.

用例告知咱们应用是做什么的, 和什么很少改动. 它们也是presentation层仅有可见的架构组件.

数据层的存在仅仅为了支撑范畴层. 因而, 范畴层的修正往往需求数据层的修正, 以便与更新的资源库接口兼容.

经过运用用例对代码库进行纵向和横向切割, 咱们能够完成咱们在分特性打包时没有的重用性.

回到产品详情页的比方(查看图表). 假如我有一个购物车组件模块, 一个期望清单组件模块和一个PDP UI模块, 我现在能够重复运用购物车和期望清单的代码, 而不需求依托任何显示购物车或期望清单屏幕的UI细节.

假如产品团队决议在期望清单界面中引入增加到购物车的功用, 咱们只需将购物车组件模块作为依托联系增加到期望清单UI模块中, 并将其链接.

我现在不仅有了更多可重用的办法, 并且还将常常改动的类与很少改动的类分开, 从而最大极限地削减了从头编译的次数.

由于组件仍然是独立的, 咱们能够并行地编译模块, 从而提高了整体的编译时刻.

假如我需求在组件模块或UI模块之间同享代码怎么办?

假如你在一个专业项目上作业, 你很或许会遇到这个问题, 解决办法如下:

Android“真正的”模块化

假如你需求分享的是一个功用的详细代码, 你能够在同享组件模块或同享UI模块中提取你需求重用的内容.

假如你需求分享的是通用代码, 比方说执行网络请求或规划系统的代码, 你将遵循与第三方库(如Retrofit, Dagger……)相同的办法, 不同的是这个模块不会揭露, 而是对你的项目来说是私有的(直到你决议与公众分享它).

假如我需求从PDP导航到Cart屏幕或Wishlist屏幕怎么办?

DIP是你的朋友. 假如你需求在模块中导航, 你所需求做的便是有一个接口, 比方说:

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

这将由主(app)模块中的一个类来完成:

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

*关于同一模块内的界面, 你不需求这样做. *

参考

  • Clean Architecture, 第34章(缺失的章节), Simon Brown
    *Simon Brown原始博文
    *Martin Fowler在缺失的章节中引用的文章

封装

假如说有一件事开发者历来没有做得好, 那便是封装作业.

翻开你的一个模块的代码. 假如每一个类或接口都是公共的, 那么你的封装就做错了.

“public”是一个修饰词, 应该只用于那些要在模块外运用的类或接口. 其他的都应该是”internal”.

假如一个模块中的每一个类或接口都是”public”的, 那么开发者或许会被误导, 以为全部的东西都需求被其他模块重复运用, 从而不敢去碰这些代码.

更有纪律的开发者, 为了获得信心, 会运用IDE的查找运用工具来查看这些类是否在模块之外运用. *假如有一个修正器就好了, 它能够防止这些额外的过程, 让开发者更有生产力. *

模块封装不仅仅是为了自信, 也是为了未来的改进.

经过了解什么是public, 什么是不public的, 你或许会找到一种办法, 在你仅仅由于少量的类/接口而运用模块的状况下, 将模块与依托联系解耦.

这并不以public/internal修饰符为终点.

你或许会发现的另一个封装问题是与反式依托的露出有关的.

这种状况产生在你的一个依托联系经过运用api而不是implementation而泄露了一个横向的依托联系.

抱负状况下, 你总是运用implementation, 由于这防止了依托联系的泄露和额外的编译时刻.

经过分组件打包进行封装

在分组件打包中, 运用封装是十分简单的, 由于能够揭露的文件数量只要几个.

  • 在组件模块中:仅有应该public的文件是用例接口和需求在模块外运用的模型. 用例完成, 资源库接口, 资源库完成, 映射器接口, 映射器完成, DTO等, 应该一向是internal的, 由于体现层不该该访问它们. 组件模块是安稳的(坚持SDP), 包括业务规则和用例. 经过只揭露用例接口, 咱们也坚持了SAP, 由于现在其他模块将只依托于这个模块的笼统性.
  • 在用户界面模块中:仅有应该public的文件是屏幕(fragments, activities, 组合界面的Composables)和外部导航器. UI模块是不安稳的, 由于UI是十分不安稳的;因而, 咱们应该尽量不把它们作为依托联系加入, 只在主(app)模块中导入它们, 以连接导航.

假如你正在运用Dagger, 提供这些依托联系的模块也应该做成internal的.

// 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
    //...
}

假如你运用手动注入, 你能够把你的依托容器变成public的, 并保证只要public的文件才能从public办法中回来(否则你会得到一个编译错误).

参考文献

*Gradle API与完成的文档

主组件

在每个系统中, 至少有一个组件创立, 协调和监督其他组件.

主组件(Android中的app模块)是最终的细节, 它包括最初级其他战略, 是系统的进口. 它是一个脏手了的初级模块, 坐落clean architecture的最外圈. 它为高层系统加载全部, 然后将控制权移送给它.

以下内容应该被放在这个区域里:

  • 全部连接模块所需的”胶水代码”.
  • 全部不能在模块内部运用的注入代码
  • 全部不能在模块内部运用的导航代码
  • 你的口味配置
  • 结构所需的全部初始化

参考文献

  • Clean Architecture, 第26章 (主组件)

最终阐明

我知道这篇文章很长, 并且准则性的东西历来都不好玩, 尤其是当它们要求你事前了解其他准则的时分.

假如我在写这篇文章时只写解决方案, 那么这些对读者来说就仅仅一种观念, 而不是组件原理的产品.

尽管这是一篇长篇大论, 但仍是遗漏了许多东西, 比方用于解耦上下文的范畴驱动规划概念或经过端口和适配器打包(另一种次优的打包规划方案).

我期望这篇文章尽管有理论部分, 但仍然是令人愉快的, 并且你现已学到了新的工具来使你的项目模块化.