天天看點

從lombok到UAST – 淺談Android Lint的AST Parser(1)

自從ADT 16第一次引入Android Lint(以下簡稱:Lint)以來,Lint便成為Android平台上最重要的靜态代碼掃描工具。與早期基于XPath的靜态掃描工具不同,Lint基于AST(Abstract Syntax Tree)進行分析,可以用來定制很複雜的掃描規則。

關于Lint的介紹,網上已有很多文章可參考。關于Lint規則的定制,也是個複雜的話題,這裡不展開。本文着眼點在于Lint使用的AST Parser,這是整個靜态掃描最核心的部分。

Lint從第一個版本就選擇了lombok-ast作為自己的AST Parser,并且用了很久。但是Java語言本身在不斷更新,Android也在不斷疊代出新,lombok-ast慢慢跟不上發展,是以Lint在25.2.0版增加了IntelliJ的PSI(Program Structure Interface)作為新的AST Parser。但是PSI于IntelliJ、于Lint也隻是個過渡性方案,事實上IntelliJ早已開始了新一代AST Parser,UAST(Unified AST)的開發,而Lint也将于即将釋出的25.4.0版中将PSI更新為UAST。

因為Lint釋出時間較久,已有很多研發人員基于lombok-ast開發了大量定制規則,出于相容目的,Lint一直到現在都還保留着lombok-ast,也就是說,從25.2.0之後,一直是新舊兩套AST Parser并存。我們現在就來看一看這個變遷的過程。

1、lombok-ast

說起Project lombok,這也是Java世界裡一個很經典的項目了。通過一組強大的annotation,它可以在編譯時修改編譯器前端生成的AST,最終生成符合JVM規範的位元組碼。可以簡單地了解為,lombok的annotation,實際上創造了一個文法簡化版的Java語言,幫助提升開發效率,lombok會在編譯階段将這種簡化的語言翻譯成真正的Java,保證其相容性。

lombok從一開始就同時支援兩大主流Java編譯器,javac和ECJ(Eclipse Compiler for Java)。這兩個編譯器雖然都可以生成符合JVM規範的位元組碼,但它們的實作是完全不同的。這使得lombok團隊不得不建立了一個子工程lombok-ast,專門用于封裝特定編譯器的AST實作細節。也正是由于它的編譯器無關特性,Lint選擇了這個子產品。

lombok-ast雖然功能很強,但是到今天它越來越不夠用了,具體地說,有這麼幾個缺點:

1.1、對Java語言隻支援到Java 6

lombok-ast的更新維護太慢了,它對Java語言的支援隻能完美支援到Java 6 …… 對于Java 7、Java 8的新特性,根本不支援 …… 可是我們都看到,Android從4.4就開始支援Java 7了,從6.0開始支援Java 8,如果你裝了Android Studio >= 2.1和SDK >= 24,開發目标版本較低的應用一樣可以用某些Java 8特性。眼看Java 9就要釋出了,lombok-ast還隻支援到Java 6 ……

例如Java 8裡一個大家喜聞樂見的新特性,lambda表達式

public void demoThreadUseLambda() {
    new Thread(() -> {
        System.out.println("Thread running ......");
    }).start();
}
           

lombok-ast是不支援的,如果你試圖自己寫個Lint規則,檢測其中的内聯類,文法上是根本做不到的。

于是,有人自己擴充了lombok-ast的文法支援,讓其能支援到Java 8,這其中做的比較好的例如 android-retrolambda-lombok 。但這樣也有問題,Lint官方版不認啊 …… 你隻能自己本地改代碼。

但是這個還算是可以自己修改的,更不能忍的是下面這點。

1.2、不支援類型解析

現代編譯器的特性裡,Type Resolver / Declaration Resolver已經是标配,畢竟進階語言普遍支援多态,判斷一個接口或抽象類型的執行個體到底是什麼類型是很必要的。

但是lombok-ast卻不支援這點 …… 當然說不支援并不完全準确,它裡面其實是有一個

lombok.ast.resolve.Resolver

的,但是其能力基本可以讓人放棄。隻能說,lombok這個項目,從它最初的目的來看,它還真不需要多麼進階的類型解析。因為它本來的用途就不是代碼靜态分析,光是修改AST這點,還真的隻需要簡單的符号分析就夠了。

