Android Lint代码检查实践

7,885 阅读10分钟

1. 为啥用Lint

平时开发中我们在提mr的时候都会进行review,但有些问题通过人眼去看很难发现,比如Fragment必须有空参构造函数,因为在内存不足的时候Fragment恢复默认是通过反射调用空参构造函数重建Fragment、又或者直接使用了kt的扩展函数String#toInt,当服务端返回string不符合int的时候会发生NumberFormatException异常,这类问题在测试环境很难测出,review阶段也可能没注意到,直到线上出现crash才被发现。

那为了避免这一类问题,我们最开始是将发生过的问题都记录在checklist中,在review的时候着重去看,但靠人眼去看难免会有遗漏。那为了彻底杜绝checklist中的问题不在发生,有没有一种方法能在review之前进行自动扫描,将checklist中的问题都检查一遍呢,答案是有的,也就是今天要提到的Lint。

Lint功能强大,有诸多的优势:

  1. 功能强大,Lint支持Java和Kt源文件、class文件、资源文件、Gradle等文件的检查。

  2. 扩展性强,支持开发自定义Lint规则。

  3. 配套工具完善,Android Studio、Android Gradle插件原生支持Lint工具。

  4. Lint专为Android设计,原生提供了几百个实用的Android相关检查规则。

  5. 有Google官方的支持,会和Android开发工具一起升级完善。

为了避免各位提不起兴趣,这里先放几张效果图

可以在AS编码时实时提示

也可以将扫描结果以报告形式输出。

2. Lint Api简介

在开始前提一句,尽量不要用中文去搜Lint的资料,不然就会像我一样,3天demo才跑起来。因为网上搜的文章大部分都是几年前的,按照那样配置跑不起来。

2.1 Lint Api

言归正传先介绍下Lint相关类的作用

  • Issue:用来声明一个Lint规则。
  • Detector:用于检测并报告代码中的Issue,每个Issue都要指定Detector。
  • Scanner:用于扫描并发现代码中的Issue,每个Detector可以实现一到多个Scanner。
  • IssueRegistry:Lint规则加载的入口,提供要检查的Issue列表。

下面看个自定义规则,实现了Serializable接口的类,引用类型成员变量也必须要实现Serializable接口

class SerializableClassDetector : Detector(), Detector.UastScanner {

    companion object {
        private const val REPORT_MESSAGE = "该对象必须要实现Serializable接口,因为外部类实现了Serializable接口"
      private const val CLASS_SERIALIZABLE = "java.io.Serializable"
        val ISSUE = Issue.create(
            "SerializableClassCheck",
            REPORT_MESSAGE,
            REPORT_MESSAGE,
            Category.CORRECTNESS,
            10,
            Severity.ERROR,
            Implementation(SerializableClassDetector::class.java, Scope.JAVA_FILE_SCOPE)
        )
    }

    override fun applicableSuperClasses(): List<String>? {
        return listOf(CLASS_SERIALIZABLE)
    }

    override fun visitClass(context: JavaContext, declaration: UClass) {
        for (field in declaration.fields) {
            //字段是引用类型,并且可以拿到该class
            val psiClass = (field.type as? PsiClassType)?.resolve() ?: continue
            if (!context.evaluator.implementsInterface(psiClass, CLASS_SERIALIZABLE, true)) {
                context.report(ISSUE, context.getLocation(field.typeReference!!), REPORT_MESSAGE)
            }
        }
    }

}

然后在IssueRegister中注册

class CustomIssueRegistry : IssueRegistry() {

    override val issues: List<Issue>
        get() = listOf(
            SerializableClassDetector.ISSUE
        )

    override val api: Int
        get() = CURRENT_API
}

2.2 Lint Debug

看起来是比较简单的,坑的是api一点文档没有,基本所有api都要靠猜和试,所以掌握一手debug非常重要,好在Lint提供了测试框架com.android.tools.lint:lint:$lintVersion,这与java的单元测试非常像,我们只需继承LintDetectorTest重写对应方法即可进行debug,看个例子

