Android静态代码检查及自定义Lint实现

概述


在日常的项目开发迭代中,相信每个人对与代码质量都是有着高要求的。但是,在所有事情中,人往往就是其中最大的变量因素,个人各异,如何去保障代码质量以及统一规范呢?开发团队也许会严格要求Code-Review以及MR来把控前期质量,也许会要求大量的自动化测试、单元测试、人工测试等来保障业务稳定性,也许还会集成异常捕获及时发现并修复线上问题。。。
任何的问题修复都需要有对应资源的付出,问题发现的越早,付出的成本也就越低。在 STICKYMINDS 网站上的一篇名为 《 The Shift-Left Approach to Software Testing 》 的文章中提到,假如在编码阶段发现的缺陷只需要 1 分钟就能解决,那么单元测试阶段需要 4 分钟,功能测试阶段需要 10 分钟,系统测试阶段需要 40 分钟,而到了发布之后可能就需要 640 分钟来修复 。
Android静态代码检查及自定义Lint实现_第1张图片

那有没有什么方法可以帮助我们更早地发现问题呢?有!那就是静态代码检查!

 

1、什么是静态代码检查

 

根据维基百科介绍,静态代码检查又称静态程序分析(英语:Static program analysis)是指在不运行程序的条件下,进行程序分析的方法。静态代码检查工具会以源代码为检查对象,从命名、语法、语义等多个维度进行扫描分析,发现可能存在的问题,并根据检查规则对问题进行严重等级划分,给出不同的标识和提示。
因为静态代码检查的对象为源代码,所以在编码阶段我们就能够发现并修复问题,时间节点的提前对应着修复问题所付出的成本大大降低。
Android静态代码检查及自定义Lint实现_第2张图片

2、价值

  • 提前发现代码问题,降低修复成本;
  • 相较于动态分析,静态代码检查速度更快;
  • 自定义规则实现,可用于编码规范限制,统一风格;

 

常用静态代码检查工具


不同的平台,不同的语言都会有一种或者多种代码检查工具,如iOS的Clang Static Analyzer、OCLint、Infer,前端的ESLint、JShint,Python的PyCharm等等,这里不做一一举例。下面将简单对比一下在Android开发中常见的几款检查工具。

维度 FindBugs/SpotBugs CheckStyle Lint
扫描对象 Java字节码 源代码文件 Java、XML、Kotlin、Java字节码、Gradle、图片资源、Manifest文件等
原理 基于BCEL库通过扫描字节码完成代码检查,主要做缺陷模式匹配和数据流分析 使用Antlr库对源码文件做词语法分析生成抽象语法树,遍历整个语法树匹配检测规则 基于抽象语法树分析
内置规则种类 300+检测规则 100+检测规则 300+检测规则
优点 针对字节码检查,对JDK定制化程度高,能发现Java代码中潜在的错误和缺陷 耗时相对较少、轻量、针对代码风格检查有有优势 官方支持、检测全面、扩展性强、支持自定义规则、配套工具完善
缺点 定制规则门槛高,依赖编译代码,扫描耗时 检查规则相对简单,无法检查潜在问题 检测字节码时依赖编译代码,全量检测耗时,版本迭代API较大

 

Lint使用


Android Lint 是 ADT 16(和 Tools 16)中引入的一个新工具,用于静态代码扫描发现 Android 项目源中的潜在错误,以及在正确性、安全性、性能、易用性、无障碍性和国际化方面是否需要优化改进。
Lint 既可以用作命令行工具,也可以与 Eclipse 和 IntelliJ 集成在一起。它被设计成独立于 IDE 的工具,我们可以在 Android Studio 中非常方便的使用它。

 

