Android 自定义 Lint 实现静态代码扫描工具

文章目录

    • 关于静态代码扫描工具
    • Lint的简单使用
      • 一、Lint 与 IDE 的结合使用
      • 二、Lint 与 gradle 命令的结合使用
        • 具体位置如下图:
        • 生成的HTML在浏览器打开如图:
    • 自定义 Lint
      • 为什么需要自定义 Lint?
      • Lint 需要自定义检查的问题
      • 参考美团的方案针对 Lint 实施的思考
    • 开发自定义 Lint 前的准备工作
      • Android SDK
    • 自定义 Lint 规则的流程
      • **下面着重于如何编写自定义规则,也就是我们 lintjar 这个 module 的开发**
      • 一 、创建 Java 工程,配置 Gradle
      • 二 、选定一个规则用 Lint 实现
      • Issue
      • 划重点 --> Scanner
        • JavaPsiScanner
        • XmlScaner
    • 实践
      • 实践一
      • 实践二
    • 后续会附上demo 以及其他规则的实现过程

关于静态代码扫描工具

在我们项目迭代过程中,线上问题频繁发生。开发时很容易写出一些问题代码,例如 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 相关检查规则。

Lint的简单使用

一、Lint 与 IDE 的结合使用

点击 Analyze 的 Inspect Code 选项,即可开启 lint 检查,在Inspection窗口中可以看到lint检查的结果。并且 android 自带的lint规则的更改可以在 Setting 的 Edit 选项下选择 Inspections(File > Settings > Project Settings),对已有的 lint 规则进行自定义选择。

二、Lint 与 gradle 命令的结合使用

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

具体位置如下图:

Android 自定义 Lint 实现静态代码扫描工具_第1张图片

生成的HTML在浏览器打开如图:

Android 自定义 Lint 实现静态代码扫描工具_第2张图片


自定义 Lint

为什么需要自定义 Lint?

由于每个业务线自身的需求,Lint 默认的检查项目可能不能满足我们的需求。 比如司机端一个自定义控件需要抽成一个库给其他项目使用,但是我们希望使用者必须在 XML 中定义一个属性,否则组件无法正常运行,我们希望Lint能够对此进行检查,并在忘记添加此属性时给出明确的错误提示。
再比如,我们的基础组件有一个日志库,能够方便的在 release 版本中关闭日志输出,还能够把日志输出到指定的文件中方便事后分析,这时如果来了一个新同学,他可能还是习惯性的用 android.util.Log 来打印日志,我们希望能够检测到本项目中所有使用了 android.util.Log 的代码,并发出警告。 要满足这些自定义需求,我们就需要通过 Android Lint 的扩展机制自己定制 Lint 规则。

Lint 需要自定义检查的问题

一、Crash 预防

Crash 率是我们司机端和乘客端的最重要的指标之一,我们期望使用 Lint 检查出一些潜在的 Crash,例如:

  • 原生的 NewApi,用于检查代码中是否调用了 Android 高版本才提供的 API。在低版本设备中调用高版本 API 会导致 Crash。
  • 实现了 Serializable 接口的类,如果其成员变量引用的对象没有实现 Serializable 接口,序列化时就会Crash。
  • 调用 Color.parseColor() 方法解析后台下发的颜色时,颜色字符串格式不正确会导致 IllegalArgumentException

二、Bug 预防

由于目前 Bug 数已经作为部门衡量的指标,我们也期望使用 Lint 来检车和预防,例如:

  • 所有的页面跳转使用统一的路由 UXRouter,并且 PATH 维护在统一的常量类里方便查阅和修改。
  • 使用 Fastjson 解析 JSON 数据时,用基础类型来接收的不要用get包装类型的方法。比如: getInteger()getIntValue()

三、性能/安全问题

