自定义 Lint 检查实践指南

1,586 阅读8分钟

本文在官方文档的基础上,详细讲解了自定义 Lint 检查代码的步骤,并给出了调试代码的方法和发布流程,方便团队进行代码的管理。

目录

1. 背景

之前开发过程中遇到过一些坑,并产生了大量的线上崩溃,遇到过的一些问题如下:

  1. 有些颜色值是通过后端下发的,但是在使用 Color.parseColor() 方法时,如果后端返回的不是标准的颜色格式,就会 crash。
  2. 在 AndroidManifest.xml 文件中对一个 Activity 同时设置方向和透明主题时,在 Android 8.0 手机上会 crash。

但是这些类似的错误并不是每位开发者都会知道,所以即使一个人遇到过,以后可能还会有人犯同类的错误。

因此,为了避免后人踩相同的坑,我们可以利用 Lint 检查工具,对大家写的代码进行检查,针对可能会产生问题的代码进行友好的提示,并在打包中的 Lint 检查过程中禁止编译通过。

IDE 自带的 Lint 检查的使用可参见 developer.android.com/studio/writ…,但是这是不能满足我们需求的,因此需要我们自己实现 Lint 检查的代码。

下面来看一下是如何自定义 Lint 检查的。

2. 创建 Lint 检查项目

2.1 新建工程

使用 Android Studio 新建一个空工程,在选择工程模板的界面,选择第一个 No Activity,然后其余的和常规项目没有区别。

在项目根目录的 build.gradle 文件添加依赖:

buildscript {
    // ...
    dependencies {
        classpath "com.android.tools.lint:lint:26.3.2"
    }
}

2.2 新建 lint module

新建一个 module,在选择 module 类型的界面,选择 Java or Kotlin Library,然后给新建的 module 命名,例如 lint

在新建的 module 下的 build.gradle 文件添加依赖:

dependencies {
    // Lint
    compileOnly "com.android.tools.lint:lint-api:26.3.2"
    compileOnly "com.android.tools.lint:lint-checks:26.3.2"
    // Lint Testing
    testImplementation "com.android.tools.lint:lint:26.3.2"
    testImplementation "com.android.tools.lint:lint-tests:26.3.2"
}

2.3 在 app module 添加 lintChecks

为了方便在写完 Lint 检查的代码后进行测试,在 app module 下的 build.gradle 文件添加依赖:

dependencies {
    lintChecks project(':lint')
}

3. 注册检查列表

在 lint module 新建一个类继承自 IssueRegistry,其中 getIssues() 方法先返回一个空列表,并重写一下 getApi() 方法:

class MyIssueRegistry extends IssueRegistry {
    @NotNull
    @Override
    public List<Issue> getIssues() {
        List<Issue> issues = new ArrayList<>();
        return issues;
    }

    @Override
    public int getApi() {
        return ApiKt.CURRENT_API;
    }
}

然后在 lint module 下的 build.gradle 文件添加如下配置:

jar {
    manifest {
        attributes("Lint-Registry": "com.jimmysun.android.lint.MyIssueRegistry")
    }
}

4. 自定义 Lint 检查

下面来看看如何实现自定义 Lint 检查的代码。

4.1 Issues vs Detectors

首先来区分一下这两个概念。Issue 代表你想要发现并提示给开发者的一种问题,包含描述、更全面的解释、类型和优先级等等。官方提供了一个 Issue 类,你只需要实例化一个 Issue,并注册到 IssueRegistry 里。

另外你还需要实现一个 Detector。Detector 负责扫描代码并找到有问题的地方,然后把它们报告出来。一个 Detector 可以报告多种类型的 Issue,你可以针对不同类型的问题使用不同的严重程度,这样用户可以更精确地控制他们想要看到的内容。

下面我们就以检测 AndroidManifest.xml 和资源文件来举例。创建一个 Detector

public class FixOrientationTransDetector extends Detector {
    private static final Implementation IMPLEMENTATION =
            new Implementation(FixOrientationTransDetector.class, EnumSet.of(Scope.MANIFEST,
                    Scope.ALL_RESOURCE_FILES));

    public static final Issue ISSUE = Issue.create(
            "FixOrientationTransError",
            "不要在 AndroidManifest.xml 文件里同时设置方向和透明主题",
            "Activity 同时设置方向和透明主题在 Android 8.0 手机会 Crash",
            Category.CORRECTNESS,
            8,
            Severity.ERROR,
            IMPLEMENTATION);
}

