深入 Android 自定义 Lint 规则全链路:从 UAST 语法树到 Detector 检测器的编译期代码规范自动化实战

某个周二下午,Code Review 里第三次出现同一个问题:有人把 LiveData 直接暴露给了 Fragment。规范文档写了、周会上强调过、甚至还录了视频教程——但人总会疏忽。

当时我决定把这条规则写进 Lint,让 IDE 在编译期直接报错。

为什么是自定义 Lint

Android Lint 是 Google 提供的静态代码分析工具,内置 400 多条检查规则。它的核心价值不在于检查本身,而在于检查的时机——编译期,远早于 Code Review 和 CI。

自定义 Lint 实际能覆盖的场景相当宽:

  • 架构约束:禁止模块间直接依赖、限制 API 调用链
  • 性能规范:检测主线程 IO、强制使用指定线程池
  • 命名约定:要求特定包下类名必须带后缀
  • 资源规范:禁止硬编码字符串、检查图片尺寸

我们项目里维护了 20 多条自定义规则,覆盖了从模块依赖到命名约定的方方面面。代码规范不应该依赖人的自觉性。

从 UAST 说起

Android Lint 的检测基于 UAST(Universal Abstract Syntax Tree),它是 JetBrains 对 PSI(Program Structure Interface)的一层抽象,跨 Java 和 Kotlin。

PSI 是 IntelliJ 平台的原生 AST 表示。Java 和 Kotlin 的 AST 节点类型完全不同——UAST 在它们之上做了统一封装,让你用一套 API 同时处理两种语言的代码。

// Java 代码
person.setName("Alice");

// Kotlin 代码
person.name = "Alice"

这两行代码在 PSI 层面是完全不同的节点类型,但通过 UAST,你都能拿到 UCallExpression,用 receivermethodName 来判断调用目标。

使用 UAST 时不用纠结底层是 Java 还是 Kotlin,把一切当成统一的方法调用和属性访问来处理。大部分场景下,UElementUCallExpressionUReferenceExpression 这三个就够用了。

构建一个实际的 Detector

以一个真实场景为例:禁止 Fragment 直接持有 MutableLiveData 引用,强制通过 ViewModel 暴露不可变类型。

定义 Issue

val EXPOSE_MUTABLE_LIVE_DATA = Issue.create(
    id = "ExposeMutableLiveData",
    briefDescription = "禁止暴露 MutableLiveData",
    explanation = """
        MutableLiveData 应封装在 ViewModel 内部,
        对外只暴露 LiveData 类型,防止外部直接修改数据。
    """,
    category = Category.CORRECTNESS,
    priority = 8,
    severity = Severity.ERROR,
    implementation = Implementation(
        MutableLiveDataExposeDetector::class.java,
        Scope.JAVA_FILE_SCOPE
    )
)

priority 从 1 到 10,越高越严重。Scope.JAVA_FILE_SCOPE 按文件粒度分析,大多数场景下这是最合适的选择。

实现 Detector

class MutableLiveDataExposeDetector : Detector(), SourceCodeScanner {

    override fun getApplicableUastTypes(): List<Class<out UElement>> {
        return listOf(UField::class.java)
    }

    override fun createUastHandler(context: JavaContext): UElementHandler {
        return object : UElementHandler() {
            override fun visitField(node: UField) {
                val type = node.type ?: return
                if (!type.canonicalText.contains("MutableLiveData")) return
                if (node.isPrivate) return  // 私有字段放行

                context.report(
                    issue = EXPOSE_MUTABLE_LIVE_DATA,
                    scope = node,
                    location = context.getLocation(node),
                    message = "将 MutableLiveData 暴露为 ${node.visibility} 可见," +
                            "应改用 LiveData 或限制为 private"
                )
            }
        }
    }
}

getApplicableUastTypes 返回你关心的 UAST 节点类型。Lint 框架遍历 AST 时遇到匹配的节点就回调对应的 visit 方法。这里只关注 UField——类成员变量。

注册到 Registry

class CustomIssueRegistry : IssueRegistry() {
    override val issues = listOf(EXPOSE_MUTABLE_LIVE_DATA)

    override val api: Int = CURRENT_API

    override val vendor: Vendor = Vendor(
        vendorName = "Custom Lint Rules",
        feedbackUrl = "https://your-project.com/lint-feedback"
    )
}

还需要在 build.gradle 里注册:

jar {
    manifest {
        attributes('Lint-Registry-v2': 'com.example.lint.CustomIssueRegistry')
    }
}

这行配置让 Lint 框架启动时自动加载你的 Registry,缺了它规则不会生效。

发布与集成

自定义 Lint 规则要打包成独立的 aar 或 jar,通过 lintChecks 依赖引入:

dependencies {
    lintChecks project(':lint-checks')
}

踩过一个坑:用 Kotlin 写 Lint 规则时,必须把 Kotlin stdlib 一起打进 jar,否则运行时找不到 Kotlin 类。解决方式是 fat jar:

tasks.register('fatJar', Jar) {
    from sourceSets.main.output
    from { configurations.runtimeClasspath.collect { 
        it.isDirectory() ? it : zipTree(it) 
    }}
    duplicatesStrategy = DuplicatesStrategy.EXCLUDE
}

Lint 规则模块必须独立于业务代码。它只依赖 Lint API(com.android.tools.lint:lint-api),不需要引入你的 App 模块。一旦把业务依赖带进来,版本升级时会双双绑定,动弹不得。

调试技巧

Lint 开发最让人头疼的是调试。println 打日志效率太低,两个实用方法:

直接用 Gradle 跑单条规则:

./gradlew :app:lint --check ExposeMutableLiveData

结果输出在 app/build/reports/lint-results.html,可以快速验证规则是否命中。

写单元测试:

class MutableLiveDataExposeDetectorTest {

    @Test
    fun `should report error when exposing MutableLiveData`() {
        TestLintTask.lint()
            .files(
                TestFiles.java("""
                    package com.example;
                    import androidx.lifecycle.MutableLiveData;
                    
                    public class MyFragment {
                        public MutableLiveData<String> data = new MutableLiveData<>();
                    }
                """.trimIndent())
            )
            .issues(EXPOSE_MUTABLE_LIVE_DATA)
            .run()
            .expectErrorCount(1)
    }
}

TestLintTask 不需要真机环境,纯本地跑,秒级反馈。我们 15 条规则配了 40 个测试用例,每次改代码跑一遍,心里踏实。

这些规则应该谁来写

实际项目里,最了解架构约束的是做架构设计的人,而不是 QA。我更倾向于让模块 Owner 自己写对应模块的 Lint 规则——谁定的规范谁维护。数据库模块禁止主线程操作、网络模块限制重试次数,都应该由模块 Owner 负责。

这有个前提:团队得先把 Lint 框架、Registry 注册、发包流程搭好,降低其他人加规则的门槛。我们那边现在加一条规则大概用不了半小时。

回头想,当初花两天搭完这套自定义 Lint 体系是值的。Code Review 里省掉的噪音比这多得多——机器不会漏,但人会。