class SerializableDetectorTest : LintDetectorTest() {
    override fun getDetector(): Detector {
        return SerializableClassDetector()
    }

    override fun getIssues(): MutableList<Issue> {
        return mutableListOf(SerializableClassDetector.ISSUE)
    }

    fun test() {
        lint()
            .files(
                kotlin("""
                    package com.rocketzly.checks

                    import java.io.Serializable

                    /**
                     * User: Rocket
                     * Date: 2020/5/27
                     * Time: 7:12 PM
                     */
                    class SerializableBean : Serializable {
                        private var serializableField: InnerSerializableBean? = null
                    }

                    class InnerSerializableBean : Serializable {
                        private var commonBean: CommonBean? = null
                    }

                    class CommonBean{
                        private var s: String = "abc"
                    }
                    
                """.trimIndent())
            )
            .run()
            .expect("")
    }

}

getDetector()传入要测试的Detector实例,getIssues()传入要测试的Issue实例,接下来定义一个测试方法,名字随意,然后就是编写测试用例,最后运行test即可进行debug。

这里插一嘴,除了可以通过Debug去试api,还可以借鉴Google提供默认几百条规则去写自定义规则

3. 可以检查什么问题

只要是能通过源码分析出的问题,Lint都可以检查出,比如前面提到的Fragment需要有空参构造函数、res资源命名规范、不能直接使用Log而应该使用项目统一的LogUtils等等,这些都可以。

目前我们项目中检查的问题大致可以分为如下几类

  • Crash预防:

    扫描项目中所有的Fragment都必须有空参构造函数,否则给与报错;避免直接使用kt扩展函数String#toXXX统一使用项目工具类String#toSafeXXX。

  • 安全&性能:

    避免直接使用原生Toast、Log、Sp、Thread类,统一使用项目封装工具类;使用RxBus的时候必须调用disposeOnDestroy避免页面销毁了监听还在,导致npe异常。

  • 代码规范:

    资源命名必须满足约定好的正则表达式;Activity必须继承BaseActivity不能直接继承AppCompatActivity等等

4. 检查时机

在你想的时机Lint基本都可以触发检查

4.1 编码实时检查

Android Studio、Android Gradle插件原生支持Lint工具,默认在编码时即可实时检查并标红提示

4.2 编译时检查

配置Gradle脚本即可实现编译时检查,好处是每次编译时可以进行检查及时发现错误,坏处是会拖慢编译速度。

编译Android需要执行assemble任务,我们只需要使assemble依赖上lint任务即可在每次编译的时候进行lint检查

android.applicationVariants.all { variant ->
    variant.outputs.each { output ->
        def lintTask = tasks["lint${variant.name.capitalize()}"]
        output.assemble.dependsOn lintTask
    }
}

LintOption加上配置

android.lintOptions {
	abortOnError true
}

4.3 commit时检查

利用GitHook在commit或者push这些节点的时候会执行.git/hooks目录中对应事件的脚本,那么我们可以在对应事件的脚本中添加lint检查命令,当发生错误则不允许提交。

具体实现是在Gradle中编写脚本每次build的时候将放在项目的脚本拷到git hook目录中,以实现给所有开发者.git/hook目录中添加lint检查命令。

但我本人是不推荐这个方法的。

第一个是因为会影响commit的速度,在实际项目中执行lint命令比较很慢的,我们项目是3.5min,对于我这种提交粒度非常细的人来说是个很痛苦的过程

第二个也是最重要的原因,如果你跟我一样平时git操作都通过AS可视化插件进行,那么githook在AS上的提示体验可以用灾难来形容,看下效果

对于commit尚且能提示出脚本中的错误信息,但是在EventLog面板不容易查阅,并且会有长度限制,超过则截断,而push的话则干脆不提示,所以直接放弃。