1、Lint源文件扫描工作流

 
Android静态代码检查及自定义Lint实现_第3张图片
应用源文件:源文件包含组成 Android 项目的文件,包括 Java、Kotlin 和 XML 文件、图标以及 ProGuard 配置文件。
lint.xml 文件:一个配置文件,可用于指定要排除的任何 lint 检查以及自定义问题严重级别。
lint 工具:一个静态代码扫描工具,您可以从命令行或在 Android Studio 中对 Android 项目运行该工具。lint 工具检查可能会影响 Android 应用的质量和性能的代码结构问题。强烈建议您先更正 lint 检测到的所有错误,然后再发布您的应用。
lint 检查结果:可以在控制台或 Android Studio 的 Inspection Results 窗口中查看 lint 检查结果。

 

2、Lint工具使用

  1. 从菜单栏中,依次选择 Analyze > Inspect Code
    在这里插入图片描述

  2. 在 Specify Inspection Scope 对话框中,查看设置。在 Inspection profile 下,选择配置文件。
    Android静态代码检查及自定义Lint实现_第4张图片

  3. 结果查看
    Android静态代码检查及自定义Lint实现_第5张图片

  4. lint扫描结果主要有下面几大类

Accessibility:无障碍,例如 ImageView 缺少 contentDescription 描述,String 编码字符串等问题。
Correctness:正确性,例如 xml 中使用了不正确的属性值,Java 代码中直接使用了超过最低 SDK 要求的 API 等。
Internationalization:国际化,如字符缺少翻译等问题。
Performance:性能,例如在 onMeasure、onDraw 中执行 new,内存泄露,产生了冗余的资源,xml 结构冗余等。
Security:安全性,例如没有使用 HTTPS 连接 Gradle,AndroidManifest 中的权限问题等。
Usability:易用性,例如缺少某些倍数的切图,重复图标等。

 

3、自定义Lint规则实现

 

Android studio 内置了许多Lint规则,但当内置规则无法完全匹配我们的需求时,尤其针对一些编码严谨性要求和编码规范要求,这就需要我们实现Lint规则的自定义;
下面就让我们来看看具体怎么实现规则的自定义吧。

 

3.1 新建module

 
自定义Lint需要一个纯Java项目,Module类型选择Java or Kotlin Library, 暂时命名 lint_customize。

 

3.2 build.gradle依赖配置

 

plugins {
    id 'java-library'
    id 'kotlin'
    id 'kotlin-kapt'
}

dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])
    compileOnly 'com.android.tools.lint:lint-api:30.2.1'
    compileOnly 'com.android.tools.lint:lint-checks:30.2.1'
}

sourceCompatibility = "1.8"
targetCompatibility = "1.8"

jar {
    manifest {
        attributes("Lint-Registry-v2": "com.jiang.lint.customize.IMockIssueRegistry")
    }
}
  1. 模块中添加了Lint相关依赖:
    com.android.tools.lint:lint-api:提供了 Lint 的 API,包括 Context、Project、Detector、Issue、IssueRegistry 等,后面会做介绍;
    com.android.tools.lint:lint-checks:包含了 Lint 支持的200多种规则;
    这里需要注意,只能使用 compileOnly 依赖,使用 implementation 将会编译报错
    Android静态代码检查及自定义Lint实现_第6张图片

  2. 因为Lint依赖库内部使用了Kotlin,所以这里也需要添加相关插件,否则编译正常,但是自定义的规则却不会生效;

  3. Lint-Registry-v2 注册,将自定义规则注册至Lint;

3.3 API说明

 

在正式开始实现前,我们先来了解几个Lint 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。

Scanner 类型 说明
UastScanner 扫描 Java、Kotlin 源文件
XmlScanner 扫描 XML 文件
ResourceFolderScanner 扫描资源文件夹
ClassScanner 扫描 Class 文件
BinaryResourceScanner 扫描二进制资源文件
GradleScanner 扫描Gradle脚本
IssueRegistry: Lint 规则加载的入口,提供要检查的 Issue 列表。

 

