先来讲说为何会有这样一个题目吧。最近这大半年都在作项目crash收敛的事情,说到crash收敛,最简单的应该是Java相关的Crash了。在作的过程当中就发现,其实不少Java Crash的产生都是开发同窗犯的低级错误,好比数组越界、parseInt的裸调等等。那有没有一种方式能够避免开发同窗犯这样的错误呢?后来就尝试接入静态代码扫描。公司级的静态代码扫描有CodeDog和CodeCC,当时CodeCC不支持kotlin,就选择了CodeDog,而CodeDog上的规则能够避免一部分问题,但不少项目相关的问题规避须要自定义规则才能解决,而CodeDog在自定义规则上的支持并非特别友好。后来就开始调研如何本身作自定义规则,支持Kotlin的静态代码扫描工具主要有如下几种:html
由于咱们的项目实际上是使用了Kotlin和Java混合开发,项目中有至关一部分使用Java开发的代码,而lint能同时支持Java和Kotlin,因此最后咱们选择了lint。 在整个自定义lint规则的实践过程当中,咱们发现lint扫描的效率很是低,好比在项目中进行一次lint全量扫描,平均须要5分钟左右,并且这是在仅扫描自定义规则的状况下。
咱们将lint扫描集成到了编译流水线中,全部的MR操做都会触发扫描,并block住MR的流程。常常会发现这样一种状况,某个MR仅仅修改了一行代码,却仍要扫瞄整个项目,这会严重影响MR的效率。因此,大部分状况下并不须要进行lint的全量扫描,咱们更关心的是新增代码是否存在问题。因而,咱们须要探索一种lint增量扫描的解决方案。java
经过查阅相关资料,发现Google官方并无提供lint增量扫描能力,网上也没有相关的解决方案。因而只能本身动手,毕竟每次提交MR后要等好久的lint检查,实在不是一个很好的体验。咱们的目标主要有如下两点:android
Google虽然没有提供lint增量扫描的能力,可是在lint2.3.0版本之后,提供了一个baseline的功能。一开始我觉得这个就是增量扫描,但后了解后才发现,baseline本质上也是全量扫描,只不过baseline容许你建立一个基准问题集,以后全部的扫描结果集合会与基准问题集作对比,筛选出增量问题写入报告。所以,baseline的方案本质是没法提高扫描效率的。git
目前来讲,使用lint有如下几种方式:github
下面是几种使用方式的对比:api
功能 | Android Studio | AndroidGradlePlugin | lint命令行工具 |
---|---|---|---|
增量问题报告 | Yes | Yes(2.3.0之后) | No |
增量扫描 | Yes | No | No |
接入持续集成 | No | Yes | Yes |
Android Studio的方式能支持增量问题报告和增量扫描,可是没法应用到流水线中,且没法强制开发同窗人人去执行;AndroidGradlePlugin和命令行的方式,都能方便地继承到流水线中,可是它们都没法实现增量扫描,效率十分低下。所以,并无一种方式能够完美契合咱们的目标。既然如此,咱们能够以现有工具为基础,开发一款能增量扫描和展现问题,又能方便接入流水线的工具。数组
经过上面对三种现有lint使用方式的对比,发现AndroidGradlePlugin基于Gradle,是最易扩展到一种,所以,咱们决定在AndroidGradlePugin的基础上进行扩展,开发一款完美的lint工具。
其实增量扫描的解决思路很是简单: android-studio
下面来看下每一步如何实现。bash
目前大多数项目都采用git进行版本控制,因此寻找增量代码,能够简化为寻找两次git提交之间的版本差别。考虑到lint检查的最小单位是单个文件,因此咱们找到增量代码文件集合便可,而git diff命令恰好可以知足咱们的要求。app
// 计算两次commit之间的差别文件,diff-filter=d是指除删除意外全部状态的文件
git diff --name-only --diff-filter=d <commit-1> <commit-2>
// 计算两个分支之间的差别文件,适用于MR的增量扫描
git diff --name-only --diff-filter=d <branch-1> <branch-2>
复制代码
封装为工具方法以下:
// 计算两次git提交之间的差别文件
static List<String> diffFileListFromTwoCommit(String revision, String baseline, String filter) {
return filterInvalidLine(runCmd("git diff --name-only --diff-filter=$filter $revision $baseline").split('\n'))
}
// 计算两个git分支之间的差别文件
static List<String> diffFileListFromTwoBranch(String revisionBranch, String baselineBranch, String filter) {
return filterInvalidLine(runCmd("git diff --name-only --diff-filter=$filter $revisionBranch $baselineBranch").split('\n'))
}
// 执行命令
static String runCmd(String cmd) {
return Runtime.getRuntime().exec(self).text.trim().replaceAll("\"", "")
}
复制代码
想要对增量文件进行lint检查,首先须要弄清楚android的gradle插件自带的lint任务是如何进行代码扫描的。经过查阅源码,能够看到lint任务的执行流程以下:
private fun runFileDetectors(project: Project, main: Project?) {
...
if (scope.contains(Scope.JAVA_FILE) || scope.contains(Scope.ALL_JAVA_FILES)) {
val checks = union(
scopeDetectors[Scope.JAVA_FILE],
scopeDetectors[Scope.ALL_JAVA_FILES]
)
if (checks != null && !checks.isEmpty()) {
val files = project.subset
if (files != null) {
checkIndividualJavaFiles(project, main, checks, files)
} else {
val sourceFolders = project.javaSourceFolders
val testFolders = if (scope.contains(Scope.TEST_SOURCES))
project.testSourceFolders
else emptyList<File>()
val generatedFolders = if (checkGeneratedSources)
project.generatedSourceFolders
else emptyList<File>()
checkJava(project, main, sourceFolders, testFolders, generatedFolders, checks)
}
}
}
...
}
复制代码
其中:
因此从这里能够看出,增量扫描是能够实现的,只要project.subset不为空!那这个subset是哪里赋值的呢?这里让咱们来看下Project的源码:
/** * The list of files to be checked in this project. If null, the whole project should be * checked. * * @return the subset of files to be checked, or null for the whole project */
@Nullable
public List<File> getSubset() {
return files;
}
/** * Adds the given file to the list of files which should be checked in this project. If no files * are added, the whole project will be checked. * * @param file the file to be checked */
public void addFile(@NonNull File file) {
if (files == null) {
files = new ArrayList<>();
}
files.add(file);
}
复制代码
因此,若是在初始化Project的时候经过addFile方法添加过文件子集,咱们就能够进行代码增量扫描了。然而,咱们发现addFile这个方法,居然只在单元测试代码中调用过!因此这个能力google并无开放出来。那咱们须要本身想办法,在合适的时机将咱们经过git diff计算出来的增量文件路径,经过Project.addFile方法添加到Project.subset中,就能够完成增量扫描的任务了。那什么时机最合适呢?先看看Project的建立时机,在LintGradleClient的createLintRequest方法中:
@Override
@NonNull
protected LintRequest createLintRequest(@NonNull List<File> files) {
LintRequest lintRequest = new LintRequest(this, files);
LintGradleProject.ProjectSearch search = new LintGradleProject.ProjectSearch();
Project project =
search.getProject(this, gradleProject, variant != null ? variant.getName() : null);
lintRequest.setProjects(Collections.singletonList(project));
registerProject(project.getDir(), project);
for (Project dependency : project.getAllLibraries()) {
registerProject(dependency.getDir(), dependency);
}
return lintRequest;
}
复制代码
这是一个protected方法,因此咱们是否是能够继承LintGradleClient,重写createLintReqeust方法来完成增量文件的写入呢?如今思路清晰多了,因而咱们写了一个自定义的LintGradleClient:
public class LintGradleClient extends com.android.tools.lint.gradle.LintGradleClient {
public List<File> incrementFiles = null;
@Override
protected LintRequest createLintRequest(List<File> files) {
LintRequest request = super.createLintRequest(files);
if (request != null && incrementFiles != null) {
for (Project project: request.getProjects()) {
for (File file: incrementFiles) {
project.addFile(file);
}
}
}
return request;
}
}
复制代码
如何将LintGradleClient替换为咱们自定义的类呢?继续看源码,发现LintGradleClient的实例化发生在LintGradleExecution的analyze()->runLint()过程当中,可是这个过程并无很好的时机去替换LintGradleClient的实例化,怎么办?那继续看LintGradleExecution的建立时机,在ReflectiveLintRunner().runLint()方法中,源码以下:
fun runLint(gradle: Gradle, request: LintExecutionRequest, lintClassPath: Set<File>) {
try {
val loader = getLintClassLoader(gradle, lintClassPath)
val cls = loader.loadClass("com.android.tools.lint.gradle.LintGradleExecution")
val constructor = cls.getConstructor(LintExecutionRequest::class.java)
val driver = constructor.newInstance(request)
val analyzeMethod = driver.javaClass.getDeclaredMethod("analyze")
analyzeMethod.invoke(driver)
} catch (e: InvocationTargetException) {
...
} catch (t: Throwable) {
...
}
}
private fun getLintClassLoader(gradle: Gradle, lintClassPath: Set<File>): ClassLoader {
if (loader == null) {
...
val urls = computeUrlsFromClassLoaderDelta(lintClassPath)
?: computeUrlsFallback(lintClassPath)
loader = DelegatingClassLoader(urls.toTypedArray())
}
return loader
}
复制代码
看到这段代码的时候,我立马眼前一亮,以为这事妥了!这里作了一件什么事情呢:经过DelegateClassLoader去加载com.android.tools.lint.gradle.LintGradleExecution这个类,而后经过反射的方式来实例化LintExecution对象,传入一个LintExecutionRequest参数,并执行analyze方法。
这里假如咱们自定义一个LintGradleExecution类,并在这个类中使用咱们以前自定义的LintGradleClient实例替代官方的实例,就能够达到狸猫换太子的效果,完成增量扫描了。而LintGradleExecution这个类的实例化是经过ClassLoader动态加载完成的,这意味着,咱们能够hook这个ClassLoader加载类的过程,让其加载咱们自定义的LintGradleExecution类。
这里使用的DelegatingClassLoader其实是一个URLClassLoader,而URLClassLoader寻找类的原理,是在一个URL列表中按顺序寻找目标类,找到即止。所以,咱们能够将含有自定义类LintGradleExecution的url插入到url列表的最前面,这样在执行loader.loadClass("com.android.tools.lint.gradle.LintGradleExecution")时,加载到的class就是咱们自定义的类了。
那如何插入自定义的url?咱们能够看下DelegatingClassLoader的url列表是如何计算的:
private fun computeUrlsFallback(lintClassPath: Set<File>): List<URL> {
val urls = mutableListOf<URL>()
for (file in lintClassPath) {
val name = file.name
// The set of jars that lint needs that *aren't* already used/loaded by gradle-core
if (name.startsWith("uast-") ||
name.startsWith("intellij-core-") ||
name.startsWith("kotlin-compiler-") ||
name.startsWith("asm-") ||
name.startsWith("kxml2-") ||
name.startsWith("trove4j-") ||
name.startsWith("groovy-all-") ||
// All the lint jars, except lint-gradle-api jar (self)
name.startsWith("lint-") &&
// Do *not* load this class in a new class loader; we need to
// share the same class as the one already loaded by the Gradle
// plugin
!name.startsWith("lint-gradle-api-")
) {
urls.add(file.toURI().toURL())
}
}
return urls
}
复制代码
说白了,就是lintClassPath这个参数里全部的file转成List,而且file命名要符合"lint-"开头的规范。那lintClassPath是怎么来的?继续看源码:
lintTask.lintClassPath = globalScope.getProject().getConfigurations().getByName("lintClassPath");
复制代码
原来是取的一个名为"lintClassPath"的配置项下全部的依赖的集合,而"lintClassPath"配置项是在AndroidGradlePlugin配置阶段配置的,以下:
project.getDependencies().add("lintClassPath", "com.android.tools.lint:lint-gradle:" +
Version.ANDROID_TOOLS_BASE_VERSION);
复制代码
所以,咱们能够在整个gradle配置完成后,删除以上配置,新增咱们自定义的配置:
class LintPlugin implements Plugin<Project> {
@Override
void apply(Project project) {
...
project.getDependencies().add("lintClassPath", "com.tencent.nijigen:lint-nice-gradle:0.0.1")
...
}
}
复制代码
这样,DelegatingClassLoader在loadClass的时候,就会加载到咱们自定义的LintGradleExecution类,从而实例化自定义的LintExecutionClient,完成自定义lint检查。
经过上述分析,咱们能够完成lint任务的增量扫描了。可是咱们须要一个自定义Task,做为增量扫描的任务,能够方便的经过./gradlew lintIncrement的方式来触发增量扫描。
经过查阅源码,能够知道全部lint任务都有一个父类LintBaseTask,这个类封装了基本的lint任务的相关配置和执行操做。因此咱们能够继承LintBaseTask,派生一个LintIncrementTask子类,源码以下:
public class LintIncrementTask extends LintBaseTask {
private VariantInputs variantInputs;
@TaskAction
public void lint() {
// 读取参数
String baseline = "";
String revision = "";
if (project.hasProperty("revision") && project.hasProperty("baseline")) {
baseline = (String) project.property("baseline");
revision = (String) project.property("revision");
}
// 寻找变动文件集合
List<String> files = GitUtil.diffFileListFromTwoBranch(revision, baseline);
if (files.isEmpty()) {
getLogger().warn("no files was modified, skip lint incremental task");
} else {
LintCheckTaskDescriptor descriptor = new LintCheckTaskDescriptor();
descriptor.incrementFiles = files;
// 执行lint检查
runLint(descriptor);
}
}
public class LintCheckTaskDescriptor extends LintBaseTaskDescriptor {
List<File> incrementFiles;
...
public List<File> getIncrementFiles() {
return incrementFiles;
}
}
}
复制代码
可是当你执行./gradlew lintIncremnt -Prevision=xxxx -Pbaseline=xxxx命令时,会报错~嗯,理想很丰满,现实很骨感!参考Lint任务的其余实现,好比LintPerVariantTask,咱们能够发现,每一个lint任务都须要进行配置,以下:
public abstract static class BaseConfigAction<T extends LintBaseTask> implements TaskConfigAction<T> {
@NonNull private final GlobalScope globalScope;
public BaseConfigAction(@NonNull GlobalScope globalScope) {
this.globalScope = globalScope;
}
@Override
public void execute(@NonNull T lintTask) {
lintTask.setGroup(JavaBasePlugin.VERIFICATION_GROUP);
lintTask.lintOptions = globalScope.getExtension().getLintOptions();
File sdkFolder = globalScope.getSdkHandler().getSdkFolder();
if (sdkFolder != null) {
lintTask.sdkHome = sdkFolder;
}
lintTask.toolingRegistry = globalScope.getToolingRegistry();
lintTask.reportsDir = globalScope.getReportsDir();
lintTask.setAndroidBuilder(globalScope.getAndroidBuilder());
lintTask.lintClassPath = globalScope.getProject().getConfigurations()
.getByName(LINT_CLASS_PATH);
}
}
复制代码
经过源码发现,每一个Lint任务须要配置sdkHome/toolingregistry/androidBuilder等一系列Android环境相关的变量,而继续对这些变量进行追本溯源,发现它们是在AndroidGradlePlugin在配置阶段就已经设置好的,而且设置代码至关复杂。 我最开始的思路是针对每个变量,参考AndroidGradlePlugin的实现对其进行赋值,发现须要拷贝大量AndroidGradlePlugin里的代码实现,而且通过屡次尝试,总有赋值错误或者赋值不彻底的状况存在。为何这三个变量的设置会很是复杂呢?由于每一个变量的类型里又有不少其余的属性须要设置,层层嵌套以后,对这些属性赋值就变得异常繁琐。最终这种方案以失败了结。
有没有一种省时省力又不会出错的方案呢?固然有了。通过屡次尝试和摸索以后,我试着换了一种思路。由于LintIncrementTask和其余标准的LintTask同样,都是继承了LintBaseTask,因此说其余LintTask在配置完成后,都会将sdkHome/toolingregistry/androidBuilder等一系列变量都设置好,而自定义的LintIncrementTask的这些变量能和这些标准LintTask的变量值一致就能够了。后来想到gradle任务都有配置和执行两个阶段,而这些变量的设置都是在配置阶段完成的,因此在整个gradle的配置阶段完成后,取到标准LintTask的这些变量值,直接赋值给LintIncrementTask就行了!什么?你说这些变量值都是私有的,怎么取?哈哈,反射大法好呀。不废话,上代码:
public void config() {
Object lintTask = getProject().getTasks().getByName("lintDebug");
sdkHome = ReflectiveUtils.getFieldValue(LintBaseTask.class, "sdkHome", lintTask);
reportsDir = ReflectiveUtils.getFieldValue(LintBaseTask.class, "reportsDir", lintTask);
toolingRegistry = ReflectiveUtils.getFieldValue(LintBaseTask.class, "toolingRegistry", lintTask);
variantInputs = ReflectiveUtils.getFieldValue(LintPerVariantTask.class, "variantInputs", lintTask);
setAndroidBuilder(ReflectiveUtils.getFieldValue(AndroidBuilderTask.class, "androidBuilder", lintTask));
}
复制代码
完美搞定!如今就能够正常运行lintIncremnt任务了~
经过在项目中应用lint全量扫描和增量扫描,耗时数据对好比下:
本文主要讨论了在自定义lint规则框架的基础上,一种实现Lint增量扫描的解决方案,解决了以下两个问题:
lint 2.3.0新增的baseline能力,也能够实现lint问题的增量报告,可是其本质也是全量扫描,并不能提高扫描效率。所以在项目的实际应用中,能够结合baseline和本方案共同使用:对项目中遗留的暂时没有时间修复的大量lint问题,可使用baseline的功能,生成lint问题基准文件,同时应用本文介绍的方案,提高扫描效率。