Android自定義Lint實踐2——改進原生Detector
在使用Lint的過程中,我們陸續又發現原生Lint的一些問題和缺陷,本文將介紹我們在實踐中提出的解決方案。
完善JDK 7泛型新寫法下的HashMap檢測
上一篇博客中我們提到了對于HashMap檢測的改進,但當時我們也在文章中提到:
代碼很簡單,總體就是獲取變量定義的地方,將泛型值傳入原先的檢測邏輯。
當然這里的增強也是有局限的,比如這個變量是成員變量,向前的推斷就會有問題,這點我們還在持續的優化中。
即:當時的檢測解決了變量聲明和變量賦值在一起的HashMap檢測問題。但對于兩者不在一起的情況,我們仍然無法檢測到。
示例代碼如下:
public static void testHashMap() {
//這種情況可以用上篇博客的檢查搞定
Map<Integer, String> map1 = new HashMap<>();
map1.put(1, "name");
//這種找不到map2的變量聲明,所以用上篇博客的檢查是無法判斷的
map2 = new HashMap<>();
map2.put(2, "name2");
}
通過我們的探索,目前已經解決了這個問題。
下面我們來詳細介紹下:
我們需要解決的情況
- 在同一個類中
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"); }
-
方法參數
public void test1(Map<Integer, String> map) { map = new HashMap<>(); map.put(1, "name"); }
-
變量聲明在另一個類中
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.map 和 sub.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.map 和 sub.map 兩個節點, resolve 利用的是 binding ,而 getType 調用的是 resolvedType (注意:這里的 resolvedType 是ECJ中的變量)。
Bindings 是ECJ一個強大的功能,有很多子類型,例如 VariableBinding 、 TypeBinding 等。
對于同一個節點可能還有多個binding(例如 QualifiedNameReference 的 otherBindings 會存放多個,上述例子中可以看到其實有 case4_1.map 中 map 類型,但在 otherBindings 中);而 resolvedType 是 TypeBinding 。顯然,使用 resolvedType 可以確保我們拿到的是類型。
這里還需要注意的是:雖然上述分析中,我們提到的這些是由ECJ提供的,且Lint中的Node也保留了拿到ECJ Node的能力,即: getNativeNode 。但并不推薦大家直接使用ECJ。
因為Lint使用 tnorbye/lombok.ast 的本意就是不依賴具體的Parser( Writing a Lint Check 中提到,他們曾經使用了多種parser),上層Detector應盡量使用Lombok AST。
解決Retrolambda下Toast檢測誤報
美團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的替換。
參考文獻
- Writing a Lint Check .
- eclipse.jdt.core .
- Abstract Syntax Tree .
- rzwitserloot/lombok.ast .
- tnorbye/lombok.ast .
- evant/android-retrolambda-lombok .
來自:http://tech.meituan.com/android_custom_lint2.html