Lint 自定义规则开发,我写完的第一条规则
Lint 自定义规则开发,我写完的第一条规则
从一次代码审查的重复劳动说起
去年下半年我们团队做了一次模块化重构,把原本耦合在 App 模块里的网络请求逻辑拆到了独立的 network 模块里。拆完之后我负责审查迁移后的代码,结果连续三天都在做同一件事:在 PR 里留评论,"这里不要用 Gson().toJson(),用我们模块里封装的 JsonSerializer"。
问题很典型。Gson 直接暴露在外部模块里,任何人都能 new Gson() 或者调用 Gson().toJson(),但我们的 JsonSerializer 做了统一配置:自定义了日期格式、处理了 Kotlin 的 data class 空安全、还加了日志拦截。重构的目的就是让调用方收敛到统一入口,但口头约束和 Code Review 显然挡不住。
我一开始想的是用 Kotlin 的 DSL 或者 typealias 做限制,但 Gson 是第三方库,没法直接禁止导入。后来想到 Detekt,但它主要是 Kotlin 代码风格检查,对 Java 互调场景和特定 API 调用点的拦截不够精确。最后绕回 Android Lint,发现这玩意虽然文档零散,但确实能做这件事:写一个自定义 Detector,在编译期扫描到 Gson#toJson 调用就直接报错。
这就是我写第一条自定义 Lint 规则的动机。不是出于兴趣,是被重复审查逼的。
环境搭建:Android Gradle Plugin 7.4 的坑
Lint 规则的代码需要放在一个独立的 Java/Kotlin 模块里,最终打包成 jar 供主工程引用。Google 官方推荐用 com.android.lint 插件,但这里有个版本陷阱。
我一开始按 2021 年的某篇博客配了 build.gradle:
apply plugin: 'com.android.lint'结果 Gradle sync 直接报错:Plugin with id 'com.android.lint' not found。查了半天发现,Android Gradle Plugin 7.0 之后这个插件 ID 变了,或者说更准确地说,它从未以这个形式发布到 Gradle Plugin Portal,而是通过 com.android.tools.lint 的 Maven 坐标引入。正确的做法是直接在模块的 build.gradle 里用 java-library 插件,然后依赖 lint-api、lint-checks 等库:
plugins {
id 'java-library'
id 'org.jetbrains.kotlin.jvm'
}
dependencies {
compileOnly 'com.android.tools.lint:lint-api:30.4.0'
compileOnly 'com.android.tools.lint:lint-checks:30.4.0'
testImplementation 'com.android.tools.lint:lint-tests:30.4.0'
}注意这里用的是 compileOnly,因为 Lint 规则在 Android Studio 或 Gradle 构建时由已经内置的 Lint 运行时加载,不需要打包进产物。30.4.0 对应 AGP 7.4 系列的 Lint 版本,这个版本号必须和项目用的 AGP 版本大致匹配,否则可能出现 API 不兼容。我试过把 lint-api 升到 31.1.0(AGP 8.1)而项目还在用 AGP 7.4,结果运行时 ClassNotFoundException,因为 AGP 7.4 内置的 Lint 运行时找不到高版本 API 里新加的类。
模块建好后,要在 src/main/resources/META-INF/services/ 下放一个注册文件,文件名叫 com.android.tools.lint.client.api.IssueRegistry,内容是你的 IssueRegistry 实现类全名。这个 SPI 机制是 Lint 发现自定义规则的唯一途径,漏掉的话规则永远不会被加载。我第一次跑测试时规则没生效,排查了半小时才发现是资源目录路径写错成了 META-INF.service(点而不是斜杠),Gradle 不会报错,只是静默忽略。
第一个 Detector:从 AST 到调用点
Lint 规则的核心是 Detector,我需要检测的是方法调用,所以继承 SourceCodeScanner。这个接口要求实现 getApplicableMethodNames 返回你关心的方法名,然后 Lint 会在扫描到这些方法调用时回调 visitMethodCall。
class GsonUsageDetector : Detector(), SourceCodeScanner {
override fun getApplicableMethodNames(): List<String> = listOf("toJson", "fromJson")
override fun visitMethodCall(context: JavaContext, node: UCallExpression, method: PsiMethod) {
val evaluator = context.evaluator
if (evaluator.isMemberInClass(method, "com.google.gson.Gson")) {
context.report(
ISSUE,
node,
context.getLocation(node),
"禁止直接调用 Gson 方法,请使用 JsonSerializer"
)
}
}
}这里有几个细节值得展开。
UCallExpression 是 Lint 基于 IntelliJ UAST(Universal AST)的抽象,统一了 Java 和 Kotlin 的调用表示。PsiMethod 则是 IntelliJ PSI(Program Structure Interface)里的方法定义。context.evaluator 提供了一堆工具方法,比如 isMemberInClass 用来判断方法是否属于指定类。我一开始尝试用 method.containingClass?.qualifiedName == "com.google.gson.Gson" 做判断,这在 Kotlin 调用场景下偶尔失效,因为 UAST 对扩展函数或某些内联场景的类解析不够稳定。evaluator.isMemberInClass 是官方推荐的方式,内部处理了更多边界情况。
context.report 的第二个参数 node 是报错关联的 AST 节点,第三个 location 决定 IDE 里红线画在哪里。context.getLocation(node) 会高亮整个调用表达式,如果只想标方法名部分,可以用 context.getNameLocation(node),这在长链式调用里更精确。
Issue 的定义长这样:
val ISSUE = Issue.create(
id = "GsonDirectUsage",
briefDescription = "禁止直接调用 Gson",
explanation = "统一使用 JsonSerializer 处理序列化,避免配置不一致",
category = Category.CORRECTNESS,
priority = 5,
severity = Severity.ERROR,
implementation = Implementation(
GsonUsageDetector::class.java,
Scope.JAVA_FILE_SCOPE
)
)id 必须全局唯一,如果和其他库冲突,Lint 会抛异常。Severity.ERROR 会让构建直接失败,改成 WARNING 则只提示不阻断。Scope.JAVA_FILE_SCOPE 表示扫描 Java 和 Kotlin 源文件,Lint 还有 Scope.CLASS_FILE_SCOPE 用于扫描字节码(比如检查第三方库里的问题),但自定义规则一般用源文件就够了。
Kotlin 的陷阱:属性访问器和方法调用
规则写好跑测试,Java 文件里 new Gson().toJson(obj) 确实被拦住了。但切换到 Kotlin,发现 val gson = Gson(); gson.toJson(obj) 也能检测到,而 Gson().toJson(obj) 这种直接构造的写法有时候漏报。更隐蔽的是,Kotlin 允许 val json = Gson().toJson(obj) 这种表达式作为赋值,UAST 解析时 UCallExpression 的父节点链和 Java 略有不同。
真正让我调试了一晚上的是这种情况:有人在 Kotlin 里写了 val gson = Gson() 作为属性,然后在 init 块里调用 gson.toJson()。Lint 的 visitMethodCall 确实触发了,但 evaluator.isMemberInClass 返回了 false。最后翻 IntelliJ 社区论坛才发现,Kotlin 属性的初始化表达式在某些编译版本下会被解析为 KtProperty 而非 UField,导致 containingClass 的推导路径断裂。 workaround 是再加一层判断:
val receiver = node.receiver
if (receiver is UReferenceExpression) {
val resolved = receiver.resolve()
if (resolved is PsiVariable) {
val type = resolved.type
if (type.canonicalText == "com.google.gson.Gson") {
// 额外确认
}
}
}这段代码通过接收者表达式的类型再做一次校验,虽然冗余,但覆盖了 isMemberInClass 失灵的 case。Lint API 的这种不确定性很烦人,官方文档几乎没有提及,全靠 issue tracker 和源码阅读。
测试:Lint 的单元测试框架
Lint 提供了专门的测试框架,不用起整个 Gradle 构建。写法是构造 TestFile 然后让 LintDetectorTest 的子类去跑。
class GsonUsageDetectorTest : LintDetectorTest() {
override fun getDetector() = GsonUsageDetector()
override fun getIssues() = listOf(ISSUE)
fun testJavaUsage() {
val java = java("""
package test;
import com.google.gson.Gson;
public class Test {
void foo() {
new Gson().toJson(new Object());
}
}
""").indented()
lint().files(java)
.run()
.expect("""
src/test/Test.java:5: Error: 禁止直接调用 Gson 方法,请使用 JsonSerializer [GsonDirectUsage]
new Gson().toJson(new Object());
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1 errors, 0 warnings
""")
}
}LintDetectorTest 是抽象基类,需要实现 getDetector 和 getIssues。java(...) 和 kotlin(...) 是框架提供的 DSL,会自动处理缩进和包路径。expect 里的字符串必须和实际输出完全匹配,包括空格和换行,否则测试失败。我一开始被这个搞得很崩溃,因为 IDE 自动格式化会破坏预期字符串的缩进。后来学会用 .trimIndent() 或者把预期输出单独放资源文件里。
更实用的功能是 expectFixDiffs,可以测试 Lint 提供的 Quick Fix。但我第一条规则没做自动修复,因为替换为 JsonSerializer 需要知道调用处的上下文(比如有没有 JsonSerializer 的 import、需不需要从 DI 容器获取实例),写死替换字符串反而危险。
测试框架还支持 TestFiles.gradleProject 模拟多模块结构,验证 lintPublish 或 lintChecks 的配置是否正确传递。这个我后来写第二条规则时才用上,第一条规则只做了单模块测试。
集成到构建:lintPublish vs lintChecks
规则模块写完后,要让它在 App 构建时生效。有两种方式。
lintChecks 是把规则 jar 作为当前模块的 Lint 检查依赖,只对本模块生效:
dependencies {
lintChecks project(':lint-rules')
}lintPublish 则是把规则打包成可供外部消费的 AAR/库,通常用于发布公共 Lint 规则库:
dependencies {
lintPublish project(':lint-rules')
}我一开始用了 lintPublish,因为觉得"规则是公共的"。结果构建时规则根本没跑,查文档才知道 lintPublish 只在库模块发布时把规则打包进去,App 模块作为消费者不会自动应用。App 模块应该用 lintChecks。这个命名确实很误导人,Google 的 issue tracker 上有不少人在问这个区别。
还有一个细节:如果规则模块本身用了 Kotlin,而消费模块是纯 Java 模块,要确保规则模块的 targetCompatibility 和 Kotlin 版本与消费模块兼容。我遇到过 NoClassDefFoundError: kotlin/jvm/internal/Intrinsics,因为 App 模块没引入 Kotlin 标准库,而 Lint 规则 jar 里依赖了。解决方式是在规则模块的 build.gradle 里把 Kotlin 标准库也声明为 compileOnly,或者确保消费模块有 Kotlin 插件。
实际运行中的意外:增量编译和缓存
规则集成后,本地构建确实能拦住 Gson().toJson() 了。但 CI 上偶尔出现漏报,本地又复现不了。排查发现是 Gradle 的增量编译和 Lint 缓存的交互问题。
Lint 在 AGP 7.4 默认启用 android.lint.useWorkerApi,会把 Lint 任务分发到 Gradle Worker 进程。增量编译时,如果某个源文件没变更,Lint 可能跳过对它的分析,但依赖它的文件如果新增了对 Gson 的调用,增量分析有时会漏掉跨文件的调用链。更坑的是 lintVitalRelease 和 lintRelease 的缓存不共享,前者是打包时强制运行的精简检查,后者是显式执行 ./gradlew lint 的完整检查,两者缓存键不同。
我最后的 workaround 是在 CI 里显式 clean 后再跑 Lint,牺牲时间换正确性。AGP 8.0 之后 Lint 的增量分析重写过一次,据说改善了这个问题,但我们项目升级 AGP 8 的迁移成本太高,暂时没验证。
规则的边界:什么时候该放弃 Lint
第一条规则上线后,我又陆续写了几个:检查 Log.d 必须用封装过的 Logger、检查 SharedPreferences 直接调用必须用 DataStore 封装层、检查 Handler() 无参构造必须用 Looper 显式版本。但写到第五条时我开始反思,Lint 不是万能的。
有些约束用 Kotlin 的可见性控制就能解决,比如把 Gson 的依赖声明为 implementation 而非 api,然后让 JsonSerializer 所在模块对外暴露,这样其他模块根本拿不到 Gson 的类引用。但我们项目历史包袱重,Gson 已经在公共模块里暴露了好几年,几十个模块直接依赖,改可见性会触发连锁重构。Lint 规则成了这种历史债务的绷带。
还有性能问题。Lint 规则在大型项目里会显著拖慢构建时间。我统计过,加了 5 条自定义规则后,lintDebug 任务从 3 分钟涨到 4 分 20 秒,增量场景下每次 Kotlin 文件修改后的 Lint 分析多了 8 秒左右。SourceCodeScanner 虽然比 ClassScanner 快,但 visitMethodCall 对每个方法调用都触发,如果规则里做了大量 resolve() 调用(解析符号到定义),会触发 PSI 的深层解析,很慢。我后来把 getApplicableMethodNames 从 listOf("toJson", "fromJson") 收窄到只检测 toJson,因为 fromJson 的重载太多,且实际违规频率低,这样省了约 15% 的时间。
Lint 规则的维护成本也不低。AGP 升级时 API 可能变动,比如 30.4.0 到 31.0.0 之间 UCallExpression 的继承结构变了,getMethodName() 的返回值在 Kotlin 的 invoke 约定场景下行为不同。这些不兼容不会在你升级 AGP 时显式报错,而是规则静默失效,直到某天 Code Review 发现违规代码漏网。
一条规则的完整代码与反思
回头看第一条规则,代码量其实很少,核心 Detector 不到 30 行,但 surrounding 的调试、测试、集成、版本兼容处理花了整整两个工作日。这个投入产出比是否值得?对于"禁止 Gson 直接调用"这种高频违规,肯定是值的。我们模块重构后的第一个月,这条规则拦截了 12 次 PR 里的违规提交,之后逐月下降到 2-3 次,说明约束逐渐被团队内化。
但如果只是为了"代码更规范"这种模糊目标写 Lint 规则,大概率是浪费。Lint 适合的是:有明确的替代方案、违规频率高、人工审查容易遗漏、且不能通过语言特性或架构隔离解决的场景。
最后放下这条规则的完整代码,包括我后来加的 Quick Fix(虽然实际没启用,因为替换逻辑太上下文依赖):
@Suppress("UnstableApiUsage")
class GsonUsageDetector : Detector(), SourceCodeScanner {
override fun getApplicableMethodNames(): List<String> = listOf("toJson")
override fun visitMethodCall(context: JavaContext, node: UCallExpression, method: PsiMethod) {
if (!context.evaluator.isMemberInClass(method, "com.google.gson.Gson")) {
// 二次校验,处理 Kotlin 属性场景
val receiver = node.receiver as? UReferenceExpression ?: return
val resolved = receiver.resolve() as? PsiVariable ?: return
if (resolved.type.canonicalText != "com.google.gson.Gson") return
}
val fix = fix()
.replace()
.all()
.with("JsonSerializer.toJson(/* migrate manually */)")
.build()
context.report(
ISSUE,
node,
context.getLocation(node),
"禁止直接调用 Gson.toJson,请使用 JsonSerializer.toJson()",
fix
)
}
companion object {
val ISSUE = Issue.create(
id = "GsonDirectUsage",
briefDescription = "禁止直接调用 Gson",
explanation = """
直接调用 Gson 会绕过我们统一的序列化配置,包括:
- 日期格式处理
- Kotlin 空安全适配
- 请求日志关联
请始终使用 network 模块提供的 JsonSerializer。
""",
category = Category.CORRECTNESS,
priority = 6,
severity = Severity.ERROR,
androidSpecific = false,
implementation = Implementation(
GsonUsageDetector::class.java,
Scope.JAVA_FILE_SCOPE
)
)
}
}androidSpecific = false 表示这条规则也适用于纯 Java/Kotlin 模块(比如我们的 network 模块本身不是 Android 库)。priority 的数值影响 Lint 报告里的排序,6 比默认的 5 稍高,让这条规则在 IDE 的 Problems 面板里更靠前。
写完后我最大的感受是:Lint 自定义规则的能力很强,但 Google 的文档和工具链支持配不上这个能力。大部分知识散落在 X 上的帖子、IntelliJ 社区的考古帖、以及 lint-checks 自己的源码测试里。如果你也打算写,建议直接从 lint-tests 的测试用例学起,比任何文档都准确。