4.4 Ci中进行检查

4.4.1 GitlabWebHook

目前大部分的项目应该都是代码仓库在Gitlab,打包在Jenkins,我们项目也是如此,那最简单的办法是通过Gitlab Webhook来触发lint检查。

具体实现是Gitlab可以在收到push、mr等一系列事件的时候,触发一个网络请求

请求体中会携带大量的相关信息

{
  "object_kind": "push",
  "before": "95790bf891e76fee5e1747ab589903a6a1f80f22",
  "after": "da1560886d4f094c3e6c9ef40349f7d38b5d27d7",
  "ref": "refs/heads/master",
  "checkout_sha": "da1560886d4f094c3e6c9ef40349f7d38b5d27d7",
  "user_id": 4,
  "user_name": "John Smith",
  "user_username": "jsmith",
  "user_email": "john@example.com",
  "user_avatar": "https://s.gravatar.com/avatar/d4c74594d841139328695756648b6bd6?s=8://s.gravatar.com/avatar/d4c74594d841139328695756648b6bd6?s=80",
  "project_id": 15,
  "project":{
    "id": 15,
    "name":"Diaspora",
    "description":"",
    "web_url":"http://example.com/mike/diaspora",
    "avatar_url":null,
    "git_ssh_url":"git@example.com:mike/diaspora.git",
    "git_http_url":"http://example.com/mike/diaspora.git",
    "namespace":"Mike",
    "visibility_level":0,
    "path_with_namespace":"mike/diaspora",
    "default_branch":"master",
    "homepage":"http://example.com/mike/diaspora",
    "url":"git@example.com:mike/diaspora.git",
    "ssh_url":"git@example.com:mike/diaspora.git",
    "http_url":"http://example.com/mike/diaspora.git"
  },
  "repository":{
    "name": "Diaspora",
    "url": "git@example.com:mike/diaspora.git",
    "description": "",
    "homepage": "http://example.com/mike/diaspora",
    "git_http_url":"http://example.com/mike/diaspora.git",
    "git_ssh_url":"git@example.com:mike/diaspora.git",
    "visibility_level":0
  },
  "commits": [
    {
      "id": "b6568db1bc1dcd7f8b4d5a946b0b91f9dacd7327",
      "message": "Update Catalan translation to e38cb41.\n\nSee https://gitlab.com/gitlab-org/gitlab for more information",
      "title": "Update Catalan translation to e38cb41.",
      "timestamp": "2011-12-12T14:27:31+02:00",
      "url": "http://example.com/mike/diaspora/commit/b6568db1bc1dcd7f8b4d5a946b0b91f9dacd7327",
      "author": {
        "name": "Jordi Mallach",
        "email": "jordi@softcatala.org"
      },
      "added": ["CHANGELOG"],
      "modified": ["app/controller/application.rb"],
      "removed": []
    },
    {
      "id": "da1560886d4f094c3e6c9ef40349f7d38b5d27d7",
      "message": "fixed readme",
      "title": "fixed readme",
      "timestamp": "2012-01-03T23:36:29+02:00",
      "url": "http://example.com/mike/diaspora/commit/da1560886d4f094c3e6c9ef40349f7d38b5d27d7",
      "author": {
        "name": "GitLab dev user",
        "email": "gitlabdev@dv6700.(none)"
      },
      "added": ["CHANGELOG"],
      "modified": ["app/controller/application.rb"],
      "removed": []
    }
  ],
  "total_commits_count": 4
}

然后在Jenkins下载一个Gitlab Hook Plugin 插件用来接收Gitlab发送的请求触发lint检查

具体可以看这篇文章

我一开始也是用这个方法去触发lint检查的,但是我在jk中拿不到Gitlab请求体中关于提交的信息,导致我后面检查出问题通知对应人的流程不好做,所以这个方案暂且搁置了。

4.4.2 Gitlab Ci

