上篇博客《Android自定义Lint实践》中我们介绍了美团App如何使用自定义Lint进行代码检查。

在使用Lint的过程中,我们陆续又发现原生Lint的一些问题和缺陷,本文将介绍我们在实践中提出的解决方案。

上一篇博客中我们提到了对于HashMap检测的改进,但当时我们也在文章中提到:

代码很简单,总体就是获取变量定义的地方,将泛型值传入原先的检测逻辑。 当然这里的增强也是有局限的,比如这个变量是成员变量,向前的推断就会有问题,这点我们还在持续的优化中。

即:当时的检测解决了变量声明和变量赋值在一起的HashMap检测问题。但对于两者不在一起的情况,我们仍然无法检测到。

示例代码如下:

public static void testHashMap() {
	//这种情况可以用上篇博客的检查搞定
    Map<Integer, String> map1 = new HashMap<>();
    map1.put(1, "name");
    //这种找不到map2的变量声明,所以用上篇博客的检查是无法判断的
    map2 = new HashMap<>();
    map2.put(2, "name2");
}

通过我们的探索,目前已经解决了这个问题。

下面我们来详细介绍下:

我们需要解决的情况

  1. 在同一个类中 java public Map<Integer, String> map; public static Map<Integer, String> map2; public void test() { // 1: 成员变量 map = new HashMap<>(); map.put(1, "name"); // 2: 静态变量 map2 = new HashMap<>(); map2.put(1, "name"); }
  2. 方法参数

    public void test1(Map<Integer, String> map) {
        map = new HashMap<>();
        map.put(1, "name");
    }
    
  3. 变量声明在另一个类中

    public class HashMapCase4_2 {
        public void test() {
            // 1: 另一个类的静态变量
            HashMapCase4_1.map2 = new HashMap<>();
            HashMapCase4_1.map2.put(1, "name");
            // 2: 另一个对象的成员变量
            HashMapCase4_1 case4_1 = new HashMapCase4_1();
            case4_1.map = new HashMap<>();
            case4_1.map.put(1, "name");
            // 3: 内部类静态变量
            Sub.map2 = new HashMap<>();
            // 4: 内部类对象的成员变量
            Sub sub = new Sub();
            sub.map = new HashMap<>();
        }
        private static class Sub {
            public Map<Integer, String> map;
            public static Map<Integer, String> map2;
        }
    }
    

解决方案

在Google官方提供的资料:Writing a Lint Check中我们发现了如下描述:

In the next version of lint (Tools 27, Gradle plugin 0.9.2+, Android Studio 0.5.3, and ADT 27); Java AST parse tree detectors can both resolve types and declarations. This was just added to lint, and offers new APIs where you can ask for the resolved type, and the resolved declaration, of a given AST node.

这里提到了resolved type,那究竟有什么用呢?

Google在描述中留下当时的commit,其中提到:

Add type and declaration resolution to Lint’s Java AST

The AST used by lint, Lombok AST, does not contain type information. That means code which for example sees this code:

getContext().checkPermission(name)

can’t find out which “checkPermission” method this is. That requires full type resolution.

根据官方描述,我们可以拿到方法属于哪个类。那resolved type是否可以帮助我们通过变量拿到变量声明呢?

在参考了commit中的代码后,我们尝试使用context.resolve来解析第一种情况中的变量map

结果证实确实帮我们解析到了变量声明的类型。

但它可以帮我们把所有情况都分析到么?我们带着怀疑的态度继续尝试,结果发现在第三种情况的case4_1.mapsub.map出现了问题:

即只分析到了map所属的对象,而无法拿到map的类型。

显然,这个解析出来的节点不仅没有帮助我们,反而让我们偏离了我们要分析的节点。

在查看JavaContext相关代码后我们发现,除了resolve还有一个getType方法,似乎从名字上看可以解决我们的问题。

@Nullable
public ResolvedNode resolve(@NonNull Node node) {
    return mParser.resolve(this, node);
}
@Nullable
public TypeDescriptor getType(@NonNull Node node) {
    return mParser.getType(this, node);
}

尝试后发现,getType适合我们列出的所有情况。

那么,两者区别是什么呢?

通过对Android Gradle Plugin(下文中称Plugin)中Lint相关代码的分析,我们发现:
在Plugin中,Lint检查依靠ECJ(Eclipse Compiler for Java)来生成抽象语法树,上文代码中提到的mParser在Plugin中对应的是EcjParser

解析时,对于case4_1.mapsub.map两个节点,resolve利用的是binding,而getType调用的是resolvedType(注意:这里的resolvedType是ECJ中的变量)。

Bindings是ECJ一个强大的功能,有很多子类型,例如VariableBindingTypeBinding等。

对于同一个节点可能还有多个binding(例如QualifiedNameReferenceotherBindings会存放多个,上述例子中可以看到其实有case4_1.mapmap类型,但在otherBindings中);而resolvedTypeTypeBinding。显然,使用resolvedType可以确保我们拿到的是类型。

这里还需要注意的是:虽然上述分析中,我们提到的这些是由ECJ提供的,且Lint中的Node也保留了拿到ECJ Node的能力,即:getNativeNode。但并不推荐大家直接使用ECJ。

因为Lint使用tnorbye/lombok.ast的本意就是不依赖具体的Parser(Writing a Lint Check中提到,他们曾经使用了多种parser),上层Detector应尽量使用Lombok AST。

美团App使用了Retrolambda,当然为了在Retrolambda下Lint能正常运行,我们引入了evant/android-retrolambda-lombok,替换官方AST(抽象语法树)为Retrolambda实现的AST。

但在lambda中写Toast经常会提示没有show, 示例如下:

public void test() {
	findViewById(R.id.button).setOnClickListener(view -> Toast.makeText(MainActivity.this, "xxx", Toast.LENGTH_SHORT).show());
}

Lint检查报告:Toast created but not shown: did you forget to call show() ?

从代码可以看到,虽然我们写了show,但还是检测说没有show。

这时候如果把Toast相关的代码抽离成单独的方法,检测就又会恢复正常。于是我们决定分析下究竟发生了什么?

通过gradle debug,我们发现ToastDetector在寻找包围Toast方法时出现了问题。

Node method1 = JavaContext.findSurroundingMethod(node.getParent());

而findSurroundingMethod方法的实现如下:

@Nullable
public static Node findSurroundingMethod(Node scope) {
    while (scope != null) {
        Class<? extends Node> type = scope.getClass();
        // The Lombok AST uses a flat hierarchy of node type implementation classes
        // so no need to do instanceof stuff here.
        if (type == MethodDeclaration.class || type == ConstructorDeclaration.class) {
            return scope;
        }
        scope = scope.getParent();
    }
    return null;
}

到这里总结一下:
当ToastDetector找到Toast的时候,它会寻找外围的方法,如果是匿名内部类的方法或者其他方法时,他能够判断到并返回这个节点。

但是对于lambda来说,它只能查找到最外层的方法,也就是示例中setOnClickListener外围的test方法,lambda并不会被识别到。

lambda在语句附近能识别到的是lombok.ast.LambdaExpression,而不是MethodDeclaration或者ConstructorDeclaration,所以会一直找到test这个MethodDeclaration

问题搞清楚了,解决办法也就有了:
我们加入一个LambdaExpression判断,提前返回,这样就可以正常识别了。

private static boolean isLambdaExpression(Class type) {
    return "lombok.ast.LambdaExpression".equals(type.getName());
}

这里需要说明的是,我们用字符串比对而不是跟MethodDeclaration一样去比对class,这是为了更好的兼容所有使用者。

因为LambdaExpression是由Retrolambda的AST提供,并不是官方的AST。也就是说如果我们想判断class就必须依赖Retrolambda的AST,我们之前也提到过自定义Lint输出的是一个JAR,并不包含这些依赖,运行时环境中如果没有使用Retrolambda AST的话就会直接ClassNotFound。

所以,这里我们选择了字符串比对,达成目标的同时,也让检测变得更简单。

Detector写好了,但是与HashMap的增强不同,ToastDetector这个实现只能选择替换掉系统实现。因为HashMap两者是增强,可以共存;而ToastDetector如果系统检测正常运行的话,遇到这种情况就会报错。所以我们反射修改内置IssueRegistry(BuiltinIssueRegistry) 完成系统Detector的替换。

本文相关示例源码已经开放,见:MeituanLintDemo

陈潼,美团Android高级工程师,效力于美团平台技术部。2015年加入美团,先后负责代码静态检查、网络层优化等基础设施开发工作。

美团平台技术部客户端技术团队,负责美团平台的基础业务和移动基础设施的开发工作。基于海量用户的美团平台,支撑了美团多条业务线的快速发展。同时,我们也在移动开发技术方面做了一些积极的探索,在动态化、质量保障、开发模型等方面有一定积累。客户端技术团队积极采用开源技术的同时,也把我们的一些积累回馈给开源社区,希望跟业界一起推动移动开发效率、质量的提升。

最后来个硬广告:客户端技术团队长期招聘技术专家,有兴趣的同学可以发送简历到:fangjintao#meituan.com。