Implementation 我们后面再解释。先看 Issue.create() 方法,其参数定义如下:

  1. id:唯一的 id,简要表达当前问题。
  2. briefDescription:简单描述当前问题。
  3. explanation:详细解释当前问题和修复建议。
  4. category:问题类别,在 Android 中主要有如下六大类:
    • SECURITY:安全性。例如在 AndroidManifest.xml 中没有配置相关权限等。
    • USABILITY:易用性。例如重复图标,一些黄色警告等。
    • PERFORMANCE:性能。例如内存泄漏,xml 结构冗余等。
    • CORRECTNESS:正确性。例如超版本调用 API,设置不正确的属性值等。
    • A11Y:无障碍(Accessibility)。例如单词拼写错误等。
    • I18N:国际化(Internationalization)。例如字符串缺少翻译等。
  5. priority:优先级,从 1 到 10,10 最重要。
  6. severity:严重程度,包括 FATALERRORWARNINGINFORMATIONALIGNORE
  7. implementation:Issue 和哪个 Detector 绑定,以及声明检查的范围。

之后将 FixOrientationTransDetector 注册到上面的 MyIssueRegistry 里:

public List<Issue> getIssues() {
    List<Issue> issues = new ArrayList<>();
    issues.add(FixOrientationTransDetector.ISSUE);
    return issues;
}

4.2 Scopes

再来说说上面创建的 Implementation 对象,它的构造方法的第二个参数传入一个 Scope 枚举类的集合,包括:

  • 资源文件
  • Java 源文件
  • Class 文件
  • Proguard 配置文件
  • Manifest 文件
  • 等等

Issue 需要指定分析代码所需的范围,例如上面代码我们要检查的是 Manifest 文件和资源文件。

4.3 Scanner

自定义 Detector 还需要实现一个或多个以下接口:

  • UastScanner:扫描 Java 文件和 Kotlin 文件
  • ClassScanner:扫描 Class 文件
  • XmlScanner:扫描 XML 文件
  • ResourceFolderScanner:扫描资源文件夹
  • BinaryResourceScanner:扫描二进制资源文件
  • OtherFileScanner:扫描其他文件
  • GradleScanner:扫描 Gradle 脚本

因为我们需要扫描的 AndroidManifest.xmlstyles.xml 都是 XML 文件,那么需要实现 XMLScanner 接口:

public class FixOrientationTransDetector extends Detector implements XmlScanner

4.4 扫描 XML 文件

要分析一个 XML 文件,你可以重写 visitDocument() 方法。这个方法每个 XML 文件都会调用一次,然后传入 XML DOM 模型,之后你就可以自己遍历并做分析。

但是呢,我们通常只关注一些特定的标签和一些特定的属性,为了让扫描更快,Detector 可以指定我们关注的元素和属性。

要筛选我们关注的元素或属性,只需实现 getApplicableElements() 或 getApplicableAttributes() 方法,并返回一个标签或属性名称的字符串列表。然后再实现 visitElement() 或 visitAttribute() 方法,这两个方法针对每个指定的元素和属性都会调用一次。

接上例,我们需要分析的是 activity 和 style 标签,那么需要实现 getApplicableElements() 方法:

@Override
public Collection<String> getApplicableElements() {
    return Arrays.asList(SdkConstants.TAG_ACTIVITY, SdkConstants.TAG_STYLE);
}

你也可以从 getApplicableElements() 和 getApplicableAttributes() 方法返回一个 ALL 常量,这样对于所有的元素或属性都会调用一次。

另外 SdkConstants.java 类内置了很多常量可以直接使用,包括 TAG_MANIFESTTAG_RESOURCES 等等,如果没有也可以自己手写。

之后我们要实现 visitElement() 方法来进行分析。我们需要判断 activity 标签中配置的 android:screenOrientation 的某些属性与透明主题是否同时设置的,如果出现这种情况则报告出来,代码如下:

private final Map<ElementEntity, String> mThemeMap = new HashMap<>();

@Override
public void visitElement(@NotNull XmlContext context, @NotNull Element element) {
    switch (element.getTagName()) {
        case SdkConstants.TAG_ACTIVITY:
            if (isFixedOrientation(element)) {
                String theme = element.getAttributeNS(SdkConstants.ANDROID_URI,
                        SdkConstants.ATTR_THEME);
                if ("@style/Theme.AppTheme.Transparent".equals(theme)) {
                    reportError(context, element);
                } else {
                    // 将主题设置暂存起来
                    mThemeMap.put(new ElementEntity(context, element),
                            theme.substring(theme.indexOf('/') + 1));
                }
            }
            break;
        case SdkConstants.TAG_STYLE:
            String styleName = element.getAttribute(SdkConstants.ATTR_NAME);
            mThemeMap.forEach((elementEntity, theme) -> {
                if (theme.equals(styleName)) {
                    if (isTranslucentOrFloating(element)) {
                        reportError(elementEntity.getContext(), elementEntity.getElement());
                    } else if (element.hasAttribute(SdkConstants.ATTR_PARENT)) {
                        // 替换成父主题
                        mThemeMap.put(elementEntity,
                                element.getAttribute(SdkConstants.ATTR_PARENT));
                    }
                }
            });
            break;
        default:
    }
}

