回顾整个职业生涯,上一次对项目进行可独立运行的组件化工程改造还是七年前,当时对项目进行第三次重构,想要引入当时比较火的组件化的配置,受限于参考资料比较少,再加上业务上堆积了其他很多的事情,后面对这块也就放弃了,时间来到现在,新项目要求每个人单独负责自己的功能模块且互不影响,这就很符合组件化的概念,于是决定在这个项目中把组件化重拾起来。
背景
我对 SVN 使用要追溯到 14 年且使用的程度也不深,15 年换了工作后就没再接触了,印象中遇到代码冲突是比较麻烦的。新加入的公司代码管理工具一直是 SVN,为了避免组员之间的代码冲突,会把工程进行拆分,首先按照人员在工程目录下创建对应的子工程目录,每个人在自己的所属工程目录下按照所分配的业务功能来进行模块化拆分,在模块功能开发完后就把他推到私有 maven 服务器里供自己或其他成员的模块引用,以此在物理层面实现成员之间代码的隔离。
这样做的好处就是:
- 成员之间的代码互不干扰,可以在自己的工程下自由发挥
- 业务模块划分比较清晰,后续如果出问题也方便定责
- 物理层面进行代码隔离,彻底避免代码冲突出现的可能
当然也有缺点:
- 每个子工程就是一个独立的 Android 工程,需要额外配置
app
模块
- 成员的开发能力不一致,也造就了开发规范和技术栈不一致,代码管理相对混乱
- 模块间沟通成本较高且出现问题
Debug
时间也会变长
- 成员之间并不会互相关注对方代码,只关注功能实现,不能互相借鉴学习
因此我在接受重构项目的任务后,为了兼顾之前的项目特点,决定使用组件化对工程进行改造
思考过程
组件化的基础是模块化,站在整体
的角度上把工程拆分为多个模块,按照模块的功能和职责划分的话,其中一些模块里封装了特定的业务逻辑和 UI,这些模块就可以称为组件,组件模块除了满足高内聚、低耦合和可独立替换或更新的特点外,在某些情况下,可以独立运行和部署。
在 Android 工程层面,一个模块可以单独运行和调试,那它的模块类型必须是application
,如果它想被其他模块引用,那它的模块类型必须是library
,于是就有了以下样板代码:
1 2 3 4 5
| if(isComponent){ apply plugin: 'com.android.application' }else{ apply plugin: 'com.android.library' }
|
可独立运行的组件还需要一些额外的配套,像作为application
时所必须的AndroidManifest.xml
、Application
类和入口Activity
等
技术要点
以下每一个技术点单拎出来都可以长篇大论一番,这里我们只做简单的介绍
三方库管理工具 Catalogs
Gradle 7.0 引入的一种新特性,以可扩容的方式添加和维护依赖项和插件。
- 提高 build 文件的可读性和一致性,减少重复和冗余的代码
- 支持代码补全和导航,提高开发效率和准确性
- 方便地在一个地方管理依赖项和插件的版本,避免版本冲突和不匹配的问题
- 支持多项目构建和复用,提高构建性能和稳定性
Kotlin Script
Kotlin Script
简称KTS
,Gradle 4.0
支持在build.gradle
配置中使用 Kotlin,用于替代Groovy
- 采用 Kotlin 编写的代码可读性更高
- 提供了更好的编译时检查和 IDE 支持
- 能更好地在 Android Studio 的代码编辑器中集成
自定义Gradle
插件
相对于在gradle.properties
里定义一个变量来控制模块的类型,自定义gradle
插件的方式更加灵活,封装的比较好的话也可以发布到远程分享出去
编写自定义插件目的主要是参与编译构建 Android 工程,去完成一些特定的功能
主要分为两种:
1.脚本插件
创建一个.gradle
后缀的文件,通过apply from
引入,然后在build.gradle
中调用插件
2.对象插件
主要是实现org.gradle.api
下的Plugin
接口,有三种编写形式:
- 在
build.gradle
文件中直接编写
- 在
buildSrc
默认插件目录下编写
- 在自定义模块中编写
这里为了配合catalogs
的使用,我们使用的是对象插件且是在自定义模块下编写自定义插件
模块AAR
化
工程模块化会把工程代码分散到一个个独立的模块中,增加内聚降低耦合的同时也方便后续管理维护,但是带来的副作用是一次工程的全量编译会花费非常多的时间,模块的数量越多,花费时间越长。
目前普遍的解决方案是把模块预编译成AAR
文件,然后在主模块中引用,这里主要用到maven-push
这个三方插件,它支持把生成的AAR
文件推到本地或者远程仓库,然后在主工程里像使用第三方库一样去使用我们的模块。
maven-push
的配置使用的是以脚本插件的方式引入,主要的代码实现如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83
| apply plugin: 'maven-publish'
tasks.withType(PublishToMavenRepository).tap { configureEach { task -> def match = task.name =~ '^publish(.*)(Release|Debug)PublicationTo(.*)$' dependsOn("assemble${match[0][1]}") } }
def useMavenLocal = true
afterEvaluate { publishing { def name = project.name def mavenPublish = Config.MODULES_MAVEN_CONFIG.get(name) if (mavenPublish != null) { def versionName = mavenPublish.version publications { release(MavenPublication) { groupId = mavenPublish.groupId artifactId = mavenPublish.artifactId version = mavenPublish.version def buildType = versionName.endsWith('SNAPSHOT') ? 'debug' : 'release' artifact "build/outputs/aar/${name}-${buildType}.aar" pom.withXml { def dependenciesNode = asNode().appendNode('dependencies') def scopes = [configurations.implementation] if (configurations.hasProperty("api")) { scopes.add(configurations.api) } if (configurations.hasProperty("implementation")) { scopes.add(configurations.implementation) } if (configurations.hasProperty("debugImplementation")) { scopes.add(configurations.debugImplementation) } if (configurations.hasProperty("releaseImplementation")) { scopes.add(configurations.releaseImplementation) } scopes.each { scope -> scope.allDependencies.each { if (it instanceof ModuleDependency) { boolean isTransitive = ((ModuleDependency) it).transitive if (!isTransitive) { return } } if (it.group == "${project.rootProject.name}.libs" || it.version == 'unspecified') { return } if (it.group && it.name && it.version) { def dependencyNode = dependenciesNode.appendNode('dependency') dependencyNode.appendNode('groupId', it.group) dependencyNode.appendNode('artifactId', it.name) dependencyNode.appendNode('version', it.version) dependencyNode.appendNode('scope', scope.name) } } } } } } repositories { RepositoryHandler handler -> if (useMavenLocal) { maven { url = uri("${project.rootDir}/.mavenLocal/repository") } } else { maven { allowInsecureProtocol = true url = versionName.endsWith('SNAPSHOT') ? Config.MAVEN.snapshotUrl : Config.MAVEN.releaseUrl credentials { username = Config.MAVEN.credentials.username password = Config.MAVEN.credentials.password } } } } } } }
|
其中的Config
类定义在buildSrc
下,用于定义阿里云效Maven
的配置以及需要发布到阿里云效下Maven
的模块配置信息。
然后在项目级的build.gradle.kts
中引入一下
1 2 3 4 5 6
| ... subprojects { apply("${project.rootDir}/script/maven-publish.gradle") } ...
|
这里的脚本插件之所以使用groovy
语言,是因为KTS
不支持用来写脚本插件,写完之后无法像.gradle
文件那样在build.gradle.kts
中引入,所以这里使用groovy
语言来实现
代码实施
结合上面的技术要点和项目的情况,我针对模块的类型定义了三个类型的gradle
插件
- App:在工程里主要是指
:app
模块
- Component:主要是指
:feature:*
模块,可以通过配置来决定是 Application 还是 Library
- Lib:在工程里主要是指
:core:*
模块
App
和Lib
插件类分别封装了app
和core
模块下build.gradle.kts
所需要的配置,像常见的buildType
、compileOptions
、productFlavors
、signingConfigs
、sourceSets
和Dependencies
等等
Component
插件类封装了feature
模块下build.gradle.kts
所需要的配置以及根据配置来决定是Application
还是Library
的配置,这个额外的配置有两个开关,一个总开关和各个组件模块下的子开关,总开关在插件工程的相应的代码里,子开关则放在组件模块下的local.properties
里且把这个文件加入到.gitignore
里,这样组员打开总开关后,可以准对不同组件模块在本地做不同的配置且不互相影响
以下是插件工程的相关配置:
1 2 3 4 5
| build-logic ─── convention ├── src/main/kotlin └── build.gradle.kts settings.gradle.kts
|
settings.gradle.kts
里主要引入了catalogs
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| dependencyResolutionManagement { repositories { google() mavenCentral() } versionCatalogs { create("libs") { from(files("../gradle/libs.versions.toml")) } } } rootProject.name = "build-logic" include(":convention")
|
convention
下的build.gradle.kts
里主要定义了插件的注册
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| plugins { `kotlin-dsl` } repositories { google() mavenCentral() maven { url = uri("https://maven.aliyun.com/repository/google") } }
dependencies { implementation(libs.android.gradle.plugin) implementation(libs.android.tools.common) implementation(gradleApi()) implementation(localGroovy()) }
gradlePlugin { plugins { plugins { register("androidComponent") { id = "com.build.component" implementationClass = "plugins.ComponentBuildPlugin" version = "1.0.0" } } } ... }
|
下面主要给出Component
插件的伪代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| class ComponentBuildPlugin : Plugin<Project> { override fun apply(project: Project) { with(project) { if(xxx) { pluginManager.apply("com.android.application") } else { pluginManager.apply("com.android.library") } sourceSets { getByName("main") { if(xxx) { manifest.srcFile("src/component/AndroidManifest.xml") java.srcDirs("src/main/java", "src/component/java") assets.srcDirs("src/main/assets", "src/component/assets") jniLibs.srcDirs("src/main/jniLibs", "src/component/jniLibs") } else { manifest.srcFile("src/main/AndroidManifest.xml") java.srcDirs("src/main/java") assets.srcDirs("src/main/assets") jniLibs.srcDirs("src/main/jniLibs") } } } } } }
|
从代码可以看出,这里主要把组件模块独立运行所需要的相关资源,放到模块下main
同级别的component
下了
延伸
还是本着配置的原则,支持模块间的协作与高效效率开发,这里通过代码来实现
模块作为Application
级别的模块时,可以配置当前模块对其他模块的依赖方式:
1 2 3 4 5
| enum class CompileMode { LIBRARY, HYBRID, PROJECT }
|
针对不同的模块做类的封装
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| data class App( val applicationId: String, val compileMode: CompileMode = CompileMode.LIBRARY, val featureFlags: Map<String, Boolean> = mapOf("main" to true,...), val libraryFlags: List<Library> = listOf( Library("model", true), ... ) )
data class Features( val name: String, val applicationId: String, val compileMode: CompileMode = CompileMode.PROJECT, val libraryFlags: List<Library> = listOf( Library("model", true), ... ) )
data class Library(val name: String, val libraryFlag: Boolean)
|
在结构化后再处理对应的依赖关系,最后给出三种模式的关系图:

总结
结合一些我看过的有关组件化开发的文章,我个人还是比较倾向于使用这种内置在工程内部的组件化方案
主要有以下优点:
- 每个模块通用的
gradle
配置可以放到自定义插件中,提高可维护性
- 针对项目的特点或者某个特别的模块可以做一些额外的处理,随时改动立马就见效果
- 比较灵活,可以通过配置来实现不同的方式,既可以全量编译,又可以部分模块参与编译运行
参考