Lint增量扫描适配AGP 7.0+源码分析记录

本文代码全部来自EaseLint,EaseLint基于AGP,适合中小型团队低门槛部署自定义扫描Lint服务的开源项目,由我和Neko一起维护,项目地址:github.com/Western-par…

适配AGP 7.x在实现上要比4.x要简单,只需要Hook LintRequest的setProject方法。不过在7.x版本中Lint执行的上游源码全变了,之前4.x的代码几乎无法复用,需要重头分析,所以本文重点整理记录下分析过程。

内容大纲

  • 版本说明
  • 如何找到Lint执行的源码,Debug进行阅读
  • AGP 7.x上可实现增量Lint的3个切入点

版本说明

本篇基于AGP 7.4.2 进行分析,因为7.4.2是7.x的最后一个版本,算是最稳定的版本,这样可避免再次适配7.x的其他版本。根据Android Lint API Guide文档说明,与AGP对应的“com.android.tools.lint”系列库的版本为 23+7,所以本次适配涉及的相关Lint库均为30.4.2版本。

如何找到Lint执行的源码,Debug进行阅读

锁定目标Task以及对应源码文件

打开 Android Studio 的gradle 窗口,找到 app>Tasks>verification:

一共13个与Lint相关的Task,当时心里只有一句:”AGP 7.x 你礼貌吗”,看看人家4.x的:

猛的一看确实不知如何下手,根据名字判断lintDebug应该是入口task,运行一下发现是对的。查看一下它的依赖,找到核心task。运行下面的命令:

./gradlew lintDebug --dry-run

log:
...other task
:app:lintAnalyzeDebug SKIPPED
:app:lintReportDebug SKIPPED
:app:lintDebug SKIPPED

到这就清楚了,看面相(名字)主角是 lintAnalyzeDebug没错了。下一步便是寻找lintAnalyzeDebug 的实现类。在 module 中下载 AGP的源码,用于检索。

compileOnly("com.android.tools.build:gradle:7.4.2")

这一步其实没啥好办法,我比较喜欢用下面的三种方式:

  • 1.double shift 输入 Lint 并勾选 include non-project items,此时会看到AGP中与Lint相关的类,碰上名字非常特殊的可以快速确认目标
  • 2.Gradle 的Task 命名通常是类名的一部分加上BuildType的名称,这里要找lintAnalyzeDebug的类,那么就可以double shift 尝试搜索输入 lintAnaly,因为涉及到时态导致单词尾部有变化,所以不要直接复制 lintAnalyze 进行搜索,最好是一个个字母输入查看检索结果。
  • 3.第三种有时候效率最高,根据2的逻辑得到task前半部分名称为lintAnalyze,直接使用google 搜索googlesourece lintAnalyze,就会查到如下内容:

以上3种方式经常需要切换使用,也都可以定位到AndroidLintAnalysisTask类。

Debug 源码,分析执行逻辑

简单阅读就能发现几个核心源码,为他们都打上断点:

AndroidLintAnalysisTask:
doTaskAction() {
...
    lintTool.submit(
        mainClass = "com.android.tools.lint.Main",
        workerExecutor = workerExecutor,
        arguments = generateCommandLineArguments(),
        android = android.get(),
        fatalOnly = fatalOnly.get(),
        await = false,
        lintMode = LintMode.ANALYSIS
    )
}

AndroidLintWorkAction:
override fun execute() {
...
val execResult = runLint(arguments)
logger.debug("Lint returned $execResult")
...
}
private fun invokeLintMainRunMethod(classLoader: ClassLoader, arguments: List<String>): Int {
// val returnValue: Int = Main().run(arguments.toTypedArray())
val cls = classLoader.loadClass(parameters.mainClass.get())
val method = cls.getMethod("run", Array<String>::class.java)
val lintMain = cls.getDeclaredConstructor().newInstance()
val returnValue = method.invoke(lintMain, arguments.toTypedArray()) as Int
}

对 lintDebug右键进行Debug,分析会得到两个比较关键的信息,一个是传递到Main.run到参数,另一个是 com.android.tools.lint.Main。

这里会断掉,因为Main是反射得来的,全局搜索也搜不到。这时候需要确认它在哪个 lib里,导入对应的lib才可以继续Debug。想要找到lib,就需要找到源码文件,看看它发布的group叫什么即可。

这时候采用第三种方式,搜索栏输入:googlesourece com.android.tools.lint.Main,找到配置文件为:

接下来确认对应的版本,因为Lint lib 都有很强的版本关联关系,通常使用一样的版本号。这时候最好是先去maven仓库看看有没有对应版本,打开 mvnrepository.com/ 搜索 com.android.tools.lint:lint 查找30.4.2版本:

