在我们项目迭代过程中,线上问题频繁发生。开发时很容易写出一些问题代码,例如 Serializable 的使用:实现了 Serializable 接口的类,如果其成员变量引用的对象没有实现 Serializable 接口,序列化时就会 Crash。再例如,如果 XML 资源文件包含未使用的命名空间,则不仅占用空间,还会导致不必要的处理。其他结构问题,例如使用目标 API 版本不支持的已弃用的元素或 API 调用等,可能导致代码无法正常运行。所以为了进一步减少问题发生,我们逐步完善了一些规范,包括制定代码规范,加强代码 Review,完善测试流程等。但这些措施仍然存在各种不足,包括代码规范难以实施,沟通成本高,因此其效果有限,相似问题仍然不时发生。
有没有办法从技术角度减少或减轻上述问题呢?
我们调研发现,静态代码检查是一个很好的思路。静态代码检查框架有很多种,例如 FindBugs、PMD、Coverity,主要用于检查 Java 源文件或 class 文件;再例如 Checkstyle,主要关注代码风格;但我们最终选择从 Lint 框架入手,因为它有诸多优势:
Lint 工具可检查 Android 项目源文件是否包含潜在错误,以及在正确性、安全性、性能、易用性、便利性和国际化方面是否需要优化改进。并且支持 class 文件、资源文件、Gradle 等文件的检查。
扩展性强,支持开发自定义 Lint 规则
配套工具完善,Android Studio、Android Gradle 插件原生支持Lint工具。
Lint 专为 Android 设计,原生提供了几百个实用的 Android 相关检查规则。
点击 Analyze 的 Inspect Code 选项,即可开启 lint 检查,在Inspection窗口中可以看到lint检查的结果。并且 android 自带的lint规则的更改可以在 Setting 的 Edit 选项下选择 Inspections(File > Settings > Project Settings),对已有的 lint 规则进行自定义选择。
AS 的控制台,进入要使用 Lint 检查规则的模块目录,使用 gradle lint 命令。输出的结果会生成一个 xml 以及 HTML(可以在浏览器打开,页面非常简单直观)。路径信息也可以在控制台看到,比如我的就是:
Wrote HTML report to file:///D:/cccx_3.0/app/build/reports/lint-results.html
Wrote XML report to file:///D:/cccx_3.0/app/build/reports/lint-results.xml
由于每个业务线自身的需求,Lint 默认的检查项目可能不能满足我们的需求。 比如司机端一个自定义控件需要抽成一个库给其他项目使用,但是我们希望使用者必须在 XML
中定义一个属性,否则组件无法正常运行,我们希望Lint能够对此进行检查,并在忘记添加此属性时给出明确的错误提示。
再比如,我们的基础组件有一个日志库,能够方便的在 release 版本中关闭日志输出,还能够把日志输出到指定的文件中方便事后分析,这时如果来了一个新同学,他可能还是习惯性的用 android.util.Log
来打印日志,我们希望能够检测到本项目中所有使用了 android.util.Log
的代码,并发出警告。 要满足这些自定义需求,我们就需要通过 Android Lint 的扩展机制自己定制 Lint 规则。
一、Crash 预防
Crash 率是我们司机端和乘客端的最重要的指标之一,我们期望使用 Lint 检查出一些潜在的 Crash,例如:
NewApi
,用于检查代码中是否调用了 Android 高版本才提供的 API。在低版本设备中调用高版本 API 会导致 Crash。Serializable
接口的类,如果其成员变量引用的对象没有实现 Serializable
接口,序列化时就会Crash。Color.parseColor()
方法解析后台下发的颜色时,颜色字符串格式不正确会导致 IllegalArgumentException
。二、Bug 预防
由于目前 Bug 数已经作为部门衡量的指标,我们也期望使用 Lint 来检车和预防,例如:
UXRouter
,并且 PATH
维护在统一的常量类里方便查阅和修改。getInteger()
和 getIntValue()
。三、性能/安全问题
对于司机端和乘客端来讲,性能和安全非常重要。我们期望使用Lint来检测一些可以规避的影响性能和安全的问题,例如:
android:allowBackup
属性值。默认情况下这个属性值为 true,故android:allowbackup
属性设置为 false,防止 adb backup 导出数据。四、代码编写规范
对于代码的编写规范之前已经讨论过并且给出了具体的实行方案,我们希望使用 Lint 来检测以便于减少沟通成本,Code Review 的时间以及新人的学习成本。例如:
Android SDK 中涉及 Lint 的主要有下面几个包,均包含在 Android Gradle 插件 com.android.tools.build:gradle
中。
com.android.tools.lint:lint-api
:这个包提供了 Lint 的 API,包括 Context、Project、Detector、Issue、IssueRegistry 等,后面会做介绍。com.android.tools.lint:lint-checks
:这个包含了 Lint 支持的200多种规则。com.android.tools.lint:lint
:这个包用于运行 Lint 的检查:
com.android.tools.lint.XxxReporter
:检查结果报告,包括纯文本、XML、HTML 格式等com.android.tools.lint.LintCliClient
:用于在命令行中执行 Lintcom.android.tools.lint.Main
:这个类是命令行版本 Lint 的 Java 入口(Command line driver),主要是解析参数、输出结果com.android.tools.build:gradle-core
:这个包提供 Gradle 插件核心功能,其中与 Lint 相关的主要有:
com.android.build.gradle.tasks.Lint
: Gradle 中 Lint 任务的实现com.android.build.gradle.internal.LintGradleClient
:用于在 Gradle 中执行 Lint,集成自 LintCliClientcom.android.build.gradle.internal.LintGradleProject
:继承自 lint-api 中的 Project 类。Gradle 执行 Lint 检查时使用的 Project 对象,可获取 Manifest、依赖等信息。其中又包含了 AppGradleProject
和 LibraryProject
两个内部类。###Lint 的主要API
Lint 规则通过调用 Lint API 实现,其中最主要的几个 API 如下:
JAVA_FILE_SCOPE
、CLASS_FILE_SCOPE
、RESOURCE_FILE_SCOPE
、GRADLE_SCOPE
等,一个 Issue 可包含一到多个 Scope。如果要查看 lint 工具支持的 issue 的完整列表和它们所对应的 issue ID,可以使用 lint --list 命令。
Lint 中包括多种类型的 Scanner 如下,其中最常用的是扫描 Java 源文件和 XML 文件的 Scanner:
我们需要注意的是,扫描Java源文件的Scanner先后经历了三个版本:
- 最开始使用的是 JavaScanner,Lint 通过 Lombok 库将 Java 源码解析成 AST(抽象语法树),然后由 JavaScanner 扫描。
- 在Android Studio 2.2和 lint-api 25.2.0版本中,Lint工具将 Lombok AST 替换为 PSI,同时弃用 JavaScanner,推荐使用 JavaPsiScanner。
- 在 Android Studio 3.0和 lint-api 25.4.0 版本中,Lint 工具将 PSI 替换为 UAST,同时推荐使用新的 UastScanner
UAST 是 JetBrains 在 IDEA 新版本中用于替换 PSI 的 API。UAST 跟加语言无关,除了支持 Java,还可以支持 Kotlin.扩展 —— 关于 PSI 的介绍在这里
自定义 Lint 和编写 gradle 插件一样,是一个纯 Java 项目,以 jar 的形式提供依赖。有了包含 Lint 规则的 jar 后,有两种使用方案:
由于方案一是全局生效的策略,无法单独针对目标项目,用处不大。在工程实践中,我们主要使用方案二。
aar 是 Android Library 的一种新的二进制分发格式,它把资源也一起打包,这样一来图片和布局资源文件也能够被同时分发。aar 格式文件能够包含一个可选的 lint.jar 文件,如果一个 app 依赖了一个包含 lint.jar 的 aar 文件,那么这个 lint.jar 中的规则就会在 app 的 lint 任务中被用来做lint检查。
下面是一个自定义 Lint 项目的目录结构:
主要包含了两个部分:
apply plugin: 'java'
repositories {
mavenCentral()
}
dependencies {
compile 'com.android.tools.lint:lint-api:25.3.0'
compile 'com.android.tools.lint:lint-checks:25.3.0'
compile 'com.android.tools.build:gradle-core:2.3.3'
compile 'com.android.tools.lint:lint:25.3.0'
}
jar {
manifest {
attributes("Lint-Registry": "com.customer.lint.core.MyIssueRegistry")
}
}
configurations {
lintChecks
}
dependencies {
lintChecks files(jar)
}
//指定编译的编码
tasks.withType(JavaCompile){
options.encoding = "UTF-8"
}
这里只需要注意下 Lint-Registry 是透露给lint工具的注册类的方法,也就是说 MyIssueRegistry 是 Lint 工具的入口,并且也通过 jar 这个命令来打包。
先用一个简单实现为例:
如图,我们看到简单实现一个检测 Log 的规则非常简单:
-------------------------------------------下面重点介绍实现过程和对应的源码 Api 分析-------------------------------
Issue 由 Detector 发现并报告,是我们自己定义的需要统一的代码规则、代码优化点。Issue对象使用静态工厂方法构造实例对象,其方法参数的意义如下:
id:唯一值,应该能简短描述当前问题。利用 Java 注解或者 XML 属性进行屏蔽时,使用的就是这个 id,所以最好使用英文。
briefDescription:简短的总结,尽量控制在10字以内,描述问题而不是修复措施。
explanation:完整的问题解释和修复建议。
category:问题类别。在 API 当中已经定义当包括:CORRECTNESS(正确性)、SECURITY(安全性)、PERFORMANCE(性能)、USABILITY(易用性)、A11Y(便利性)、I18N(国际化),还有几个隶属于这些大类别中的子类别,详情可查阅源码 com.android.tools.lint.detector.api.Category
。
priority:优先级。1-10 的数字,10 为最重要/最严重
Implementation:为 Issue 和 Detector 提供映射关系,Detector 就是当前 Detector。声明扫描检测的范围 Scope,Scope 用来描述 Detector 需要分析时需要考虑的文件集,包括:Resource 文件或目录、Java 文件、Class 文件。
自定义 Detector 的实际核心逻辑部分就是实现什么样的 Scanner 接口以及 Scanner 中的方法实现。上文也有说明,目前常用的是扫描 Java 源文件和 XML 文件的 Scanner,下面就以 JavaPsiScanner 和 XmlScaner 为例介绍一下关键 Api ,其他的目前由于没有精力研究,暂且搁置。
下面是 JavaPsiScanner 这个扫描器的 6 组 12 个回调方法:
createPsiVisitor()
构造一个代码访问器。一个 Detector 对象必须通过这个方法返回一个代码访问器对象,除非 Detector 对象的 appliesToResourceRefs() 返回了 True
或者 getApplicableMethodNames() 返回的不是 Null
。
getApplicablePsiTypes()
返回访问器需要访问的Java元素类型。
getApplicableMethodNames()
和 visitMethod()
一般配对使用,访问特定方法
getApplicableConstructorTypes()
和 visitConstructor()
配对使用,访问特定构造器
getApplicableReferenceNames()
和 visitReference()
配对使用,访问特定的引用
applicableSuperClasses()
和 checkClass()
访问特定的超类
appliesToResourceRefs()
和 visitResourceReference()
访问 Java 代码中的资源引用,例如 R.layout.main
用法可以参考 Lint 源码
com.android.tools.lint.checks.LayoutInflationDetector
和com.android.tools.lint.checks.StringFormatDetector
在实际编码过程中,仅知道 Api 的作用是不够的,还需要知道 Api 接口的返回参数是做什么的,这个也非常关键:
JavaContext 这个是在代码扫描的时候获得 Java 文件的一个上下对象。其中核心方法包括:
report()
检查的规则命中之后上报就需要调用这个方法
getLocation()
返回传入元素的位置,可以帮助我们定位需要报告问题的代码区域
getProject()
返回一个 project 对象。这个是 JavaContext 继承过来的方法,当我们需要了解一些 project 的属性,比如编译版本之类的信息的时候,这个对象就会很有用
getEvaluator()
返回一个 JavaEvaluator对象,用于帮助分析具体的java源文件元素
JavaEvaluator 这个对象可以帮助我们分析 Java 源文件,在自定义 lint 规则的时候,所有的 Java 源文件元素都被抽象成了另一种语法树结构表示,在新的 API 版本当中,这种语法树结构使用的是 interllij 的 java-psi 的 API。
extendsClass()
判断某个对象是否继承于某个类
implementsInterface()
判断某个对象是否实现某接口
inheritsFrom()
以上两个的结合版
methodMatches()
某个方法是否与某个类当中的方法匹配
Java-psi 的语法树抽象和 Java 元素的映射关系:
举个例子,ResourceXmlDetector
就是实现了 XmlScaner
的 一个Detector,扫描 Xml 文件并获取 Xml Dom 元素以执行检查。
createPsiVisitor()
:构造一个代码访问器。一个 Detector 对象必须通过这个方法返回一个代码访问器对象,除非 Detector 对象的 appliesToResourceRefs()
返回了 True 或者 getApplicableMethodNames()
返回的不是 Null。
getApplicableElements()
返回的是指定检查的元素(可以是多个)。
visitElement()
这个方法在 XmlScanner 扫描到了对应的元素时调用。如代码,我在这里添加了自己的逻辑,当扫描 Textview 时,会去判断是否含有 textAppearance
,如果没有的话就抛出自定义的 Issue。
再介绍几个用到的方法,context.report()
是用于报告问题并生成报告的,context.getLocation()
精确的定位出现问题的位置,包含文件路径,行号,列数。
这里附上 Lint 的 API 文档,点击查看
了解了这么多,其实还是得归于实践,关于自定义 Lint 规则的 demo 网上都是千篇一律,不是检测 Log 就是检测 Thead,部分还是老的实现方式(实现的 JavaScanner 接口),而我们现在是基于 JavaPsiScanner 实现的扫描器。以下是我的实践结果,检测 Color.parseColor()
避免解析的字符串格式不正确导致抛出异常。
另外。代码中包含注释,可以结合 API 说明与代码一起看。
/**
* @ProjectName: AndroidLint-master
* @Package: com.paincker.lint.core
* @Author: yao.dang
* @CreateDate: 2019/3/6 15:49
* @UpdateUser: 更新者
* @UpdateDate: 2019/3/6 15:49
* @UpdateRemark: 更新说明
*/
public class ColorParseDetector extends Detector implements Detector.JavaPsiScanner {
public static final String ISSUE_ID = "ColorParse";
public static final String ISSUE_DESCRIPTION = "避免 parseColor 解析出现异常";
public static final String ISSUE_EXPLANATION = "当解析错误时会抛出异常,请加入try catch防护";
public static final Category ISSUE_CATEGORY = Category.SECURITY;
/**
* 优先级,1到10的数字,10是最重要/最严重的
*/
private static final int ISSUE_PRIORITY = 6;
private static final Severity ISSUE_SEVERITY = Severity.ERROR;
/**
* 特定的方法名
*/
static final String PARSECOLOR = "parseColor";
public static final Issue ISSUE = Issue.create(
ISSUE_ID,
ISSUE_DESCRIPTION,
ISSUE_EXPLANATION,
ISSUE_CATEGORY,
ISSUE_PRIORITY,
ISSUE_SEVERITY,
new Implementation(ColorParseDetector.class, Scope.JAVA_FILE_SCOPE)
);
@Override
public List getApplicableMethodNames() {
return Collections.singletonList(PARSECOLOR);
}
@Override
public void visitMethod(JavaContext context, JavaElementVisitor visitor, PsiMethodCallExpression call, PsiMethod method) {
//如果方法名不一致就不走判断逻辑
if (method.getName().equals(PARSECOLOR)) {
JavaEvaluator evaluator = context.getEvaluator();
//接着要确定是哪个类的方法
if (evaluator.isMemberInClass(method, "android.graphics.Color")) {
/**
* 在AST抽象语法树中,调用 parseColor 的节点应该是 try 的子节点,
* 向上追溯,查到的对应的是 Try 那么就说明已经在调用 parseColor 前做了try-catch处理
*/
PsiElement psiElement = PsiTreeUtil.getParentOfType(method, Try.class);
if (psiElement == null) {
context.report(ISSUE,
context.getLocation(call.getMethodExpression()),
ISSUE_EXPLANATION
);
}
}
}
}
}
在实践的过程中,有一点必不可少,那就是调试。普通的 debug 调试大家应该都没问题,但是对于 aar 的调试呢,如果不了解的话,可以看下文:
首先,打开Run -> Edit Configuration 然后点击加号,创建一个 Remote 的配置,注意要将 JVM 命令拷贝。
然后,在右边的 gradle 状态栏,找到主工程下的 assembleDebug,右键 create 一个新的 Run Configurations,将上一步拷贝的内容粘贴到 VM Options 内,注意要将 suspend 对应的值改为 y。
最后,双击上一步新生成的调试任务,这时候就会进入挂起状态等待调试,接着在状态栏切换到之前未命名的 Remote 任务,点击右侧的 debug 调试按钮,就可以进入调试流程了,当然,你需要提前打好断点。