Lint增量扫描实践

Lint增量扫描实践

1. 背景

在上一篇Android Lint代码检查实践中说到了Lint全量扫描项目的耗时在3.5m,执行时机是在mr的时候,所以在大多数时候,不会因为Lint检查阻塞开发流程。

但是,特殊情况下,比如你只提交了几行代码需要mr的时候,review只需要10秒完事了,而Lint检查却需要3.5m,这个时候你就需要浪费宝贵的3分多钟进行等待,这种事情是我不希望看到的,本着对极致的追求,我决定支持增量扫描的功能,来压缩Lint扫描的时间。

老规矩先看下成果,不然被我骗着做完后发现没卵用怎么办,滑稽脸。

效果是不是很显著,从3分30秒压缩到了30秒左右,23333成功勾起了各位看官的欲望,接下来看看怎么实现的吧。

先来波广告上篇文章中所有功能和本篇的增量扫描都在AndroidLint实现了,欢迎star。

2. 怎么做

2.1 如何找到变更文件

先从简单的入手,对于找到变更文件我们可以通过git diff命令,git diff支持两个分支或者不同commit节点等方式对比修改。我这里的命令是git diff $baseline $revision --name-only --diff-filter=ACMRTUXB--name-only是只展示文件名,--diff-filter=ACMRTUXB是用来过滤掉删除的文件只要增改的文件,其他的不过多赘述官方文档有详细说明。

2.2 增量扫描思路的形成

变更文件找到了,接下来是做Lint增量扫描,首先想到的肯定是去看LintOptions有没有提供这个功能,很遗憾没有,那也就意味着需要自定义了,但我们并不知道怎么起手,所以就只能先看看AGP提供的LintTask怎么做的。最好情况是他有提供这个功能只是没有开放api给我们,那反射反射程序员的快乐一下就O了,最差的就是得照着他源码自己手撸一套有增量扫描功能的LintTask,这里我直接给出结果,AGP提供的LintTask有这个功能只是没开放api。

接下来我们将debug一遍LintTask执行流程,看看如何开启增量扫描功能,(ps:源码全贴的话特别多不容易抓住重点,所以非重点的就直接展示调用流程了,重点的地方在贴源码)

2.3 Debug Lint Task

LintTask默认有三个实现类

不管哪个都是调到LintBaseTask#runLint()进行lint扫描,具体调用流程如下:

LintBaseTask#runLint()

ReflectiveLintRunner#runLint()

LintGradleExecution#analyze()

LintGradleExecution#runLint()//这里其实跳了一步,三个Task实现类稍有不同但都会调到这

LintGradleClient#run()

LintCliClient#run()

LintDriver#analyze()

LintDriver#checkProject()

LintDriver#runFileDetectors()

我们着重看LintDriver#runFileDetectors()对源码检测的部分

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) {//如果project.subset不为空
                    checkIndividualJavaFiles(project, main, checks, files)//则进行自定义文件的扫描
                } 
            }
        }
  ...
}