由于JK中拿不到Gitlab关于提交的信息(也可能是我姿势不对),于是换个思路能直接在Gitlab触发脚本的执行么?在Gitlab上肯定能很好获取提交相关信息,很幸运Gitlab自带一个Ci功能正好满足我们的需求。

具体实现是配置一个Gitlab Runner用来执行脚本,然后在项目根目录添加一个.gitlab-ci.yml文件,dsl官方文档有详细说明

before_script:

lintDebug:
  stage: test
  rules:
    - if: '$CI_PIPELINE_SOURCE == "push"' #只在push的时候执行
  script:
    - echo "执行成功"
    - export PUB_HOSTED_URL=https://pub.flutter-io.cn
    - export FLUTTER_STORAGE_BASE_URL=https://storage.flutter-io.cn
    - python3 --version
    - git submodule update --init # 拉取submodule
    - python3 buildSystem/lint_check.py ${CI_COMMIT_BRANCH} ${GITLAB_USER_NAME} #执行lint检查脚本
  tags:
    - android
    - lint

Gitlab Ci默认给添加了很多的环境变量,可以很方便的拿到我们想要的提交信息,只列出部分供参考

执行结果可以很方便的在Gitlab看到

详情可以查阅Gitlab Ci官方文档

4.5 最终方案

对于lint我们这边是希望不要过多的影响到开发流程,即不要开发人员因为lint检查而过多的等待,所以只在关键节点进行了检查

  1. 编码时实时检查,发现问题可以及时修改
  2. 提mr的时候触发Gitlab Ci进行lint检查,当出现问题的时候,终止mr,并通知到企业微信

如果想要更严格点可以再加上编译时的检查,但会影响编译速度所以暂未打开。

5. 相关配置

对于lintOptions支持的配置可以查阅官方文档,这里我只讲下几个比较重要的配置

5.1 只检查自己编写的lint规则

Android自带了非常多的规则,如果都扫的话时间非常长,历史错误也会非常多,导致我们自定义的错误反而容易被忽略,所以我们需要指定只扫描自定义规则

android {
  lintOptions {
        //设置只检查的类型
        def checkList = [
                'SerializableClassCheck',
                'HandleExceptionCheck',
                'AvoidUsageApiCheck',
                'DependencyApiCheck',
                'ResourceNameCheck'
        ] as String[]
        check checkList
        ...
    }
}

给check传入自定义规则的Issue id即可

5.2 只扫当前项目代码

你给哪个module添加了lint依赖,则默认只会扫当前module的代码,对于依赖代码都不会扫描,所以对于多module的项目,每个module都需要添加lint依赖和对应的配置。

顺带插一嘴执行检查命令时最好指定moduleName、flavour、variant可以节省一些执行时间

例如./gradlew :app:lintDevDebug

5.3 忽略历史问题

我们目前开发的项目都是有一定历史年限的,在我们添加了lint规则后老代码或多或少会扫出些问题,但这些代码实际已经经过线上的验证了,并不需要我们立即修改,那么我们就需要一个配置可以暂时的忽略它们,等到有空的时候在修改。LintOptions正好提供了一个配置能帮到我们

android{
  lintOptions {
        //创建警告基准
        baseline file("lint-baseline.xml")
    }
}

添加了这个配置后,在第一次执行lint命令时会在目录创建一个lint-baseline.xml文件用来记录已经存在的错误,在下次执行lint检查的时候则会忽略掉lint-baseline.xml中的错误以达到忽略历史问题的目的。效果如下

结果是成功的,但是会告诉你有多少个错误和警告从lint-baseline.xml中过滤掉了,避免忘记修改。

6. 配置文件支持

在Lint使用过程中,会发现在不同项目有相同的需求,比如避免直接使用Log,而应该使用xxxLog,又或者避免直接使用Thread创建线程而应该使用xxxUtils,针对不同的项目提示的信息不同,如果都在代码中写死的话则没法复用,所以我们可以弄一个配置系统,将这些属性给抽离出来,这样不同项目就可以单独配置互不影响。