于是我們在Lint裡就看到了Google做的一個有點奇葩的設計,ResolvedNode機制。

package com.android.tools.lint.client.api;

public abstract class JavaParser {
    public abstract ResolvedNode resolve(@NonNull JavaContext context, @NonNull Node node);

    /** A resolved declaration from an AST Node reference */
    public abstract static class ResolvedNode {
        ......
    }

    /** A resolved class declaration (class, interface, enumeration or annotation) */
    public abstract static class ResolvedClass extends ResolvedNode {
        ......
    }

    /** A method or constructor declaration */
    public abstract static class ResolvedMethod extends ResolvedNode {
        ......
    }

    /** A field declaration */
    public abstract static class ResolvedField extends ResolvedNode {
        ......
    }

    /**
     * An annotation <b>reference</b>. Note that this refers to a usage of an annotation,
     * not a declaraton of an annotation. You can call {@link #getClassType()} to
     * find the declaration for the annotation.
     */
    public abstract static class ResolvedAnnotation extends ResolvedNode {
        ......
    }

    /** A package declaration */
    public abstract static class ResolvedPackage extends ResolvedNode {
        ......
    }

    /** A local variable or parameter declaration */
    public abstract static class ResolvedVariable extends ResolvedNode {
        ......
    }
}
           

ResolvedNode機制是對lombok-ast的一個擴充,對于一個lombok-ast封裝的AST節點

lombok.ast.Node

,Lint允許你通過

com.android.tools.lint.client.api.JavaParser#resolve()

将其解析成一個

com.android.tools.lint.client.api.JavaParser.ResolvedNode

節點,ResolvedNode就包含了AST節點的類型解析資訊。

是以當我們基于lombok-ast定制規則時,隻要規則稍複雜點,或者我們需要更精确的實作,我們就不得不面臨一個尴尬的局面,就是我們必須在lombok-ast提供的AST節點樹和Lint提供的ResolvedNode類型解析樹之間來回切換,這兩個樹的結構是一一對應的,但是卻是分離的 …… 想必Google自己也很受不了這點,是以後面才要換成PSI / UAST。

這裡多說一句,可能有人感到好奇,Lint又是如何實作類型解析的呢?原因是Lint使用了ECJ作為lombok-ast的編譯器實作,而ECJ在編譯階段其實已經為每個AST節點生成了類型解析資訊并附加在節點上,也就是ECJ牛逼的binding機制。

package org.eclipse.jdt.internal.compiler;

public class Compiler implements ITypeRequestor, ProblemSeverities {
    protected void internalBeginToCompile(ICompilationUnit[] sourceUnits, int maxUnits) {
        ......
        try {
                ......
                // diet parsing for large collection of units
                CompilationUnitDeclaration parsedUnit;
                ......
                if (this.totalUnits < this.parseThreshold) {
                    parsedUnit = this.parser.parse(sourceUnits[i], unitResult);
                } else {
                    parsedUnit = this.parser.dietParse(sourceUnits[i], unitResult);
                }
                ......
                // initial type binding creation
                this.lookupEnvironment.buildTypeBindings(parsedUnit, null /*no access restriction*/);
                ......
                addCompilationUnit(sourceUnits[i], parsedUnit);
                ......

        // binding resolution
        this.lookupEnvironment.completeTypeBindings();
    }
}
           

在Lint裡,前述JavaParser的實作類是EcjParser,在其中讀取lombok-ast提供的ECJ AST節點(也即lombok-ast所謂的Native Node),擷取其binding資訊,完成了最終的類型解析。

package com.android.tools.lint;

public class EcjParser extends JavaParser {
    public ResolvedNode resolve(@NonNull JavaContext context, @NonNull Node node) {
        Object nativeNode = getNativeNode(node);
        if (nativeNode == null) {
            return null;
        }

        if (nativeNode instanceof NameReference) {
            return resolve(((NameReference) nativeNode).binding);
            ......
    }

    private ResolvedNode resolve(@Nullable Binding binding) {
        if (binding == null || binding instanceof ProblemBinding) {
            return null;
        }

        if (binding instanceof TypeBinding) {
            TypeBinding tb = (TypeBinding) binding;
            return new EcjResolvedClass(tb);
            ......
    }
}