可以看到他在project.subset不为null的时候进行自定义文件的扫描,那么我们要做的就是将变更的文件插入到Project中,于是来看Project.subset取的什么

    /**
     * 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);
    }

    /**
     * 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;
    }

注释我特意没删,上面明确说明了如果getSubset()返回值不为null将只对files中的文件扫描,再次印证了我们方向没错,而addFile只在单元测试的时候被调用了,所以默认情况下是全量扫描,那我们要做的是在Project创建后调用addFile将变更文件传入来实现增量扫描。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);//创建Project
        lintRequest.setProjects(Collections.singletonList(project));

        registerProject(project.getDir(), project);
        for (Project dependency : project.getAllLibraries()) {
            registerProject(dependency.getDir(), dependency);
        }

        return lintRequest;
    }

那其实只要我们拿到LintRequest然后遍历Project调用addFile传入变更文件即可,但一路debug下来会发现基本都是通过局部变量传递没有能反射修改的点。

到此全剧终!!!我逗你的。

在调用链的第二个方法ReflectiveLintRunner#runLint()中是通过创建ClassLoader去加载的LintGradleExecution类进行后面的操作

    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) {
            if (e.targetException is GradleException) {
                // Build error from lint -- pass it on
                throw e.targetException
            }
            throw wrapExceptionAsString(e)
        } catch (t: Throwable) {
            // Reflection problem
            throw wrapExceptionAsString(t)
        }
    }

看到ClassLoader是不是有一阵窃喜,也就意味着后面用到的类,很有可能都是在这个时候才加载进来,那么我们只要像Tinker那样,自己造一个LintGradleClient类插入到ClassLoader数组的最前面,稍微修改下createLintRequest方法,在方法中加上一段逻辑,遍历Project再调用addFile传入变更文件就完成了增量扫描的工作。

那接下来就是确认LintGradleClient是由该ClassLoader加载么,我Debug确认正是。

然后我们在看该ClassLoader是什么类型的类加载器,确认我们能不能把类插入到他的最前面,这里就不看源码了,直接给出答案是URLClassLoader,这种类加载器是通过URL数组去加载类,那目标很明确了,就是把我们修改过的LintGradleClient插入到URL数组的前面就好了。

如果你对URLClassLoader源码感兴趣的话可以看这篇博客

2.4 代码插入

这里有两种思路,第一个是拿到ClassLoader的URL数组把我们的LintGradleClient加入在数组最前面,debug发现该ClassLoader并不好获取暂且搁置,第二个是看该ClassLoader构造时传入的URL数组怎么生成的,是来源于getProject().getConfigurations().getByName(LINT_CLASS_PATH),那找到Lint_Class_Path的赋值处

    public static void createLintClasspathConfiguration(@NonNull Project project) {
        Configuration config = project.getConfigurations().create(LintBaseTask.LINT_CLASS_PATH);
        config.setVisible(false);
        config.setTransitive(true);
        config.setCanBeConsumed(false);
        config.setDescription("The lint embedded classpath");

        project.getDependencies().add(config.getName(), "com.android.tools.lint:lint-gradle:" +
                Version.ANDROID_TOOLS_BASE_VERSION);
    }

也就是通过project.getDependencies().add(LintBaseTask.LINT_CLASS_PATH, $依赖)就可以将代码插入URL数组。

接下来我们copy一份LintGradleClient代码,只修改createLintRequest方法,在方法中加上一段逻辑,遍历Project再调用addFile传入变更文件。

public class LintGradleClient extends LintCliClient{
  	...
    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);
    }
    
    IncrementUtils.inject(gradleProject, lintRequest);//增量扫描逻辑

    return lintRequest;
  }
  ...
}

变更文件插入代码如下

class IncrementUtils {

    companion object {

        const val TAG = "lint增量信息"

        @JvmStatic
        fun inject(project: Project, lintRequest: LintRequest) {
            //增量扫描逻辑
            printSplitLine(TAG)
            var revision = project.properties["revision"]
            var baseline = project.properties["baseline"]

            val command =
                "git diff $baseline $revision --name-only --diff-filter=ACMRTUXB"
            println("开始执行:")
            println(command)
            val byteArray = Runtime.getRuntime()
                .exec(command)
                .inputStream
                .readBytes()
            val diffFileStr = String(byteArray, Charsets.UTF_8)
            val diffFileList = diffFileStr.split("\n")

            println("diff结果:")
            println(diffFileStr.removeSuffix("\n"))
            lintRequest.getProjects()?.forEach { p ->
                diffFileList.forEach {
                    p.addFile(File(it))
                }
            }
            printSplitLine(TAG)
        }
    }
}

fun printSplitLine(tag: String) {
    println("--------------------------------------------日志分割线:$tag--------------------------------------------")
}

然后把这两个类作为一个工程上传jcenter,在通过project.getDependencies().add(LintBaseTask.LINT_CLASS_PATH, $依赖)加入就插入到了URLClassLoader的URL数组中。

那此刻你可能有个问题,如何保证你加入的LintGradleClient能插在URL数组最前面,首先Dependencies这个map是LinkedHashMap能记录插入顺序,其次apply plugin: 'com.android.application'我们一般都是写在Gradle脚本的最上面的,所以他的config方法是最先执行的,如果你不放心可以在配置完成后在执行project.getDependencies().add(LintBaseTask.LINT_CLASS_PATH, $依赖)这样替换的类就能保证一定会在URL数组的第一个。

2.5 Task入口

通过上面的步骤增量扫描功能基本就完成了,现在只差一个入口了,这里就可以参照Lint的LintPerVariantTask,写一个自己的LintTask,基本照着他写就好了,注释都有我就不过多说明了

open class LintTask : LintBaseTask() {

    private var allInputs: ConfigurableFileCollection? = null
    private var variantName: String? = null
    private var variantInputs: VariantInputs? = null

    @InputFiles
    @Optional
    open fun getAllInputs(): FileCollection? {
        return allInputs
    }

    @TaskAction
    fun lint() {
        val descriptor = object : LintBaseTaskDescriptor() {

            /**
             * com.android.tools.lint.gradle.LintGradleExecution#analyze会判断
             */
            override val variantName: String? = this@LintTask.variantName

            /**
             * com.android.tools.lint.gradle.LintGradleExecution#lintSingleVariant用来作为lint扫描参数
             */
            override fun getVariantInputs(variantName: String): VariantInputs? = variantInputs

        }

        runLint(descriptor)
    }

    open class CreationAction(
        private val taskName: String,
        private val scope: VariantScope,
        private val variantScopes: List<VariantScope>
    ) : BaseCreationAction<LintTask>(scope.globalScope) {
        override val name: String = taskName

        override val type: Class<LintTask> = LintTask::class.java

        override fun configure(task: LintTask) {
            super.configure(task)
            task.apply {
                variantName = scope.fullVariantName//lint检测时会判断有没有该值,必须有
                variantInputs = VariantInputs(scope)//lint检测时会取该值,必须有
                allInputs = scope.globalScope.project.files()
                    .from(this.variantInputs!!.allInputs)//gradle增量任务

                for (variantScope in variantScopes) {//不知道干嘛的,反正是模拟LintPerVariantTask就直接照抄了
                    addJarArtifactsToInputs(allInputs, variantScope)
                }
                description = "run lint scanner"
            }
        }

    }
}

最后既然是找到增量代码,是需要有两个分支做对比的,我这边是通过命令入参的,

执行./gradlew lintTask -Pbaseline="xxx" -Prevision="xxx"即可进行增量扫描。

如果不知道Gradle怎么获取命令行参数可以参照下面这个图

3. 注意点

3.1 Ci缓存问题clean策略

如果你已经支持了增量扫描,但是时间还是很长,先别打我,请注意是不是因为CI在执行新流水线的时候执行了git clean把Gradle缓存给删了导致Gradle所有Task全部重新执行了遍。

3.2 gradle版本适配问题

由于我们代码是基于AGP去自定义的,所以必然存在适配的问题,目前我是参照AGP3.5.3的代码自定义的,那么后面AGP升级后,就意味着可能需要修改来适配新版AGP。

4. Demo

上面所述所有功能都可以参照AndroidLint中的Plugin模块,有不明白的地方欢迎留言提问。

5. 参考

©️2020 CSDN 皮肤主题: 编程工作室 设计师:CSDN官方博客 返回首页