相信大部分的开发团队,不管前端也好,后端也好,都会有自己内部的一套规范。它是团队协作开发的基石,如果团队成员各自搞自己的,最后集成时肯定或多或少会出现问题。所以问题就来了,在我们组件化开发的过程中,每个人各自开发自己的组件,单独运行时可能没问题,但是最后集成打包时总是失败。作为一个合格的团队 leader ,你肯定强调过各组员要遵循一致的代码规范、行为准则等,甚至形成各种必要的规范文档。但是实践告诉我们,这需要所有人都要有很强的自觉性,但是这种靠自觉性的规则往往是靠不住的,你不能保证所有人都理解了你的规则,也不能保证所有人每时每刻都按照这个规则来执行,如果没有强有力的执行,这个规则就是一纸空文,很快就会被淡忘。基于这个原因,在组件化开发的过程中,我们可以通过自定义 Gradle 插件的方式,来统一各种规范,以下讲讲我在这方面的部分实践(这需要了解 Gradle 相关知识)。
1. 统一compileSdkVersion、minSdkVersion、targetSdkVersion
每个人的开发环境都是不相同的,编译环境的不同的可能会导致编译结果的差异。举几个栗子:当 targetSdkVersion >= 23
时,安卓引入了动态权限,所有敏感权限都需要先申请再使用,但是 targetSdkVersion < 23
时,是不需要申请的,如果有的人使用了低版本 sdk ,那么最终集成到主 app 中时,就可能会出现权限方面的问题了;其次就是支持的最小 sdk 版本问题了,由于历史原因,很多 api 在高版本 sdk 中才出现,如果有的人在开发组件的过程中设置的 minSdkVersion = 23,但为了兼容更多的手机,集成打包时设置的 minSdkVersion = 19,那打包就会出现问题,或者是在低版本系统的手机上不兼容出现闪退。
通过插件强制使用相同的 sdk 版本:
static def MIN_SDK = 19
static def TARGET_SDK = 26
static def COMPILE_SDK = "android-26"
project.afterEvaluate {
com.android.build.gradle.BaseExtension android = project.extensions.getByName("android")
//强制统一 compileSdkVersion、 minSdkVersion、targetSdkVersion
String compileSdkVersion = android.compileSdkVersion
int targetSdkVersion = android.defaultConfig.targetSdkVersion.apiLevel
int minSdkVersion = android.defaultConfig.minSdkVersion.apiLevel
if (compileSdkVersion != COMPILE_SDK) {
throw new GradleException("请修改 compileSdkVersion,必须设置为 ${COMPILE_SDK}")
}
if (minSdkVersion != MIN_SDK) {
throw new GradleException("请修改 minSdkVersion,必须设置为 ${MIN_SDK}")
}
if (targetSdkVersion != TARGET_SDK) {
throw new GradleException("请修改 targetSdkVersion,必须设置为 ${TARGET_SDK}")
}
}
如果发现 sdk 版本不一致,直接抛出异常,强制所有人使用相同的 sdk 版本。
2. 统一 support 等常用第三方库的版本
由于 support 库使用范围实在太广了,不仅我们自己会使用到,很多第三方库也可能会依赖到,最终会出现各种不同的版本号,以我自己的一个项目为例:
除了 support 库之外,还有很多其他的常用库,例如:okhttp、retrofit、gson 等,我们可以采用 gradle 的解析策略来强制统一版本号:
static def SUPPORT_VERSION = "26.1.0"
static def MULTIDEX_VERSION = "1.0.2"
static def GSON_VERSION = "2.8.0"
static def KOTLIN_VERSION = "1.3.40"
ConfigurationContainer container = project.configurations
container.all { Configuration conf ->
ResolutionStrategy rs = conf.resolutionStrategy
rs.force 'com.google.code.findbugs:jsr305:2.0.1'
//统一第三方库的版本号
rs.eachDependency { details ->
def requested = details.requested
if (requested.group == "com.android.support") {
//强制所有的 com.android.support 库采用固定版本
if (requested.name.startsWith("multidex")) {
details.useVersion(MULTIDEX_VERSION)
} else {
details.useVersion(SUPPORT_VERSION)
}
} else if (requested.group == "com.google.code.gson") {
//统一 Gson 库的版本号
details.useVersion(GSON_VERSION)
} else if (requested.group == "org.jetbrains.kotlin") {
//统一内部 kotlin 库的版本
details.useVersion(KOTLIN_VERSION)
}
}
}
在实践过程中,可以逐渐收集常用的第三方库,定时更新版本号。
3. 统一添加 git hook
什么是 git hook 呢?简单说来,就是 git 钩子,当我们采用 git 管理代码时,提交代码、更新代码、回退代码等等操作时,会先触发一个脚本执行。基于这个功能,我们可以做很多事情,比如:检查 commit 的信息是否规范,不规范的信息不允许提交;push 代码时,先做个 lint 检查,有问题或不符合规范的代码禁止推到远程分支上。
使用 git 管理代码时,在工程根目录下,会默认有个 .git/hooks 目录,我们看看这个目录下都有些什么文件,如下图所示:
可以看到有很多以.sample
为后缀名的文件,这些都是 git hook 文件,默认情况下 git hook 是不开启的,但是当去掉 .sample 后缀时,对应的 hook 就生效了。以commit-msg.sample
为例,我们将之重命名为commit-msg
,当我们执行git commit
命令时,会先执行该脚本文件,如果脚本运行通过,commit 才会成功,否则就会提交失败。除此之外,其他的功能就不一一赘述了,可搜索相应资料进行学习。
很显然,我们不能要求所有人都能自觉地配置 git hook,这样太繁琐了,如果能通过插件自动为我们配置一切,那是不是就完美了。例如:我们想通过 git hook 规范所有人的 commit 信息,其思路如下:
- 首先检测 .git/hooks/commit-msg 文件是否存在;
- 如果已存在则不处理;
- 如果不存在,则将 .git/hooks/commit-msg.sample 文件重命名为 commit-msg;
- 将要检测提交信息是否规范的脚本代码写入 commit-msg 文件里;
private static final String GIT_COMMIT_MSG_CONFIG = '''#!/usr/bin/env groovy
import static java.lang.System.exit
//要提交的信息保存在该文件里
def commitMsgFileName = args[0]
def msgFile = new File(commitMsgFileName)
//读出里面的提交信息
def commitMsg = msgFile.text
//对要提交的信息做校验,如果不符合要求的,不允许提交
def reg = ~"^(fix:|add:|update:|refactor:|perf:|style:|test:|docs:|revert:|build:)[\\\\w\\\\W]{5,100}"
if (!commitMsg.matches(reg)) {
StringBuilder sb = new StringBuilder()
sb.append("================= Commit Error =================\\n")
sb.append("===>Commit 信息不规范,描述信息字数范围为[5, 100],具体格式请按照以下规范:\\n")
sb.append(" fix: 修复某某bug\\n")
sb.append(" add: 增加了新功能\\n")
sb.append(" update: 更新某某功能\\n")
sb.append(" refactor: 某个已有功能重构\\n")
sb.append(" perf: 性能优化\\n")
sb.append(" style: 代码格式改变\\n")
sb.append(" test: 增加测试代码\\n")
sb.append(" docs: 文档改变\\n")
sb.append(" revert: 撤销上一次的commit\\n")
sb.append(" build: 构建工具或构建过程等的变动\\n")
sb.append("================================================")
println(sb.toString())
exit(1)
}
exit(0)
'''
//在根目录的 .git/hooks 目录下,存在很多 .sample 文件,把相应的 .sample 后缀去掉,git hook 就生效了
File rootDir = project.rootProject.getProjectDir()
File gitHookDir = new File(rootDir, ".git/hooks")
//如果该目录存在
if (gitHookDir.exists()) {
//将 commit-msg.sample 文件的后缀名去掉,git hook 就会生效
File commitMsgSampleFile = new File(gitHookDir, "commit-msg.sample")
File commitMsgFile = new File(gitHookDir, "commit-msg")
if (!commitMsgFile.exists() && commitMsgSampleFile.exists()) {
//重命名的方式,自己创建的文件可能没有可执行权限,需要手动加权限,故采用重命名原文件的方式,省去手动加权限的操作
commitMsgSampleFile.renameTo(commitMsgFile)
commitMsgFile.setText(GIT_COMMIT_MSG_CONFIG)
println("-----自动配置 git hook 成功-----")
} else {
println("-----git hook 已经启用-----")
}
} else {
println("-----没有找到.git目录----")
}
提交信息规范参考了网上别人的文章,可以定制符合自己团队需求的规范。这里的脚本文件,我是采用 groovy 来实现的,因此需要预先安装 groovy 运行环境。比较好的方案是直接使用 shell 脚本,但我对此不是特别熟练,还有就是这里不支持 windows 运行环境,如需支持还得额外考虑(当然我们默认开发人员都是用 mac 的)。里面有个地方需要特别注意,commit-msg 文件一定要有可执行权限,如果是代码创建,是没有可执行权限的,所以我这里采用的是将 commit-msg.sample 文件重命名为 commit-msg 的方式,这样就避免了还要额外手动增加权限的步骤,真正做到了自动化增加 git hook 的功能。
4. ProGuard 规则限制
这个是受“知乎APP”组件化方案的启发:“aar 中可以携带 ProGuard 规则,理论上来说,开发同学可以在自己组件中任意添加 ProGuard 规则并影响到主工程”。如果有人不小心这样配置:
-ignorewarnings
-dontwarn **
-keep class com.xx.** { *;}
这样将会产生很大的影响:一是盲目 keep 导致很多代码无法混淆压缩;二是盲目 dontwarn,导致很多警告被忽略无法发现,后果不堪设想。通过插件在编译时读取 ProGuard 配置文件,发现有不合规的配置,则直接终止打包,具体的检测规则有:
- 禁止使用
-ignorewarnings
; - 禁止使用
-dontwarn **
; - 包含我们业务的包名,限制 dontwarn 的范围,例如我们某个业务包名为
com.hjy.app
,则禁止使用-dontwarn com.hjy.app.**
; - 禁止使用
-keep class **
,这样一把梭太危险了; - 同样限制 keep 的范围,禁止使用类似
-kepp class com.hjy.app.* { *; }
,这样包含的范围太广了; - 禁止使用
-dontshrink
、-dontoptimize
,这是关于压缩性能优化的选项;
很多时候,我们在使用第三方依赖库时,有些会要求你一把梭全部无脑 keep,通过插件自动检测的方式,可以避免最终打包时采用了这些无脑的规则。
5. 打包选项自动移除不必要文件
我曾经在用 Kotlin 开发的过程中,会发现打出的 aar 会包含一个类似 META-INF/library_release.kotlin_module
的文件,当我集成打包时,发现不同的 aar 包中含有相同的 .kotlin_module 文件,这样会导致打包失败,这个时候通常的做法是在 build.gradle 文件中这样配置:
packagingOptions {
exclude 'META-INF/*.kotlin_module'
}
这完全可以在插件中自动实现,避免手动配置:
project.afterEvaluate {
com.android.build.gradle.BaseExtension android = project.extensions.getByName("android")
android.getPackagingOptions().exclude("META-INF/*.kotlin_module")
}
6. configuration 冲突
在配置依赖时,可以使用 copile、implementation、api
等等,其中 api 是 compile 的升级方式,功能基本一样。现在官方一直推荐使用 implementation ,它与 api 的核心区别是做了一些依赖隔离。举个栗子:如果一个依赖链是这样的:A -> B -> C,当采用 implementation 的方式依赖时,A 是不能直接访问 C 的。但是在实际使用过程中,发现使用 implementation 并没有带来很大的收益,反而带来很多问题,因此可以使用插件将 implementation 转换成 compile 或 api ,以后也不用关心它们的差别了。
7. 其他
除此之外,通过插件还可以做更多事情:
- 强制 lint,在代码发布前必须强制运行 lint;
- 限制第三方库的无节制引入,例如防止引入多个不同的图片加载框架;
- 检查重复资源等;
8. 插件使用
部分代码已经开源,github 地址:https://github.com/houjinyun/android-comm-config-plugin
系列文章
Android组件化开发实践(一):为什么要进行组件化开发?
Android组件化开发实践(二):组件化架构设计
Android组件化开发实践(三):组件开发规范
Android组件化开发实践(四):组件间通信问题
Android组件化开发实践(五):组件生命周期管理
Android组件化开发实践(六):老项目实施组件化
Android组件化开发实践(七):开发常见问题及解决方案
Android组件化开发实践(八):组件生命周期如何实现自动注册管理
Android组件化开发实践(九):自定义Gradle插件
Android组件化开发实践(十):通过Gradle插件统一规范