对于司机端和乘客端来讲,性能和安全非常重要。我们期望使用Lint来检测一些可以规避的影响性能和安全的问题,例如:

  • 使用 PendingIntent 时,使用了空 Intent 会导致恶意用户劫持修改 Intent 的内容。所以使用 PendingIntent 时,禁止使用空 intent,同时禁止使用隐式 Intent。
  • 在 Android API level 8 以后增加了 android:allowBackup 属性值。默认情况下这个属性值为 true,故
    当 allowBackup 标志值为 true 时,即可通过 adb backup 和 adb restore 来备份和恢复应用程序数据。所以就需要强制将 android:allowbackup 属性设置为 false,防止 adb backup 导出数据。

四、代码编写规范

对于代码的编写规范之前已经讨论过并且给出了具体的实行方案,我们希望使用 Lint 来检测以便于减少沟通成本,Code Review 的时间以及新人的学习成本。例如:

  • 对 Activity 的命名和 Fragment 的命名,不要使用简写 Act、Frg 等。
  • 资源文件命名需要按照规范加入模块作为前缀,防止不同模块之间的资源文件名冲突。

参考美团的方案针对 Lint 实施的思考

  1. 明确优先级,代码检查报告中重点体现高优先级问题,屏蔽无关紧要的问题
  2. 高优问题,强制要求开发者修复,否则代码不予提交。
  3. 执行时机可选。以下列出针对 Lint 执行时机的一些参考:
    • 编码阶段IDE实时检查,第一时间发现问题
    • 本地编译时,及时检查高优先级问题,检查通过才能编译
    • 提代码时,CI 检查所有问题,检查通过才能合代码
    • 打包阶段,完整检查工程,确保万无一失

开发自定义 Lint 前的准备工作

Android SDK

Android SDK 中涉及 Lint 的主要有下面几个包,均包含在 Android Gradle 插件 com.android.tools.build:gradle 中。

  1. com.android.tools.lint:lint-api:这个包提供了 Lint 的 API,包括 Context、Project、Detector、Issue、IssueRegistry 等,后面会做介绍。
  2. com.android.tools.lint:lint-checks:这个包含了 Lint 支持的200多种规则。
  3. com.android.tools.lint:lint:这个包用于运行 Lint 的检查:
    • com.android.tools.lint.XxxReporter:检查结果报告,包括纯文本、XML、HTML 格式等
    • com.android.tools.lint.LintCliClient:用于在命令行中执行 Lint
    • com.android.tools.lint.Main:这个类是命令行版本 Lint 的 Java 入口(Command line driver),主要是解析参数、输出结果
  4. com.android.tools.build:gradle-core:这个包提供 Gradle 插件核心功能,其中与 Lint 相关的主要有:
    • com.android.build.gradle.tasks.Lint: Gradle 中 Lint 任务的实现
    • com.android.build.gradle.internal.LintGradleClient:用于在 Gradle 中执行 Lint,集成自 LintCliClient
    • com.android.build.gradle.internal.LintGradleProject:继承自 lint-api 中的 Project 类。Gradle 执行 Lint 检查时使用的 Project 对象,可获取 Manifest、依赖等信息。其中又包含了 AppGradleProjectLibraryProject 两个内部类。

###Lint 的主要API

Lint 规则通过调用 Lint API 实现,其中最主要的几个 API 如下:

  1. Issue:问题的描述,其实就是表示一个 Lint 规则。
  2. Detector:中文是探测器。顾名思义,用于检测并报告代码中的 Issue,每个 Issue 都要指定 Detector。
  3. Scope:翻译过来是表示范围的意思。这是用于声明 Detector 要扫描的代码范围,例如 JAVA_FILE_SCOPECLASS_FILE_SCOPERESOURCE_FILE_SCOPEGRADLE_SCOPE 等,一个 Issue 可包含一到多个 Scope。
  4. Scanner:翻译过来就是扫描器的意思。用于扫描并发现代码中的 Issue,每个 Detector 可以实现一到多个 Scanner。
  5. IssueRegistry: Lint 规则加载的入口,提供要检查的 Issue 列表。

如果要查看 lint 工具支持的 issue 的完整列表和它们所对应的 issue ID,可以使用 lint --list 命令。