3.4 规则实现

 
下面就让我们开始定义XML布局文件内控件id命名检测规则 ViewIdDetector 吧:

  1. 定义 ViewIdDetector 类继承 Detector ,因为我们需要检查的内容在XML文件内,所以还需要实现 Detector.XmlScanner 接口;
  2. 指定文件选择
    在通过 XmlScanner 和 Scope 限制检查范围后,我们还需要使用 appliesTo 对文件进行进一步地选择,这里我限制只有 ResourceFolderType.LAYOUT 类型的布局文件才会进入最终的检测;
    /**
     * 自定义检测范围,layout文件
     * @param folderType
     * @return 
     */
    @Override
    public boolean appliesTo(@NonNull ResourceFolderType folderType) {
        return folderType == ResourceFolderType.LAYOUT;
    }
  1. 获取元素,进行元素内容分析
  • 重写 getApplicableElements 方法,返回我们所需的空间类型集合
  • 重写 visitElement 方法,获取元素 id 属性,与我们所需的命名规则进行匹配;若不满足规则,则通过 report 方法进行上报;

不同的 Scanner 扫描器所对应的 report 方法各有不同,需要根据具体场景实现;
在这里 report有三个参数,第一个参数是Issue,就是第二步中我们定义的规则; 第二个参数是当前节点; 第三个参数location会返回当前的位置信息,便于在报告中显示定位;最后的字符串用来为警告添加解释。
完整类实现如下:

public class ViewIdDetector extends Detector implements Detector.XmlScanner {

    /**
     * "ViewIdCheck" 是 Lint 规则的 id,必须是唯一的。
     * "ViewId命名不规范" 是简述。
     * "ViewIdName建议使用 view的缩写加上_xxx,例如tv_xxx, iv_xxx" 是详细解释。
     * 5 是优先级系数。必须是1到10之间的某个值。
     * ERROR 是严重程度
     * Implementation 是Detector间的桥梁,用于发现问题。Scope则用于确认分析范围。在本例中,我们必须处于资源文件层面才能分析前缀问题。
     */
    public static Issue ISSUE = Issue.create("ViewIdCheck",
            "ViewId命名不规范",
            "ViewIdName建议使用 view的缩写加上_xxx,例如tv_xxx, iv_xxx",
            Category.CORRECTNESS,
            5,
            Severity.ERROR,
            new Implementation(ViewIdDetector.class, Scope.RESOURCE_FILE_SCOPE));

    /**
     * 自定义检测范围,layout文件
     * @param folderType
     * @return
     */
    @Override
    public boolean appliesTo(@NonNull ResourceFolderType folderType) {
        return folderType == ResourceFolderType.LAYOUT;
    }

    @Nullable
    @Override
    public Collection<String> getApplicableElements() {
        return Arrays.asList(SdkConstants.TEXT_VIEW, SdkConstants.IMAGE_VIEW, SdkConstants.BUTTON);
    }

    @Override
    public void visitElement(@NotNull XmlContext context, @NotNull Element element) {
        //判断是否设置了 id
        if (!element.hasAttributeNS(SdkConstants.ANDROID_URI, SdkConstants.ATTR_ID)) {
            return;
        }
        //获取 id 命名,并进行前缀校验
        Attr attr = element.getAttributeNodeNS(SdkConstants.ANDROID_URI, SdkConstants.ATTR_ID);
        String value = attr.getValue();
        if (value.startsWith(SdkConstants.NEW_ID_PREFIX)) {
            String idValue = value.substring(SdkConstants.NEW_ID_PREFIX.length());
            boolean matchRule = true;
            String expMsg;
            switch (element.getTagName()) {
                case SdkConstants.TEXT_VIEW:
                    expMsg = "tv_";
                    matchRule = idValue.startsWith(expMsg);
                    break;
                case SdkConstants.IMAGE_VIEW:
                    expMsg = "iv_";
                    matchRule = idValue.startsWith(expMsg);
                    break;
                case SdkConstants.BUTTON:
                    expMsg = "btn_";
                    matchRule = idValue.startsWith(expMsg);
                    break;
            }
            if (!matchRule) {
                context.report(ISSUE, attr, context.getLocation(attr), "ViewIdName建议使用view的缩写_xxx; ${element.tagName} 建议使用 `${expMsg}_xxx`");
            }
        }
    }
}
  1. 创建一个 Issue 对象(即一条规则),用于和 ViewIdDetector 进行绑定;