private boolean isFixedOrientation(Element element) {
    switch (element.getAttributeNS(SdkConstants.ANDROID_URI, "screenOrientation")) {
        case "landscape":
        case "sensorLandscape":
        case "reverseLandscape":
        case "userLandscape":
        case "portrait":
        case "sensorPortrait":
        case "reversePortrait":
        case "userPortrait":
        case "locked":
            return true;
        default:
            return false;
    }
}

private boolean isTranslucentOrFloating(Element element) {
    for (Node child = element.getFirstChild(); child != null; child = child.getNextSibling()) {
        if (child instanceof Element
                && SdkConstants.TAG_ITEM.equals(((Element) child).getTagName())
                && child.getFirstChild() != null
                && SdkConstants.VALUE_TRUE.equals(child.getFirstChild().getNodeValue())) {
            switch (((Element) child).getAttribute(SdkConstants.ATTR_NAME)) {
                case "android:windowIsTranslucent":
                case "android:windowSwipeToDismiss":
                case "android:windowIsFloating":
                    return true;
                default:
            }
        }
    }
    return "Theme.AppTheme.Transparent".equals(element.getAttribute(SdkConstants.ATTR_PARENT));
}

private void reportError(XmlContext context, Element element) {
    context.report(
            ISSUE,
            element,
            context.getLocation(element),
            "请不要在 AndroidManifest.xml 文件里同时设置方向和透明主题"
    );
}

private static class ElementEntity {
    private final XmlContext mContext;
    private final Element mElement;

    public ElementEntity(XmlContext context, Element element) {
        mContext = context;
        mElement = element;
    }

    public XmlContext getContext() {
        return mContext;
    }

    public Element getElement() {
        return mElement;
    }
}

这里先提一下 Lint 处理文件的顺序:

  1. Manifest 文件
  2. 资源文件,按字母顺序处理(先是按资源文件夹的字母顺序,然后在每个文件夹里的字母顺序)
  3. Java 源文件
  4. Java class 文件,按字母顺序处理(如果有内部类,外部类在内部类之前)
  5. Proguard 文件

那么上面代码大体逻辑是这样的:因为 Lint 分析会先检查 AndroidManifest 文件,后检查资源文件,那么在检查 AndroidManifest 文件时如果遇到 Activity 同时设置了方向和主题,将相应节点和主题名先暂存下来。在检查资源文件时,判断暂存的主题里是否存在透明设置,如果存在则上报出来,否则将暂存的主题名改成父主题(如果有的话)。这里会有个缺陷,就是如果主题的继承关系比较复杂,可能会有漏报的情况。

另外,上面代码中有个上报错误的方法 reportError(),这个后面再详细说明。

4.5 分析 Java/Kotlin 源文件

此外我们再来讲讲如何分析 Java 和 Kotlin 文件,我们以分析 Color.parseColor() 方法为例进行说明。旧版本的 Detector 需要实现 JavaScanner 接口,新的已经被 UastScanner 替代。示例代码:

public class ParseColorDetector extends Detector implements Detector.UastScanner {
    private static final Implementation IMPLEMENTATION =
            new Implementation(ParseColorDetector.class, Scope.JAVA_FILE_SCOPE);
    public static final Issue ISSUE = Issue.create(
            "ParseColorError",
            "Color.parseColor 解析可能 crash",
            "后端下发的色值可能无法解析,导致 crash",
            Category.CORRECTNESS,
            8,
            Severity.ERROR, IMPLEMENTATION)
            .setAndroidSpecific(true);
 
    @Override
    public List<String> getApplicableMethodNames() {
        return Collections.singletonList("parseColor");
    }
 
    @Override
    public void visitMethodCall(@NotNull JavaContext context, @NotNull UCallExpression node,
                                @NotNull PsiMethod method) {
        // 不是 android.graphics.Color 类的方法,直接返回
        if (!context.getEvaluator().isMemberInClass(method, "android.graphics.Color")) {
            return;
        }
        // 参数写死的比如 "#FFFFFF" 这种,简单判断如果是 # 号开头,直接返回
        if (isConstColor(node)) {
            return;
        }
        // 已经做了 try catch 处理,直接返回
        if (isWrappedByTryCatch(node, context)) {
            return;
        }
        reportError(context, node);
    }
 