Lint 中包括多种类型的 Scanner 如下,其中最常用的是扫描 Java 源文件和 XML 文件的 Scanner:

  • JavaScanner / JavaPsiScanner / UastScanner:扫描 Java 源文件
  • XmlScanner:扫描 XML 文件
  • ClassScanner:扫描 class 文件
  • BinaryResourceScanner:扫描二进制资源文件
  • ResourceFolderScanner:扫描资源文件夹
  • GradleScanner:扫描 Gradle 脚本
  • OtherFileScanner:扫描其他类型文件

我们需要注意的是,扫描Java源文件的Scanner先后经历了三个版本:

  1. 最开始使用的是 JavaScanner,Lint 通过 Lombok 库将 Java 源码解析成 AST(抽象语法树),然后由 JavaScanner 扫描。
  1. 在Android Studio 2.2和 lint-api 25.2.0版本中,Lint工具将 Lombok AST 替换为 PSI,同时弃用 JavaScanner,推荐使用 JavaPsiScanner。
  1. 在 Android Studio 3.0和 lint-api 25.4.0 版本中,Lint 工具将 PSI 替换为 UAST,同时推荐使用新的 UastScanner
    UAST 是 JetBrains 在 IDEA 新版本中用于替换 PSI 的 API。UAST 跟加语言无关,除了支持 Java,还可以支持 Kotlin.

扩展 —— 关于 PSI 的介绍在这里


自定义 Lint 规则的流程

自定义 Lint 和编写 gradle 插件一样,是一个纯 Java 项目,以 jar 的形式提供依赖。有了包含 Lint 规则的 jar 后,有两种使用方案:

  • 方案一:把此 Jar 拷贝到 ~/.android/lint/ 目录中(文件名任意)。此时,这些 Lint 规则针对所有项目生效。
  • 方案二:继续创建一个 Android library 项目,用来输出包含 Lint.jar 的 aar;然后,让目标项目依赖此 aar 即可使自定义 Lint 规则生效。

由于方案一是全局生效的策略,无法单独针对目标项目,用处不大。在工程实践中,我们主要使用方案二。

aar 是 Android Library 的一种新的二进制分发格式,它把资源也一起打包,这样一来图片和布局资源文件也能够被同时分发。aar 格式文件能够包含一个可选的 lint.jar 文件,如果一个 app 依赖了一个包含 lint.jar 的 aar 文件,那么这个 lint.jar 中的规则就会在 app 的 lint 任务中被用来做lint检查。

下面是一个自定义 Lint 项目的目录结构:
Android 自定义 Lint 实现静态代码扫描工具_第3张图片
主要包含了两个部分:

  • lintjar 主要是编写自定义 Lint 的规则,编译后生成 Lint.jar 文件
  • lintaar 主要将 Lint.jar 打包成 aar 方便引用

下面着重于如何编写自定义规则,也就是我们 lintjar 这个 module 的开发

一 、创建 Java 工程,配置 Gradle

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 这个命令来打包。

二 、选定一个规则用 Lint 实现

先用一个简单实现为例:
Android 自定义 Lint 实现静态代码扫描工具_第4张图片
如图,我们看到简单实现一个检测 Log 的规则非常简单:

  • 继承 Detector,并实现相应的 Scanner 接口
  • 实例化 Issue 对象
  • 使用 Lint 的 API 扫描代码、定义规则,也就是我们实现的 Scanner 接口。

-------------------------------------------下面重点介绍实现过程和对应的源码 Api 分析-------------------------------

Issue