public static Issue ISSUE = Issue.create(
        "ViewIdCheck",
        "ViewId命名不规范",
        "ViewIdName建议使用 view的缩写加上_xxx,例如tv_xxx, iv_xxx",
        CustomizeCategory.NAMING_CONVENTION,
        5,
        Severity.ERROR,
        new Implementation(ViewIdDetector.class, Scope.RESOURCE_FILE_SCOPE));

Issue.create方法说明如下

fun create(
    id: String,
    briefDescription: String,
    explanation: String,
    category: Category,
    priority: Int,
    severity: Severity,
    implementation: Implementation
)

id:唯一的id,简要表面当前提示的问题;
briefDescription: 简单描述当前问题参数;
explanation:详细解释当前问题和修复建议;
category:问题类别,可自定义;
priority:从1到10,10最重要;
Severity:严重程度,FATAL(奔溃), ERROR(错误), WARNING(警告),INFORMATIONAL(信息性),IGNORE(可忽略);
Implementation:Issue和哪个Detector绑定,以及声明 Scope 检查的范围

Category 也可以自定义类型,如下:

public class CustomizeCategory {
    public static final Category NAMING_CONVENTION = Category.create("CustomizeCategory命名规范", 100);
}
  1. 规则注册
    定义 CustomizeIssueRegistry 继承 IssueRegistry 提供 Issue 集合
class CustomizeIssueRegistry : IssueRegistry() {
    override val issues: List<Issue>
        get() = mutableListOf(ViewIdDetector.ISSUE)
}

gradle 中注册 CustomizeIssueRegistry

jar {
    manifest {
        attributes("Lint-Registry-v2": "com.jiang.lint.customize.CustomizeIssueRegistry")
    }
}

至此,规则定义部分已经完成。

  1. 使用
  • 通过 lintChecks 直接将 Lint module 引入需要检测module后,使用 Lint 命令即可进行检测。
lintChecks project(':lint_customize')

我们可以在代码中直接看到自定义规则的提示:
Android静态代码检查及自定义Lint实现_第7张图片
也可以在检测报告中找到规则的检测结果:
Android静态代码检查及自定义Lint实现_第8张图片

  • 为了更方便快捷的集成自定义的规则,我们还可以将其打包为 Jar 然后放入一个 aar 中,以便快速依赖;

4、自定义规则适用场景

 

我们选择了自定义规则,必是要有用武之地,让我们来看看哪些场景下可以发挥自己的创造力:
 
1. 编码规则
如上面的例子中,我们就要求开发人员在XML中进行 id 命名时必须带上控件缩写前缀,同理我们还可以检测文件命名、文件大小等;
 
2. 统一工具库使用
在项目开发中,我们往往会用到很多基础的工具功能,其中大部分我们会沉淀为一个底层工具库,在工具库中会针对各种异常场景进行完善的处理,但并不是所有开发者都知道这个工具库的存在,这里我们就看定义规则进行检测。
如:Log日志、Toast、SharedPrefrence、Glide、Picasso 等,我们可以定义规则检查 Java/Kotlin 文件,限制必须使用统一工具库提供接口;
 
3. TODO Check
检查代码中是否还有TODO尚未完成。例如开发时可能会在代码中写一些测试数据,最终上线前要确保删除;
 
4. 个人隐私限制
现在对于用户个人隐私安全越来越重视,很多系统API因涉及隐私政策问题,需要严格控制其使用。在此我们就可以指定规则检测特定API调用,来限制隐私数据的获取;

 
以上为几个条为本人认为可以继续实践的场景,大家可以发散思维寻找更多有价值的实现!

结语

 
本文对于Lint的自定义选择了一个小场景进行了实现,但是在项目开发中我们可能会对于Java、Kotlin、字节码等进行规则的定义,因覆盖检测范围的不同需要实现不同的 Lint API。大家在实际尝试中可以多多借鉴 lint-checks 库内已提供的 Detector,帮助理解更多API的正确使用。

你可能感兴趣的:(android)