同时可以再进一步,将通用型的问题都抽象出来,通过配置来加载具体的规则,这样就可以减少Detector的编写,也方便添加规则。

举个板栗:

{
  "avoid_usage_api": {
    "method": [
      {
        "name": "android.content.ContextWrapper.getSharedPreferences",
        "message": "禁止直接调用getSharedPreferences方法获取sp,建议使用SpUtils",
        "exclude": [
          "com.rocketzly.androidlint.MainActivity",
          "com.rocketzly.androidlint.Test"
        ],
        "severity": "error"
      },
      {
        "name_regex": "android\\.util\\.Log\\.(v|d|i|w|e)",
        "message": "禁止直接使用android.util.Log,必须使用统一工具类xxxLog",
        "severity": "error"
      }
    ],
    "construction": [
      {
        "name": "java.lang.Thread",
        "message": "禁止直接使用new Thread()创建线程,建议使用RxJava做线程操作",
        "severity": "error"
      }
    ],
    "inherit": [
      {
        "name_regex": "\\.(AppCompat|Main)?Activity$",
        "exclude_regex": "com\\.rocketzly\\.androidlint",
        "message": "避免直接继承Activity,建议继承xxxActivity",
        "severity": "warning"
      }
    ]
  },
  "handle_exception_method": [
    {
      "name": "android.graphics.Color.parseColor",
      "exception": "java.lang.IllegalArgumentException",
      "message": "Color.parseColor需要加try-catch处理IllegalArgumentException异常",
      "severity": "error"
    }
  ],
  "dependency_api": [
    {
      "trigger_method": "com.example.common.RxBus.toObservable",
      "dependency_method": "com.example.common.RxLifeCycleExtensionsKt.disposeOnDestroy",
      "message": "RxBus监听后必须调disposeOnDestroy在页面销毁的时候关闭",
      "severity": "warning"
    }
  ],
  "resource_name": {
    "drawable": {
      "name_regex": "^(bg|shape)_",
      "message": "drawable命名不符合 (bg|shape)_ 规则",
      "severity": "warning"
    },
    "layout": {
      "name_regex": "^(activity|dialog|item|view|page)_",
      "message": "layout命名不符合 (activity|dialog|item|view|page)_ 规则",
      "severity": "warning"
    }
  }
}

比如避免使用的Api我们可以抽象为避免调用的方法、避免创建的类、避免继承的类,那么避免直接使用Log的规则只需要添加如下配置即可

{
  "avoid_usage_api": {
    "method":[
      {
        "name_regex": "android\\.util\\.Log\\.(v|d|i|w|e)",
    		"message": "禁止直接使用android.util.Log,必须使用统一工具类xxxLog",
    		"severity": "error"
      }
    ]
	}
}

对于配置的加载,可从Context获取被检查工程目录从而读取配置文件

class LintConfig private constructor(context: Context) {

    private var parser: ConfigParser

    companion object {
        const val CONFIG_FILE_NAME = "custom_lint_config.json"

        private var instance: LintConfig? = null
        fun getInstance(context: Context): LintConfig {
            if (instance == null) {
                instance = LintConfig(context)
            }
            return instance!!
        }
    }

    init {
        val configFile =
            File(
              	context.project.dir.absolutePath + "/../",
              	CONFIG_FILE_NAME
            )
        parser = ConfigParser(configFile)//加载配置
    }

至于Context可以在Detector获取

open class BaseDetector : Detector() {

    lateinit var lintConfig: LintConfig