Issue 由 Detector 发现并报告,是我们自己定义的需要统一的代码规则、代码优化点。Issue对象使用静态工厂方法构造实例对象,其方法参数的意义如下:

  1. id:唯一值,应该能简短描述当前问题。利用 Java 注解或者 XML 属性进行屏蔽时,使用的就是这个 id,所以最好使用英文。

  2. briefDescription:简短的总结,尽量控制在10字以内,描述问题而不是修复措施。

  3. explanation:完整的问题解释和修复建议。

  4. category:问题类别。在 API 当中已经定义当包括:CORRECTNESS(正确性)、SECURITY(安全性)、PERFORMANCE(性能)、USABILITY(易用性)、A11Y(便利性)、I18N(国际化),还有几个隶属于这些大类别中的子类别,详情可查阅源码 com.android.tools.lint.detector.api.Category

  5. priority:优先级。1-10 的数字,10 为最重要/最严重

  6. Implementation:为 Issue 和 Detector 提供映射关系,Detector 就是当前 Detector。声明扫描检测的范围 Scope,Scope 用来描述 Detector 需要分析时需要考虑的文件集,包括:Resource 文件或目录、Java 文件、Class 文件。


划重点 --> Scanner

自定义 Detector 的实际核心逻辑部分就是实现什么样的 Scanner 接口以及 Scanner 中的方法实现。上文也有说明,目前常用的是扫描 Java 源文件和 XML 文件的 Scanner,下面就以 JavaPsiScanner 和 XmlScaner 为例介绍一下关键 Api ,其他的目前由于没有精力研究,暂且搁置

JavaPsiScanner

下面是 JavaPsiScanner 这个扫描器的 6 组 12 个回调方法:

  1. createPsiVisitor() 构造一个代码访问器。一个 Detector 对象必须通过这个方法返回一个代码访问器对象,除非 Detector 对象的 appliesToResourceRefs() 返回了 True 或者 getApplicableMethodNames() 返回的不是 Null

  2. getApplicablePsiTypes() 返回访问器需要访问的Java元素类型。

  3. getApplicableMethodNames()visitMethod() 一般配对使用,访问特定方法

  4. getApplicableConstructorTypes()visitConstructor() 配对使用,访问特定构造器

  5. getApplicableReferenceNames()visitReference() 配对使用,访问特定的引用

  6. applicableSuperClasses()checkClass() 访问特定的超类

  7. appliesToResourceRefs()visitResourceReference() 访问 Java 代码中的资源引用,例如 R.layout.main

用法可以参考 Lint 源码 com.android.tools.lint.checks.LayoutInflationDetectorcom.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 元素的映射关系:

    • PsiClass -> 类
    • PsiMethod -> 方法
    • PsiField -> 字段属性
    • PsiVariable -> 变量(方法参数、属性、本地变量)
    • PsiExpression -> 表达式

XmlScaner

举个例子,ResourceXmlDetector 就是实现了 XmlScaner的 一个Detector,扫描 Xml 文件并获取 Xml Dom 元素以执行检查。

  1. createPsiVisitor() :构造一个代码访问器。一个 Detector 对象必须通过这个方法返回一个代码访问器对象,除非 Detector 对象的 appliesToResourceRefs() 返回了 True 或者 getApplicableMethodNames() 返回的不是 Null。

  2. getApplicableElements() 返回的是指定检查的元素(可以是多个)。

  3. visitElement() 这个方法在 XmlScanner 扫描到了对应的元素时调用。如代码,我在这里添加了自己的逻辑,当扫描 Textview 时,会去判断是否含有 textAppearance,如果没有的话就抛出自定义的 Issue。

  4. 再介绍几个用到的方法,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 命令拷贝。
Android 自定义 Lint 实现静态代码扫描工具_第5张图片
然后,在右边的 gradle 状态栏,找到主工程下的 assembleDebug,右键 create 一个新的 Run Configurations,将上一步拷贝的内容粘贴到 VM Options 内,注意要将 suspend 对应的值改为 y。
Android 自定义 Lint 实现静态代码扫描工具_第6张图片

最后,双击上一步新生成的调试任务,这时候就会进入挂起状态等待调试,接着在状态栏切换到之前未命名的 Remote 任务,点击右侧的 debug 调试按钮,就可以进入调试流程了,当然,你需要提前打好断点。
Android 自定义 Lint 实现静态代码扫描工具_第7张图片

后续会附上demo 以及其他规则的实现过程

你可能感兴趣的:(android,Lint,Java,Android,Lint,代码扫描工具,自定义)