在module中添加依赖,下载源码。

compileOnly("com.android.tools.lint:lint:30.4.2")

这个方法可以用于查找任何Android 相关开源代码所属的lib。

这时候使用方法2进入 com.android.tools.lint.Main.java ,找到run方法,打上断点,开始肆无忌惮的愉快的Debug。到这一步基本上就可以复用之前4.x的分析经验了,相关LintRequest,LintCliClient,LintDriver逻辑基本都没变。

AGP 7.x上实现增量Lint的3种方式

  • 为project.subset赋值

LintDriver与4.x一样,会根据project.getSubject是否有值来来进行定向扫描:

private fun checkClasses(project: Project, main: Project?) {
    val files = project.subset
    if (files != null) {
        checkIndividualClassFiles(project, main, files)
        return
    }
 ...   
 }

可以选择覆盖Main 或者LintRequest,以及其他任何在LintDriver扫面前可以为project.files赋值的点,都可以。

我这里选择的是LintRequest 的setProejct方法,并用JvmWideVariable实现外部插件传值:

JvmWideVariable 非常巧妙易用,有点迫不及待深入学习下了

fun setProjects(projects: Collection<Project>?): LintRequest {
fun setProjects(projects: Collection<Project>?): LintRequest {
    println("========= easeLint cover LintRequest =========")
    projects?.first()?.let {
        val unExistFiles = mutableListOf<String>()
        targetFiles.executeCallableSynchronously {
            targetFiles.get().forEach { path ->
                val file = File(path)
                if (file.exists()) {
                    it.addFile(file)
                } else {
                    unExistFiles.add(path)
                }
            }
        }
        if (unExistFiles.isNotEmpty()) {
            println("======== easeLint Can not found files:$unExistFiles")
            throw IOException("LintRequest:Can not found files:$unExistFiles")
        }
    }
    this.projects = projects
    println("======== easeLint projects.subset:${projects?.first()?.subset?.size} ========")
    return this
}

companion object {
    private val targetFiles: JvmWideVariable<ArrayList<String>> =
        JvmWideVariable(
            LintRequest::class.java,
            "targetFiles",
            object : TypeToken<ArrayList<String>>() {}
        ) { ArrayList() }
}

}

为了保证与原来的Main等com.android.tools.lint包下的类保持一致的ClassLoader,避免class实例不同,执行出错, 需要借助原来加载Main的classLoader,提前将目标自己生成的jar包加载进classPath中。这一步在AGP 7.x中尤为容易,因为它加载Main.class使用的是AndroidLintWorkAction的cachedClassloader属性

/**
 * Cache the classloaders across the daemon, even if the buildscipt classpath changes
 *
 * Use a soft reference so the cache doesn't end up keeping a classloader that isn't
 * actually reachable.
 *
 * JvmWideVariable must only use built in types to avoid leaking the current classloader.
 */
private val cachedClassloader: JvmWideVariable<MutableMap<String, SoftReference<URLClassLoader>>> = JvmWideVariable(
    AndroidLintWorkAction::class.java,
    "cachedClassloader",
    object: TypeToken<MutableMap<String, SoftReference<URLClassLoader>>>() {}
) { HashMap() }

注释说“即使构建脚本的类路径发生变化,也要在守护进程中缓存类加载器。”那不是意味可以提前在自己Plugin进行调用,完成变量共享?事实也确实如此,那你需要看它是如何创建的,提前把需要覆盖的class加载进去,即可覆盖系统的class。这里的关键变量是classpath,稍加Debug分析就可以发现是LintTool的,LintTool在AndroidLintAnalysisTask 可获取到。

AndroidLintAnalysisTask 实例可以通过project读取:

val lintAnalyzeDebug =
    project.tasks.getByName("lintAnalyze$buildType") as AndroidLintAnalysisTask
val lintTool = lintAnalyzeDebug.lintTool

在向classLoader提前从自己的jar中加载用于覆盖的LintRequest:

fun loadHook(lintTool: LintTool, project: Project, version: String) {
    val group = "com.easelint.snapshot"
    val name = "30.4.2-lint-api"
    // 创建 classLoader 将自己的 maven包排在最前面 先喂给 cachedClassloader
    val sysLintFiles: List<URI> = lintTool.classpath.files.map { it.toURI() }
    val config = project.configurations.detachedConfiguration(
        project.dependencies.create(
            mapOf(
                "group" to group,
                "name" to name,
                "version" to version,
            )
        )
    ).apply {
        isTransitive = true
        isCanBeResolved = true
    }
    val hookMavenConfig = LintFromMaven(config, version)
    val hookFiles = hookMavenConfig.files.files.map { it.toURI() }
    val summaryFiles = LinkedHashSet<URI>()
    summaryFiles.addAll(hookFiles)
    summaryFiles.addAll(sysLintFiles)

    val key = lintTool.versionKey.get()
    lintMainClassLoader = cachedClassloader.executeCallableSynchronously {
        val map = cachedClassloader.get()
        val classloader = map[key]?.get()?.also {
            logger.info("Android Lint: Reusing lint classloader {}", key)
        } ?: createClassLoader(key, summaryFiles.toList())
            .also { map[key] = SoftReference(it) }
        classloader
    }
    lintRequestClass =
        lintMainClassLoader.loadClass("com.android.tools.lint.client.api.LintRequest")
}