    override fun beforeCheckRootProject(context: Context) {
        super.beforeCheckRootProject(context)
        lintConfig = LintConfig.getInstance(context)
    }
}

具体例子可以查看Andrid Lint根目录custom_lint_config.json文件

7. 踩过的坑

7.1 UAST没有API文档

目前Lint最新的Scanner是UastScanner,相比旧的JavaPsiScanner最大的优点是支持了Kotlin,但缺点也很明显没有API文档,中文资料几乎为0,只能FQ用英文搜索,并且由于没有API极大的依赖Debug去试去猜API,特别考验耐心。

7.2 Lint和Gradle版本有对应关系

在接Lint的初期,光Demo能跑起来我就用了三天各种问题一度想要放弃,当然这也怨我用惯了中文搜索,中文搜出的博客大部分都是几年前的,他们当时能跑起来,但现在却跑不起来了,原因是Lint版本与Gradle版本是有关联的,更坑的是这点在官方文档只字未提,只在Google Lint Demo的子目录中有提到。

7.3 AS对Lint支持不算友好

写的自定义规则在跑测试用例的时候一切正常,并且生成的报告中也是有的,但是在编码时就是不能实时提示,所以还是以报告为准。好在不能实时提示这种情况还是极少。

修改了Issue severity等级不能立马生效,需要重启AS才行,猜测是AS有缓存没及时刷新。

7.4 lifecycle-extensions:2.2.0会导致自定义lint无效

这个是最最最最坑的,我在Demo工程的时候一切正常,而接入到项目的时候就是一直报找不到我自定义Lint规则,我一直以为是我写错了,各种检查Issue注册,Detector编写始终没发现问题,然后我在项目中找一个依赖比较少的module依赖上lint,依旧不行,于是我再项目中新建一个module依赖上lint是可以的,于是猜测是common模块中有某些东西影响了,然后一顿注释代码去试最后发现是lifecycle-extensions:2.2.0影响了。

现在说起来觉得比较轻松,当时真的是一脸懵逼。

官方文档也没有任何说没,o((⊙﹏⊙))o,只需将lifecycle-extensions替换成需要的特定Lifecycle组件即可。

8. 最终效果

虽然吧,Lint有上面种种的问题,但最终的效果还是满足预期的。

除了AS的实时提示,我们在Gitlab Ci收到MR的时候触发Lint检查,当发生错误的时候将report上传到内网Tomcat,然后企业微信通知(当然你可以再加上邮件通知)。

企业微信通知效果如下(分支名和提交人被我抹去了,不是没有):

点击report即可跳转到对应的网页

Gitlab lint执行失败可中断MR

9. 可优化点

9.1 利用Gradle插件优化配置

前面我们说到每一个要执行lint检查的项目都要添加对应的配置,以我们项目为例

android{
  lintOptions {
        //设置只检查的类型
        def checkList = [
                'SerializableClassCheck',
                'HandleExceptionCheck',
                'AvoidUsageApiCheck',
                'DependencyApiCheck',
                'ResourceNameCheck'
        ] as String[]
        check checkList
        //是否发现错误,则停止构建
        abortOnError true
        //是否应该编写XML报告。默认为true。
        xmlReport false
        //指定html输出目录
        htmlOutput file('build/reports/lint-results.html')
        //返回lint是否应将所有警告视为错误
        warningsAsErrors false
        //在 release 版本是否检查 fatal 类型错误,默认release版本为开启。开启后,检查到 fatal 类型错误则会关闭
        checkReleaseBuilds false
    }
}

dependencies {
  implementation project(':lintlibrary')
}

那对于多Module的项目的话,每个都添加配置既麻烦也不方便统一。

所以可以写一个Gradle插件,将这些配置都统一起来,需要的module直接依赖插件就可以了。

具体可以参考Android Lint中lintplugin模块。

9.2 增量扫描

lint扫描本身就是一个十分耗时的过程,随着项目的增大时间会越来越长,所以只扫描修改文件就变得有必要了,那具体实现可以参考Android Lint中LintPlugin模块。

详细过程可以看我另外篇博客Lint增量扫描实践

10. 参考

11. Github地址

前面提到的功能AndroidLint都有实现。

欢迎大佬们一起开发、交流、star。