概述
Lint 是 Google 提供的 Android 静态代码检查工具,可以扫描并发现代码中潜在的问题,提醒开发人员及早修正,提高代码质量。除了Android 原生提供的几百个 Lint 规则,还可以开发自定义 Lint 规则以满足实际需要。
为什么要使用 Lint
在美团外卖 Android App 的迭代过程中,线上问题频繁发生。开发时很容易写出一些问题代码,例如 Serializable 的使用:实现了Serializable 接口的类,如果其成员变量引用的对象没有实现 Serializable 接口,序列化时就会 Crash。我们对一些常见问题的原因和解决方法做分析总结,并在开发人员组内或跟测试人员一起分享交流,帮助相关人员主动避免这些问题。
为了进一步减少问题发生,我们逐步完善了一些规范,包括制定代码规范,加强代码 Review,完善测试流程等。但这些措施仍然存在各种不足,包括代码规范难以实施,沟通成本高,特别是开发人员变动频繁导致反复沟通等,因此其效果有限,相似问题仍然不时发生。另一方面,越来越多的总结、规范文档,对于组内新人也产生了不小的学习压力。
有没有办法从技术角度减少或减轻上述问题呢?
我们调研发现,静态代码检查是一个很好的思路。静态代码检查框架有很多种,例如 FindBugs、PMD、Coverity,主要用于检查 Java 源文件或 class 文件;再例如 Checkstyle,主要关注代码风格;但我们最终选择从 Lint 框架入手,因为它有诸多优势:
功能强大,Lint 支持 Java 源文件、class 文件、资源文件、Gradle 等文件的检查。
扩展性强,支持开发自定义 Lint 规则。
配套工具完善,Android Studio、Android Gradle 插件原生支持 Lint 工具。
Lint 专为 Android 设计,原生提供了几百个实用的 Android 相关检查规则。
有 Google 官方的支持,会和 Android 开发工具一起升级完善。
在对 Lint 进行了充分的技术调研后,我们根据实际遇到的问题,又做了一些更深入的思考,包括应该用 Lint 解决哪些问题,怎么样更好的推广实施等,逐步形成了一套较为全面有效的方案。
Lint API 简介
为了方便后文的理解,我们先简单看一下 Lint 提供的主要 API。
主要 API
Lint 规则通过调用 Lint API 实现,其中最主要的几个 API 如下:
Issue:表示一个 Lint 规则。
Detector:用于检测并报告代码中的 Issue,每个 Issue 都要指定 Detector。
Scope:声明 Detector 要扫描的代码范围,例如 JAVA_FILE_SCOPE 、CLASS_FILE_SCOPE 、RESOURCE_FILE_SCOPE 、GRADLE_SCOPE 等,一个 Issue 可包含一到多个 Scope。
Scanner:用于扫描并发现代码中的 Issue,每个 Detector 可以实现一到多个 Scanner。
IssueRegistry:Lint 规则加载的入口,提供要检查的 Issue列表。
举例来说,原生的 ShowToast 就是一个 Issue,该规则检查调用 Toast.makeText() 方法后是否漏掉了Toast.show()的调用。其 Detector 为 ToastDetector,要检查的 Scope 为 JAVA_FILE_SCOPE ,ToastDetector 实现了JavaPsiScanner,示意代码如下:
IssueRegistry 的示意代码如下:
Scanner
Lint 开发过程中最主要的工作就是实现 Scanner。Lint 中包括多种类型的 Scanner 如下,其中最常用的是扫描 Java 源文件和 XML 文件的 Scanner。
JavaScanner / JavaPsiScanner / UastScanner:扫描 Java 源文件
XmlScanner:扫描 XML 文件
ClassScanner:扫描 class 文件
BinaryResourceScanner:扫描二进制资源文件
ResourceFolderScanner:扫描资源文件夹
GradleScanner:扫描 Gradle 脚本
OtherFileScanner:扫描其他类型文件
值得注意的是,扫描 Java 源文件的 Scanner 先后经历了三个版本。
最开始使用的是 JavaScanner,Lint 通过 Lombok 库将 Java 源码解析成 AST(抽象语法树),然后由 JavaScanner 扫描。
在 Android Studio 2.2 和 lint-api 25.2.0 版本中,Lint 工具将 Lombok AST 替换为 PSI,同时弃用 JavaScanner,推荐使用 JavaPsiScanner。PSI 是 JetBrains 在 IDEA 中解析 Java 源码生成语法树后提供的 API 相比之前的 Lombok AST,PSI 可以支持 Java1.8、类型解析等使用 JavaPsiScanner 实现的自定义 Lint 规则,可以被加载到 Android Studio 2.2+版本中,在编写 Android 代码时实时执行。
在 Android Studio 3.0 和 lint-api 25.4.0 版本中,Lint 工具将 PSI 替换为 UAST,同时推荐使用新的 UastScanner。UAST 是 JetBrains 在 IDEA 新版本中用于替换 PSI 的 API。UAST更加语言无关,除了支持 Java,还可以支持 Kotlin。
本文目前仍然基于 PsiJavaScanner 做介绍。根据 UastScanner 源码中的注释,可以很容易的从 PsiJavaScanner 迁移到 UastScanner。
Lint 规则
我们需要用 Lint 检查代码中的哪些问题呢?
开发过程中,我们比较关注 App 的 Crash、Bug 率等指标。通过长期的整理总结发现,有不少发生频率很高的代码问题,其原理和解决方案都很明确,但是在写代码时却很容易遗漏且难以发现;而 Lint 恰好很容易检查出这些问题。
Crash 预防
Crash 率是 App 最重要的指标之一,避免 Crash 也一直是开发过程中比较头疼的一个问题,Lint 可以很好的检查出一些潜在的 Crash。例如:
原生的 NewApi,用于检查代码中是否调用了Android 高版本才提供的 API。在低版本设备中调用高版本 API 会导致 Crash。
自定义的 SerializableCheck。实现了Serializable 接口的类,如果其成员变量引用的对象没有实现 Serializable 接口,序列化时就会 Crash。我们制定了一条代码规范,要求实现了Serializable 接口的类,其成员变量(包括从父类继承的)所声明的类型都要实现 Serializable 接口。
自定义的 ParseColorCheck。调用 Color.parseColor() 方法解析后台下发的颜色时,颜色字符串格式不正确会导致 IllegalArgumentException,我们要求调用这个方法时必须处理该异常。
Bug 预防
有些 Bug 可以通过 Lint 检查来预防。例如:
SpUsage:要求所有 SharedPrefrence 读写操作使用基础工具类,工具类中会做各种异常处理;同时定义 SPConstants 常量类,所有 SP 的 Key 都要在这个类定义,避免在代码中分散定义的 Key 之间冲突。
ImageViewUsage:检查 ImageView 有没有设置 ScaleType,加载时有没有设置 Placeholder。
TodoCheck:检查代码中是否还有 TODO 没完成。例如开发时可能会在代码中写一些假数据,但最终上线时要确保删除这些代码。这种检查项比较特殊,通常在开发完成后提测阶段才检查。
性能/安全问题
一些性能、安全相关问题可以使用 Lint 分析。例如: - ThreadConstruction:禁止直接使用 new Thread() 创建线程(线程池除外),而需要使用统一的工具类在公用线程池执行后台操作。 - LogUsage:禁止直接使用 android.util.Log ,必须使用统一工具类。工具类中可以控制 Release 包不输出 Log,提高性能,也避免发生安全问题。
代码规范
除了代码风格方面的约束,代码规范更多的是用于减少或防止发生 Bug、Crash、性能、安全等问题。很多问题在技术上难以直接检查,我们通过封装统一的基础库、制定代码规范的方式间接解决,而 Lint 检查则用于减少组内沟通成本、新人学习成本,并确保代码规范的落实。例如:
前面提到的 SpUsage、ThreadConstruction、LogUsage 等。
ResourceNaming:资源文件命名规范,防止不同模块之间的资源文件名冲突。
代码检查的实施
当检查出代码问题时,如何提醒开发者及时修正呢?
早期我们将静态代码检查配置在 Jenkins 上,打包发布 AAR/APK 时,检查代码中的问题并生成报告。后来发现虽然静态代码检查能找出来不少问题,但是很少有人主动去看报告,特别是报告中还有过多无关紧要的、优先级很低的问题(例如过于严格的代码风格约束)。
因此,一方面要确定检查哪些问题,另一方面,何时、通过什么样的技术手段来执行代码检查也很重要。我们结合技术实现,对此做了更多思考,确定了静态代码检查实施过程中的主要目标:
重点关注高优先级问题,屏蔽低优先级问题。正如前面所说,如果代码检查报告中夹杂了大量无关紧要的问题,反而影响了关键问题的发现。
高优问题的解决,要有一定的强制性。当检查发现高优先级的代码问题时,给开发者明确直接的报错,并通过技术手段约束,强制要求开发者修复。
某些问题尽可能做到在第一时间发现,从而减少风险或损失。有些问题发现的越早越好,例如业务功能开发中使用了Android 高版本 API,通过 Lint 原生的 NewApi 可以检查出来。如果在开发期间发现,当时就可以考虑其他技术方案,实现困难时可以及时和产品、设计人员沟通;而如果到提代码、提测,甚至发版、上线时才发现,可能为时已晚。
优先级定义
每个 Lint 规则都可以配置 Sevirity(优先级),包括 Fatal、Error、Warning、Information 等,我们主要使用 Error 和 Warning,如下。
Error 级别:明确需要解决的问题,包括 Crash、明确的 Bug、严重性能问题、不符合代码规范等,必须修复。
Warning 级别:包括代码编写建议、可能存在的 Bug、一些性能优化等,适当放松要求。
执行时机
Lint 检查可以在多个阶段执行,包括在本地手动检查、编码实时检查、编译时检查、commit 检查,以及在 CI 系统中提 Pull Request 时检查、打包发版时检查等,下面分别介绍。
手动执行
在 Android Studio 中,自定义 Lint 可以通过 Inspections 功能( Analyze InspectCode )手动运行。
在 Gradle 命令行环境下,可直接用./gradlew lint 执行Lint 检查。
手动执行简单易用,但缺乏强制性,容易被开发者遗漏。
编码阶段实时检查
编码时检查即在 Android Studio 中写代码时在代码窗口实时报错。其好处很明显,开发者可以第一时间发现代码问题。但受限于 Android Studio 对自定义 Lint 的支持不完善,开发人员 IDE 的配置不同,需要开发者主动关注报错并修复,这种方式不能完全保证效果。
IDEA 提供了Inspections 功能和相应的 API 来实现代码检查,Android 原生 Lint 就是通过 Inspections 集成到了AndroidStudio 中。对于自定义 Lint 规则,官方似乎没有给出明确说明,但实际研究发现,在 Android Studio 2.2+版本和基于 JavaPsiScanner 开发的条件下(或 Android Studio 3.0+和 JavaPsiScanner/UastScanner),IDE 会尝试加载并实时执行自定义 Lint 规则。
技术细节:
在 Android Studio 2.x 版本中,菜单 Preferences EditorInspectionsAndroidLintCorrectnessErrorfrom Custom Lint Check(avaliable for Analyze|Inspect Code) 中指出,自定义 Lint 只支持命令行或手动运行,不支持实时检查。
Error from Custom Rule When custom (third-party) lint rules are integrated in the IDE, they arenot available as native IDE inspections, so the explanation text (which must be staticallyregistered by a plugin) is not available. As a workaround, run the lint target in Gradle instead; theHTML report will include full explanations.
在 Android Studio 3.x 版本中,打开 Android 工程源码后,IDE 会加载工程中的自定义 Lint 规则,在设置菜单的 Inspections列表里可以查看,和原生 Lint 效果相同(Android Studio 会在打开源文件时触发对该文件的代码检查)。
分析自定义 Lint 的 IssueRegistry.getIssues() 方法调用堆栈,可以看到 Android Studio 环境下,是由 org.jetbrains.android.inspections.lint.AndroidLintExternalAnnotator 调用 LintDriver 加载执行自定义 Lint 规则。
参考代码:https://github.com/JetBrains/android/tree/master/android/src/org/jetbrains/android/inspections/lint
在 Android Studio 中的实际效果如图:
本地编译时自动检查
配置 Gradle 脚本可实现编译 Android 工程时执行Lint 检查。好处是既可以尽早发现问题,又可以有强制性;缺点是对编译速度有一定的影响。
编译 Android 工程执行的是 assemble 任务,让 assemble 依赖 lint 任务,即可在编译时执行Lint 检查;同时配置 LintOptions,发现 Error 级别问题时中断编译。
在 Android Application 工程(APK)中配置如下,Android Library 工程(AAR)把 applicationVariants 换成 libraryVariants 即可。
LintOptions 的配置:
本地 commiitt 时检查
利用 git pre-commit hook,可以在本地 commit 代码前执行Lint 检查,检查不通过则无法提交代码。这种方式的优势在于不影响开发时的编译速度,但发现问题相对滞后。
技术实现方面,可以编写 Gradle 脚本,在每次同步工程时自动将 hook 脚本从工程拷贝到.git/hooks/ 文件夹下。
提代码时 CI 检查
作为代码提交流程规范的一部分,发 Pull Request 提代码时用 CI 系统检查 Lint 问题是一个常见、可行、有效的思路。可配置 CI 检查通过后代码才能被合并。
CI 系统常用 Jenkins,如果使用 Stash 做代码管理,可以在 Stash 上配置 Pull Request Notifier for Stash 插件,或在 Jenkins 上配置 Stash Pull Request Builder 插件,实现发 Pull Request 时触发 Jenkins 执行Lint 检查的 Job。
在本地编译和 CI 系统中做代码检查,都可以通过执行Gradle 的 Lint 任务实现。可以在 CI 环境下给 Gradle 传递一个 StartParameter,Gradle 脚本中如果读取到这个参数,则配置 LintOptions 检查所有 Lint 问题;否则在本地编译环境下只检查部分高优先级 Lint 问题,减少对本地编译速度的影响。
Lint 生成报告的效果如图所示:
打包发布时检查
即使每次提代码时用 CI 系统执行Lint 检查,仍然不能保证所有人的代码合并后一定没有问题;另外对于一些特殊的 Lint 规则,例如前面提到的 TodoCheck,还希望在更晚的时候检查。
于是在 CI 系统打包发布 APK/AAR 用于测试或发版时,还需要对所有代码再做一次 Lint 检查。
最终确定的检查时机
综合考虑多种检查方式的优缺点以及我们的目标,最终确定结合以下几种方式做代码检查:
编码阶段 IDE 实时检查,第一时间发现问题。
本地编译时,及时检查高优先级问题,检查通过才能编译。
提代码时,CI 检查所有问题,检查通过才能合代码。
打包阶段,完整检查工程,确保万无一失。
配置文件支持
为了方便代码管理,我们给自定义 Lint 创建了一个独立的工程,该工程打包生成一个 AAR 发布到 Maven 仓库,而被检查的 Android 工程依赖这个 AAR(具体开发过程可以参考文章末尾链接)。
自定义 Lint 虽然在独立工程中,但和被检查的 Android 工程中的代码规范、基础组件等存在较多耦合。
例如我们使用正则表达式检查 Android 工程的资源文件命名规范,每次业务逻辑变动要新增资源文件前缀时,都要修改 Lint 工程,发布新的 AAR,再更新到 Android 工程中,非常繁琐。另一方面,我们的 Lint 工程除了在外卖 C 端 Android 工程中使用,也希望能直接用在其他端的其他 Android 工程中,而不同工程之间存在差异。
于是我们尝试使用配置文件来解决这一问题。以检查 Log 使用的 LogUsage 为例,不同工程封装了不同的 Log 工具类,报错时提示信息也应该不一样。定义配置文件名为 customlintconfig.json ,放在被检查 Android 工程的模块目录下。在 Android 工程 A 中的配置文件是:
而 Android 工程 B 的配置文件是:
从 Lint 的 Context 对象可获取被检查工程目录从而读取配置文件,关键代码如下:
配置文件的读取,可以在 Detector 的 beforeCheckProject、beforeCheckLibraryProject 回调方法中进行。LogUsage 中检查到错误时,根据配置文件定义的信息报错。
模板 Lint 规则
Lint 规则开发过程中,我们发现了一系列相似的需求:封装了基础工具类,希望大家都用起来;某个方法很容易抛出 RuntimeException,有必要做处理,但 Java 语法上 RuntimeException 并不强制要求处理从而经常遗漏……
这些相似的需求,每次在 Lint 工程中开发同样会很繁琐。我们尝试实现了几个模板,可以直接在 Android 工程中通过配置文件配置 Lint 规则。
如下为一个配置文件示例:
示例配置中定义了两种类型的模板规则:
DeprecatedApi:禁止直接调用指定 API
HandleException:调用指定 API 时,需要加 try-catch 处理指定类型的异常
问题 API 的匹配,包括方法调用(method)、成员变量引用(field)、构造函数(construction)、继承(super-class)等类型;匹配字符串支持 glob 语法或正则表达式(和 lint.xml 中 ignore 的配置语法一致)。
实现方面,主要是遍历 Java 语法树中特定类型的节点并转换成完整字符串(例如方法调用 android.content.Intent.getIntExtra ),然后检查是否有模板规则与其匹配。匹配成功后,DeprecatedApi 规则直接输出 message 报错;HandleException 规则会检查匹配到的节点是否处理了特定 Exception(或 Exception 的父类),没有处理则报错。
按 Git 版本检查新增文件
随着 Lint 新规则的不断开发,我们又遇到了一个问题。Android 工程中存在大量历史代码,不符合新增 Lint 规则的要求,但也没有导致明显问题,这时接入新增 Lint 规则要求修改所有历史代码,成本较高而且有一定风险。例如新增代码规范,要求使用统一的线程工具类而不允许直接用 Handler 以避免内存泄露等。
我们尝试了一个折中的方案:只检查指定 git commit 之后新增的文件。在配置文件中添加配置项,给 Lint 规则配置 gitbase 属性,其值为 commit ID,只检查此次 commit 之后新增的文件。
实现方面,执行git revparseshowtoplevel 命令获取 git 工程根目录的路径;执行git lstreefulltreefullnamenameonlyr<commitid>命令获取指定 commit 时已有文件列表(相对 git 根目录的路径)。在 Scanner 回调方法中通过 Context.getLocation(node).getFile() 获取节点所在文件,结合 git 文件列表判断是否需要检查这个节点。需要注意的是,代码量较大时要考虑 Lint 检查对电脑的性能消耗。
总结
经过一段时间的实践发现,Lint 静态代码检查在解决特定问题时的效果非常好,例如发现一些语言或 API 层面比较明确的低级错误、帮助进行代码规范的约束。使用 Lint 前,不少这类问题恰好对开发人员来说又很容易遗漏(例如原生的 NewApi 检查、自定义的 SerializableCheck);相同问题反复出现;代码规范的执行,特别是有新人参与开发时,需要很高的学习和沟通成本,还经常出现新人提交代码时由于没有遵守代码规范反复被要求修改。而使用 Lint 后,这些问题都能在第一时间得到解决,节省了大量的人力,提高了代码质量和开发效率,也提高了App 的使用体验。
参考资料与扩展阅读
Lint 和 Gradle 相关技术细节还可以阅读个人博客:
作者简介
子健,Android 高级工程师,2015年毕业于西安电子科技大学并校招加入美团外卖。前期先后负责过外卖 App 首页、商家容器、评价等核心业务模块的开发维护,目前重点负责参与外卖打包自动化、代码检查、平台化等技术工作。
评论