1. 为啥用Lint
平时开发中我们在提mr的时候都会进行review,但有些问题通过人眼去看很难发现,比如Fragment必须有空参构造函数,因为在内存不足的时候Fragment恢复默认是通过反射调用空参构造函数重建Fragment、又或者直接使用了kt的扩展函数String#toInt,当服务端返回string不符合int的时候会发生NumberFormatException异常,这类问题在测试环境很难测出,review阶段也可能没注意到,直到线上出现crash才被发现。
那为了避免这一类问题,我们最开始是将发生过的问题都记录在checklist中,在review的时候着重去看,但靠人眼去看难免会有遗漏。那为了彻底杜绝checklist中的问题不在发生,有没有一种方法能在review之前进行自动扫描,将checklist中的问题都检查一遍呢,答案是有的,也就是今天要提到的Lint。
Lint功能强大,有诸多的优势:
-
功能强大,Lint支持Java和Kt源文件、class文件、资源文件、Gradle等文件的检查。
-
扩展性强,支持开发自定义Lint规则。
-
配套工具完善,Android Studio、Android Gradle插件原生支持Lint工具。
-
Lint专为Android设计,原生提供了几百个实用的Android相关检查规则。
-
有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检查而过多的等待,所以只在关键节点进行了检查
- 编码时实时检查,发现问题可以及时修改
- 提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. 参考
- Android Lint官方文档
- Google 官方Demo
- Android Lint增量扫描实战纪要
- 美团外卖Android Lint代码检查实践
- GitHook 官方文档
- Gitlab利用Webhook实现Push代码后的jenkins自动构建
- Gitlab Ci官方文档
- lintOptions配置官方文档
11. Github地址
前面提到的功能AndroidLint都有实现。
欢迎大佬们一起开发、交流、star。