本文代码全部来自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检查对电脑的性能消耗。」
滑到底部可点赞评论
浏览橘子树其他文章:橘子树的飞书写作记录