在lintDebug执行前关联一个自己的Task,用于抓取扫描文件,并设置给LintRequest:

abstract class EaseLintTask : DefaultTask() {
    companion object {
        const val TASK_NAME = "a1EaseLint"
        private val targetFiles: JvmWideVariable<ArrayList<String>> =
            JvmWideVariable(LintHookHelper.lintRequestClass,
                "targetFiles",
                object : TypeToken<ArrayList<String>>() {}) { ArrayList() }
    }
    
    @TaskAction
fun action() {
    targetFiles.executeCallableSynchronously {
        targetFiles.set(ArrayList<String>().apply {
            val files = LintSlot.finalTargets(project)
            if (files.isNullOrEmpty()) {
                add(zombieFile)
            } else {
                LintSlot.finalTargets(project).forEach {
                    add(it.absolutePath)
                }
            }

        })
    }
}
}

方案1整体上难度并不复杂,只是需要仔细Debug找到各个变量的初始化方式。在分析过程中我还发现如何获取全局参数,解决了后面想要动态修改lintOptions的需求:

// 修改checkOnly
project.configure<BaseAppModuleExtension> {
    lint.checkOnly.add("LogDetector")
    lint.checkOnly.add("ParseStringDetector")
    // disable 优先级最高
    // lint.disable.add("LogDetector")
}
project.afterEvaluate {
    val globalConfig = basePlugin.variantManager.globalTaskCreationConfig
    val lint = globalConfig.lintOptions
    val checkOnly = lint.checkOnly
    val disableIssue = lint.disable
}

  • 2.修改参数,移除–lint-model 指定扫描文件

在Debug 时可以发现,由AndroidLintAnalysisTask 生成一个参数列表传递到了Main.run。分析Main到源码会看到下面针对参数的判断逻辑:

private static LintRequest createLintRequest(
        LintCliClient client, ArgumentState argumentState) {
    LintRequest lintRequest;
    List<LintModelModule> modules = argumentState.modules;
    if (!modules.isEmpty()) {
        ...
        lintRequest = new LintRequest(client, Collections.emptyList());
    } else {
        lintRequest = client.createLintRequest(argumentState.files);
    }     
    return lintRequest;
}        

那么只要移除model参数,并添加files参数,是不是就达到了自定扫描目标了呢?在debug的时候尝试修改参数,确实可以完成执行。

想要在代码层面改参数可以直接Hook 整个AndroidLintAnalysisTask类,这样可以正常完成扫描,但是report 也需要同步改一下参数,保持一致。这一步我暂时没有尝试,这个项目原计划的时间也到了,要解决的应用问题还是挺多的,而且不知道是否完全可行。

  • 3.在Lint扫描的Detector中过滤出自定义文件

这方案是在美团外卖Android Lint代码检查实践中看到的,此方案是在扫描时反向过滤出不需要的文件,执行效率虽不如上面两种方式,但并不依赖AGP版本,兼容性极好。

原文摘录:

「只检查指定git commit之后新增的文件。在配置文件中添加配置项,给Lint规则配置git-base属性,其值为commit ID,只检查此次commit之后新增的文件。

实现方面,执行git rev-parse --show-toplevel命令获取git工程根目录的路径;执行git ls-tree --full-tree --full-name --name-only -r <commit-id>命令获取指定commit时已有文件列表(相对git根目录的路径)。在Scanner回调方法中通过Context.getLocation(node).getFile()获取节点所在文件,结合git文件列表判断是否需要检查这个节点。需要注意的是,代码量较大时要考虑Lint检查对电脑的性能消耗。」


滑到底部可点赞评论

浏览橘子树其他文章:橘子树的飞书写作记录

© 版权声明
THE END
喜欢就支持一下吧
点赞0

Warning: mysqli_query(): (HY000/3): Error writing file '/tmp/MY1tHcpP' (Errcode: 28 - No space left on device) in /www/wwwroot/583.cn/wp-includes/class-wpdb.php on line 2345
admin的头像-五八三
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

图形验证码
取消
昵称代码图片