    private boolean isConstColor(UCallExpression node) {
        return node.getValueArguments().get(0).evaluate().toString().startsWith("#");
    }
 
    private boolean isWrappedByTryCatch(UCallExpression node, JavaContext context) {
        if (context.getUastFile() instanceof KotlinUFile) {
            return UastUtils.getParentOfType(node.getUastParent(), UTryExpression.class) != null;
        }
        for (PsiElement parent = node.getSourcePsi().getParent(); parent != null && !(parent instanceof MethodElement); parent = parent.getParent()) {
            if (parent instanceof PsiTryStatement) {
                return true;
            }
        }
        return false;
    }
 
    private void reportError(JavaContext context, UCallExpression node) {
        context.report(ISSUE, node, context.getCallLocation(node, false, false)
                , "Color.parseColor 解析后端下发的值可能导致 crash,请 try catch");
    }
}

同分析 XML 文件一样,你需要实现 getApplicableXXX()visitXXX() 方法,例如我们需要分析 parseColor() 方法,那么就要重写 getApplicableMethodNames()visitMethodCall() 方法。

4.6 报告错误

如果你的 Detector 定位到一个问题,需要使用 Context 对象(Detector 的每个方法都会传入进来)调用 report() 方法来报告错误,例如 4.4 节中的代码如下:

private void reportError(XmlContext context, Element element) {
    context.report(
            ISSUE,
            element,
            context.getLocation(element),
            "请不要在 AndroidManifest.xml 文件里同时设置方向和透明主题"
    );
}

除了列出要报告的问题外,还需要提供位置、作用域节点和错误提示消息:

  • 作用域节点:对于 XML 和 Java 源文件,是指发生的错误周围最近的 XML DOM 或 Parse AST 树节点,例如上面传入的 element 对象。
  • 位置:是指错误发生的位置。一般只需将 AST/XML 节点传递给 context.getLocation() 方法,该方法将创建一个具有正确文件名和与给定节点相对应的偏移量的 Location。如果你的错误与某个属性有关,则传递该属性,以使该问题更好地指出错误发生的位置。

好了,这样一个完整的自定义 Lint 检查的代码就算完成了。

更多关于状态保存、多阶段操作、分析 class 文件和增量 Lint 等高级用法可以参见:tools.android.com/tips/lint/w…

5. 执行 Lint 检查

在编写完 Lint 检查的代码之后就可以使用 ./gradlew :app:lintDebug 命令执行 Lint 检查了,我在 app module 下故意写了两个出问题的代码,对应输出结果如下:

lintDebug 输出

上面两个链接是分析报告,下面是错误的提示。

5.1 分析报告

一般 HTML 版的报告更清晰一些,我们复制链接到浏览器里查看一下,可以看到与我们代码对应的关系:

HTML 报告

点击 FixOrientationTransError 可以看到 report() 方法输出的信息和定义的问题类别、严重程度和优先级等,如下:

FixOrientationTransError 详情

截图中间那部分是我后加的,读者不用在意。

5.2 错误提示

刚才终端输出的错误提示也是 report() 方法输出的信息,因为我们传递了 Location,所以输出了问题出现在哪个文件的哪一行并可以直接点击跳转源码对应的位置。

6. 调试代码

有的时候我们写完代码可能并不会完美地按照我们的想法去分析,那么我们还可以通过调试代码来查找问题,方法如下。(该方法也适用于自定义 gradle plugin 的调试。)

6.1 新建 Remote 配置

找到**「Edit Configurations...」**,如图:

Edit Configurations...

然后点击左上角的加号选择 Remote,如图:

新建 Remote

然后在右侧输入一个名字,例如 LintCheckDebug,其它的使用默认值就好,最后点击 OK,如图:

Remote 配置

6.2 开启调试

在命令行启动远程调试器来调试对应的任务,例如我们要调试的任务是 lintDebug,那么就输入如下命令:

./gradlew --no-daemon -Dorg.gradle.debug=true :app:lintDebug

最后,我们在代码中打好相应的断点,选中我们上一步创建的 Remote 配置,点击 Debug 按钮即可开始调试我们的自定义 Lint 检查的代码了。

7. 发布

我们可以发布 aar 到远程仓库,步骤可以参见 juejin.cn/post/684490…

但是我这里走的公司内部发布流程,上面方法并没有验证过。

最后各个组件可以在 build.gradle 文件添加 lint 检查:

dependencies {
    lintChecks "com.xxx.lint:lint-checks:x.x.x"
}

参考文章