模块化与组件化
提及模块化,常常会想到组件化,这两者都是软件开发中的设计理念,相似但不尽相同。
- 组件化:组件相当于库,组件化是指把一些能在项目里或者不同类型项目中可复用的代码进行工具性的封装,抽离为单个组件,按照功能逻辑进行划分并控制粒度便于项目的维护和开发。多个组件也可以组合成组件库,方便调用和复用,组件间也可以嵌套,小组件组合成大组件。
- 模块化:更多从业务逻辑出发,分属同一功能/业务的代码进行隔离(分装)成独立的模块,,让业务逻辑在模块内内聚,可以独立运行,以页面、功能或其他不同粒度划分程度不同的模块,位于业务框架层,模块间通过接口调用,目的是降低模块间的耦合,由之前的主应用与模块耦合,变为主应用与接口耦合,接口与模块耦合。
为什么要模块化
- 提升编译速度:模块化可以将一个大的项目分解为多个小的模块,每个模块都可以独立编译。这样,当你修改了某个模块的代码后,只需要重新编译这个模块,而不需要重新编译整个项目。这大大减少了编译的时间,从而提高了编译速度。
- 提升开发效率:模块化可以使每个模块都可以独立进行开发测试,开发者可以专注于模块内的开发;同时,模块化通过分工合作、代码重用、模块独立和易于扩展来提升开发效率。
可维护性强:同一业务的代码在模块内聚合,每个模块都有自己的职责和功能。这样,当一个模块出现问题时,只需要修复该模块,而不需要影响整个应用程序。
提高可扩展性:模块化可以将应用程序拆分成多个独立的模块,每个模块都可以单独扩展或与其他模块组合扩展。这样可以提高应用程序的可扩展性,使其更容易适应不同的需求和场景。
模块化实践方案
模块化中的重要课题是:
- 主模块与子模块解耦合,在安卓工程上的表现是主模块要依赖子模块来使用子模块中的能力,因此主模块可以依赖子模块,但子模块不能依赖主模块,否则就是非法环形依赖了。
- 子模块与主模块通信,解耦合带来的问题是在子模块中不能依赖主模块,也就不能直接使用主模块中的能力了,因此需要解决特定情况下子模块使用主模块能力的问题。
本文介绍两种常见常用的模块化方案。
以下示例都假设有主模块(app)、接口模块(api)和子模块(impl)三种模块角色
隐式依赖(SPI)
SPI(service provider interface),通过使用Java提供的ServiceLoader接口,在工程结构的module/resources/META-INF/services目录里创建服务接口同名文件,文件内是实现类的包路径,从而在运行时动态通过该服务接口名字查找到实现类的名字,并装载实例化,完成模块的注入。
逻辑关系如图所示:
具体实现:
- 在接口模块中声明接口
1
2
3
4
5package com.example.spi.api
interface ILogInterface {
fun logD(msg: String)
} - 在实际业务模块中实现接口
1
2
3
4
5
6
7
8
9
10
11
12
13package com.example.spi.impl
import android.util.Log
import com.example.spi.api.ILogInterface
class Logger: ILogInterface {
companion object {
private const val TAG = "Logger"
}
override fun logD(msg: String) {
Log.d(TAG, msg)
}
} - 在接口模块中的resources/META-INF/services目录里创建服务接口同名文件,接口同名文件内部是实现类的包路径
- 在主模块中通过ServiceLoader与接口模块中的接口使用实际业务模块中的能力
1
ServiceLoader.load(ILogInterface::class.java, classLoader).iterator().next()?.logD("test")
显式依赖
显示依赖也是借助运行时反射来达到依赖的,但这交由开发者来手动实现,并非同spi方式只需在接口模块编写配置文件即可。
逻辑关系如图所示:
具体实现:
同样地在接口模块中声明接口,主模块与子模块不直接通信,而是通过接口模块这个桥梁实现通信
1
2
3
4
5
6
7
8
9// 子模块通过接口模块提供给主模块的能力,由子模块实现
interface IService {
fun getSubActivity(): Activity
}
// 主模块通过接口模块提供给子模块的能力,由主模块实现
interface IContext {
val util: IUtil
}在子模块中实现接口,并提供接口接受主模块注入的能力
1
2
3
4
5
6
7
8
9
10
11
12
13
14// 子模块实现接口
class ServiceImpl(context: IContext): IService {
init {
HostContextForService.init(context) // 接受主模块注入的能力,后续通过访问HostContextForService即可使用主模块的能力
}
// 向外暴露子模块的能力
override
fun getSubActivity(): Activity {
return SubActivity()
}
}主模块通过接口模块使用子模块的能力,并注入主模块的能力
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22// 实现接口,向子模块提供能力
class ContextImpl: IContext {
override val util: IUtil
get() = Util()
}
// 使用子模块的能力
fun initService() {
runCatching {
if (ServiceManager.getService(IService::class.java) == null) {
val subModuleHost = ContextImpl() // 向子模块提供能力
// 运行时构造子模块中的实例,并注入主模块的能力
val serviceImplClass = Class.forName("com.example.impl.service.ServiceImpl")
val constructor = serviceImplClass.getConstructor(IContext::class.java)
val serviceImpl = constructor.newInstance(subModuleHost) as IService
ServiceManager.registerIfNull(IService::class.java, serviceImpl)
// 后续通过 ServiceManager获取子模块的实例并使用子模块
}
}.onFailure {
Log.e("ServiceRegistry", "init template show service error.", it)
}
}
至此,便实现了主模块与子模块间的通信,并且可以在子模块中不用依赖主模块也能使用主模块中的能力了。
可能有人会觉得需要手动显示依赖的方式会比较麻烦,但正因为显式反射依赖,开发者可以在反射构造子模块中实例时注入主模块中的一些能力,这样能更方便地实现从主模块拆分子模块的模块化工作。
总结
本文介绍了模块化的两种方案,两种方案都是除了主模块与子模块外还有一个接口层模块,接口层模块的存在可以更好的隔绝主模块与子模块,以实现更好的解耦合。
其中,第一种隐式依赖,主模块能通过语言级的spi能力快速与子模块通信,但无法向子模块注入主模块中的能力;第二种显式依赖,需要开发者手动编写运行时反射代码以实现主模块对子模块的依赖,但可以在反射时注入主模块的一些能力,来让子模块也能调用主模块的一些能力。