Android 官方模块化方案解读 | 开发者说·DTalk
The following article is from Rethink Android Author madroid
本文将与您一起看一下官方文档中是如何描述 Android App 模块化的。
官方文档
概述
模块化解决了什么问题?
什么是模块化?
注: :app:phone 是模块名,app 表示是子目录的形式,具体可以参考我给 Now In Android 提交的 PR:
https://github.com/android/nowinandroid/pull/241
模块化有哪些好处?
优点 | 概括 |
多 App 复用 | 模块化是在多 App 开发中复用代码逻辑的基础。每个模块都是一个独立有效的构建单元。 |
严格的访问权限 | 模块可以很好做控制代码的可访问性,模块内部私有的逻辑添加 internal 或者 private 修饰。防止代码被其他模块引用而导致的过度耦合。 |
可定制的交付 | 可以使用动态下发 (Play Feature Delivery) 功能 (注: 国内应用商店基本不可用)。 |
Play Feature Delivery
上述好处只能通过模块化才能实现。以下是不使用模块化也能实现的好处,但模块化可以更好地实现。
优点 | 概括 |
可扩展性 | 在代码紧密耦合的代码仓库中,一个微小的更改都有可能导致牵一发动全身。一个好的模块化的项目会做到代码解耦 (关注点分离原则),从而规避了上述问题。 |
负责人 | 一个模块可以有一个专门的负责人,负责维护代码、修复错误、添加测试和 CodeReview。方便代码与人员的双重管理。 |
封装 | 封装意味着代码的每一部分都应该对其他部分有尽可能少的了解 (最少知道原则)。孤立的代码更容易阅读和理解。 |
可测试性 | 可测试性描述了测试代码的难易程度。可测试代码是可以轻松独立测试组件的代码。小类总比大型类容易测试,依赖少的类总比依赖多的类容易测试。 |
构建时间 | 模块化可以提升编译速度,例如增量构建、构建缓存或并行构建。 |
模块化常见的误区
任何一项技术都有好坏,模块化也是如此。对模块化使用不当,可能也会引入一些问题。一些常见的问题如下:
太细粒度: 太细粒度就意味着项目中会有很多模块,而多模块又会导致编译耗时以及多模块配置同步的问题; 太粗粒度: 太粗粒度就意味着项目中会有很少模块,基本不能完全发挥出模块化的好处;当做这是一个循序渐进的过程,太粗粒度可以是一个开始不应该是一个结束; 太复杂: 将项目模块化并不总是有意义的。如果在可预见的未来项目的增长并不明确,保持现状也是一种不错的选择。
高内聚低耦合原则
没有适合所有项目的模块化方案。下面讲下模块化开发过程中可以采用的一些一般规则和常见模式。
高内聚低耦合是模块化项目的一种属性。耦合衡量模块相互依赖的程度,内聚衡量单个模块的元素在功能上的相关性。应该争取低耦合和高内聚:
低耦合模块不应该了解其他模块的内部工作原理,这意味着模块应该尽可能地相互独立,以便对一个模块的更改对其他模块的影响为零或最小。
高内聚意味着模块应该仅包含相关性的代码。在一个示例电子书应用程序,将书籍和支付的代码混合在同一个模块中可能是不合适的,因为它们是两个不同的功能领域。
如果两个模块严重依赖彼此,那么它们实际上应该作为一个系统运行。相反,如果一个模块的两个部分不经常交互,它们可能应该是单独的模块。
小结
模块化就是一种将复杂问题拆解成多个简单问题的工程化方案。所以如果您觉得项目还没有那么复杂,引入模块化的收益将没有那么明显。这里的复杂性包括多 App 复用、严格的代码可见性以及 Google Paly 的动态下发 (Play Feature Delivery)。当然,如果希望在可扩展性、所有权、封装或构建时间中受益,那么模块化是值得考虑的事情。
模块的类型
App 模块
应用程序模块是应用程序的入口点。它们依赖于特性 (feature) 模块,通常提供导航能力。使用多渠道打包方案,单个应用程序模块可以编译为许多不同的二进制文件。
多渠道打包
如,根据使用用途可以分为正式版本 App、测试 Demo App,其中正式版本 App 根据其发布平台又可以分为智能手机、汽车、电视、可穿戴设备等,其依赖关系大致如下:
特性 (Feature) 模块
特性是 App 中功能相对独立的部分,通常对包含一个页面或一系列密切相关的页面,例如注册或结帐流程。如果您的应用具有底部栏导航,则很可能每个目的地都是一项功能。
特性模块中一般会包含页面或路由 (destinations)。因此,在模块内部需求处理 UI Layer 中相关的内容。特性模块中不必局限于单个页面或导航目的地,可以包含多个页面。特性模块依赖于数据模块。
数据 (Data) 模块
数据模块通常包含 Repository、DataSource 和实体类。数据模块主要有三个职责:
封装某个领域的所有数据和业务逻辑: 每个数据模块应该负责处理代表某个领域的数据。它可以处理多种相关类型的数据。 将 Repository 公开为外部 API: 数据模块的公共 API 应该是 Repository,因为它们负责将数据公开给 App 的其余部分。 对外隐藏所有实现细节和 DataSource: DataSource 只能由同一模块的 Repository 访问,对外是隐藏的状态。可以通过使用 Kotlin 的 private 或者 internal 关键字来强制执行此操作。
公共 (Common) 模块
公共模块,也称为核心模块或者基础模块,包含其他模块经常使用的代码。以下是常用模块的示例:
基础 UI 模块: 如果 App 中使用自定义 View 和样式 (style),应该考虑将他们统一封装到一个模块中,以便可以复用。也就是大家通常所说的 UI 规范库,这可以使 UI 在不同特性模块之间保持一致。
打点统计模块: 打点统计模块,一般是使用市面上现有的 SDK,当然也有自研的。取决于项目需要。
网络模块: 网络库模块,通常是对三方网络库 (如 OhHttp) 的封装,简化自定义配置时,减少不必要的重复代码。
工具模块: 工具类,也称为帮助类,通常是在应用程序中重用的小段代码。如文件读写、电子邮件验证器或自定义运算符等。
App 模块化整体汇总形式大致如下:
模块间通信
此部分结合自身经验以及官方文档整合而得,请批判性观看。
抽离基础模块
大致流程如下:
分别在 :feature:home 与 :feature:checkout 设置对基础依赖模块的初始化操作,如接口实现、回调监听等;
在 :feature:home 模块内通过依赖的 :data:books 模块,调用其 navigate() 方法跳转至 :feature:checkout 模块;
在 :feature:books 模块内将跳转事件通过 onNavigationBook 分发出去,由 :feature:checkout 模块实现;
:feature:home 模块通过 :data:books 模块提供的 onPaymentCanceled() 回调来监听对应的结果。
这种通讯方式随着业务的迭代,底层通用的数据模块会不断膨胀,耦合也会越加严重,所以并不建议使用此方式。官方文档中示例方式则是交由调用者处理,各自模块也相对内聚。
依赖调用者模块
这种方式一般是依赖 app 模块来组装各个业务模块的业务逻辑,也就是一样意义上的胶水代码。大致方式如下:
:app 模块调用 :feature:home 提供的 navigate 函数跳转至 home 页面,并通过 onCheckout 函数将对应的结果回调出去;
:app 模块监听到 onCheckout() 回调后,调用 :feature:checkout 模块提供 navigate 函数进行跳转,并通过 onPaymentCanceled() 回调将结果抛出。
此种方式使得各业务模块的逻辑更加内聚,虽然这种方式的结果及事件也能很好的暴露出去。但是如果这种方式在大型项目中使用时会导致产生大量的胶水代码 (频繁的初始化以及 Callback 设置),不利于项目中后续迭代。为了解决胶水代码问题,可以在项目中引入依赖管理的框架。
依赖管理框架
依赖管理不仅可以很好地解决对象繁琐的初始化逻辑,还可以很好的实施控制反转的编码思想。目前主流的依赖管理的方案有两种,分别为依赖注入与服务查找:
依赖注入: 依赖注入使类能够定义其依赖项而不构造它们。在运行时,另一个类负责提供这些依赖项。一般是使用注解方式,适合大中型项目,如 Hilt;
服务查找: 服务查找的方式一般是维护一个注册表,所需的依赖都可以在这个注册表中查找;一般是使用相对简单,适合中小型项目,如 koin。
官方推荐使用 Hilt 来进行依赖管理,如果您的项目中在使用其他的依赖管理方式,并且没有遇到问题的话,那么继续使用当前的框架即可。
依赖管理的方式不仅可以使用模块间通信,在模块内部通信也是一种很好的解耦与复用的手段,只是在模块间通信流程会变得更加复杂,也更能突出依赖管理的重要性。整个依赖管理在模块化整体架构大致如下图:
以服务查找方式实现,其大致流程 (忽略模块内通讯) 如下:
数据层可以将对应的数据注入到 DI 容器中;
在特性模块中可以获取到数据层提供的数据,同时也可以将自身的数据注入到 DI 中;
在 app 模块获取所需的数据或特性功能。
小结
其实整个模块间的通信可以按照其行为方式分为两大类,一种是不同模块间页面直接的跳转,另一种则是不同模块间的数据交互。
对于前者有各种路由框架,Android 官方也提供了 Navigation 库;对于后者也有不少框架,如 Dagger2、Koin,Android 官方也提供了 hilt 库;当然社区中也有两者都能满足的库。
官方文档中只是提到了比较原始的方式,也是对初学者比较友好的方式。大家可以根据自己项目中的现状选择适合自己的即可。
最佳实践
虽然开发模块化 App 没有唯一正确的方式,但是以下的一些建议仍可以使代码更具可读性、可维护性和可测试性。
保持配置一致
每个模块都会引入配置开销。当模块数量达到某个阈值,则管理一致的配置将成为一项挑战。下面的配置可以减少这部分的工作量:
使用 version catalog 来统一各模块中依赖的版本号;
使用约定插件在模块之间共享 build.gradle 中的构建逻辑。
尽量少暴露
模块的公共接口应该是最小的,并且仅仅只公开必需公开的。它不应该在外面暴露任何实现细节。尽可能的缩小外部调用者的可访问范围,使用 Kotlin 的 private 或 internal 可以很好的做到这一点。在模块中声明依赖项时推荐使用 implementation,而非 api。implementation 不会透传依赖,从而可以做到缩短构建时间。
尽量使用 Kotlin 和 Java 模块
Android Studio 支持三种基本类型的模块:
应用程序模块 AndroidManifest.xml 是您的应用程序的入口点。它们可以包含源代码、资源、资产和应用模块的输出是一个 Android App Bundle (AAB) 或一个 Android 应用程序包 (APK)。
库模块依赖项与应用程序模块具有相同的内容。它们被其他 Android 模块用作依赖项。库模块的输出是一个 Android Archive (AAR),在结构上与应用程序模块相同,但它们被编译为一个 Android Archive (AAR) 文件,以后可以被其他模块用作。库模块可以在许多应用程序模块中封装和重用相同的逻辑和资源。
Kotlin 和 Java 库不包含任何 Android 资源、资产或清单文件。
由于 Android 模块会带来开销,因此您最好尽可能使用 Kotlin 或 Java 类型。
库模块
依赖项
总结
以上内容是根据官方文档整理而得,对部分内容做了结构调整、重新绘制了 UML 图以及添加了些自己的经验感悟。对原文整理会存在疏忽遗漏的部分,请大家到官方文档中查看,做到 "交叉验证"。
虽然模块化技术在国内并算不上什么新技术了,但是我仍然看到了一些积极的影响:
对于初学者而言,有一套相对详细指导文档并且完整示例的项目 (Now in Android) 可以参考,从而可以快速搭建模块化的项目; 对于已经实践过模块化项目团队,我相信仍能从官方文章中学习到一些新思路及方法,以复盘的视角审视自己团队中的模块化方案的优劣。
长按右侧二维码
查看更多开发者精彩分享
"开发者说·DTalk" 面向