Android自定义Lint实践
Android Lint是Google提供給Android開發者的靜態代碼檢查工具。使用Lint對Android工程代碼進行掃描和檢查,可以發現代碼潛在的問題,提醒程序員及早修正。
為保證代碼質量,美團在開發流程中加入了代碼檢查,如果代碼檢測到問題,則無法合并到正式分支中,這些檢查中就包括Lint。
我們在實際使用Lint中遇到了以下問題:
- 原生Lint無法滿足我們團隊特有的需求,例如:編碼規范。 - 原生Lint存在一些檢測缺陷或者缺少一些我們認為有必要的檢測。
基于上面的考慮,我們開始調研并開發自定義Lint。
在介紹美團的實踐之前,先用一個小例子,來看看如何進行自定義Lint。
示例介紹
開發中我們希望開發者使用RoboGuice的Ln替代Log/System.out.println。
Ln相比于Log有以下優勢:
- 對于正式發布包來說,debug和verbose的日志會自動不顯示。
- 擁有更多的有用信息,包括應用程序名字、日志的文件和行信息、時間戳、線程等。
- 由于使用了可變參數,禁用后日志的性能比Log高。因為最冗長的日志往往都是debug或verbose日志,這可以稍微提高一些性能。
- 可以覆蓋日志的寫入位置和格式。
這里我們以此為例,讓Lint檢查代碼中Log/System.out.println的調用,提醒開發者使用Ln。
創建Java工程,配置Gradle
apply plugin: 'java'dependencies {compile fileTree(dir: 'libs', include: ['*.jar'])compile 'com.android.tools.lint:lint-api:24.5.0'compile 'com.android.tools.lint:lint-checks:24.5.0' }注:
- lint-api: 官方給出的API,API并不是最終版,官方提醒隨時有可能會更改API接口。 - lint-checks:已有的檢查。
創建Detector
Detector負責掃描代碼,發現問題并報告。
/*** 避免使用Log / System.out.println ,提醒使用Ln** RoboGuice's Ln logger is similar to Log, but has the following advantages:* - Debug and verbose logging are automatically disabled for release builds.* - Your app name, file and line of the log message, time stamp, thread, and other useful information is automatically logged for you. (Some of this information is disabled for release builds to improve performance).* - Performance of disabled logging is faster than Log due to the use of the varargs. Since your most expensive logging will often be debug or verbose logging, this can lead to a minor performance win.* - You can override where the logs are written to and the format of the logging.* * https://github.com/roboguice/roboguice/wiki/Logging-via-Ln** Created by chentong on 18/9/15.*/ public class LogDetector extends Detector implements Detector.JavaScanner{public static final Issue ISSUE = Issue.create("LogUse","避免使用Log/System.out.println","使用Ln,防止在正式包打印log",Category.SECURITY, 5, Severity.ERROR,new Implementation(LogDetector.class, Scope.JAVA_FILE_SCOPE));@Overridepublic List<Class<? extends Node>> getApplicableNodeTypes() {return Collections.<Class<? extends Node>>singletonList(MethodInvocation.class);}@Overridepublic AstVisitor createJavaVisitor(final JavaContext context) {return new ForwardingAstVisitor() {@Overridepublic boolean visitMethodInvocation(MethodInvocation node) {if (node.toString().startsWith("System.out.println")) {context.report(ISSUE, node, context.getLocation(node),"請使用Ln,避免使用System.out.println");return true;}JavaParser.ResolvedNode resolve = context.resolve(node);if (resolve instanceof JavaParser.ResolvedMethod) {JavaParser.ResolvedMethod method = (JavaParser.ResolvedMethod) resolve;// 方法所在的類校驗JavaParser.ResolvedClass containingClass = method.getContainingClass();if (containingClass.matches("android.util.Log")) {context.report(ISSUE, node, context.getLocation(node),"請使用Ln,避免使用Log");return true;}}return super.visitMethodInvocation(node);}};} }可以看到這個Detector繼承Detector類,然后實現Scanner接口。
自定義Detector可以實現一個或多個Scanner接口,選擇實現哪種接口取決于你想要的掃描范圍
- Detector.XmlScanner - Detector.JavaScanner - Detector.ClassScanner - Detector.BinaryResourceScanner - Detector.ResourceFolderScanner - Detector.GradleScanner - Detector.OtherFileScanner
這里因為我們是要針對Java代碼掃描,所以選擇使用JavaScanner。
代碼中getApplicableNodeTypes方法決定了什么樣的類型能夠被檢測到。這里我們想看Log以及println的方法調用,選取MethodInvocation。對應的,我們在createJavaVisitor創建一個ForwardingAstVisitor通過visitMethodInvocation方法來接收被檢測到的Node。
可以看到getApplicableNodeTypes返回值是一個List,也就是說可以同時檢測多種類型的節點來幫助精確定位到代碼,對應的ForwardingAstVisitor接受返回值進行邏輯判斷就可以了。
可以看到JavaScanner中還有其他很多方法,getApplicableMethodNames(指定方法名)、visitMethod(接收檢測到的方法),這種對于直接找尋方法名的場景會更方便。當然這種場景我們用最基礎的方式也可以完成,只是比較繁瑣。
那么其他Scanner如何去寫呢?
可以去查看各接口中的方法去實現,一般都是有這兩種對應:什么樣的類型需要返回、接收發現的類型。
這里插一句,Lint是如何實現Java掃描分析的呢?Lint使用了Lombok做抽象語法樹的分析。所以在我們告訴它需要什么類型后,它就會把相應的Node返回給我們。
回到示例,當接收到返回的Node之后需要進行判斷,如果調用方法是System.out.println或者屬于android.util.Log類,則調用context.report上報。
context.report(ISSUE, node, context.getLocation(node), "請使用Ln,避免使用Log");第一個參數是Issue,這個之后會講到; 第二個參數是當前節點; 第三個參數location會返回當前的位置信息,便于在報告中顯示定位;
最后的字符串用來為警告添加解釋。對應報告中的位置如下圖:
這里還需要說明report會自動處理被suppress(suppressLint)/ignore(tools:ignore)的警告。所以發現問題直接調用report就可以,不用擔心其他問題。
Issue
Issue由Detector發現并報告,是Android程序代碼可能存在的bug。
public static final Issue ISSUE = Issue.create("LogUse","避免使用Log/System.out.println","使用Ln,防止在正式包打印log",Category.SECURITY, 5, Severity.ERROR,new Implementation(LogDetector.class, Scope.JAVA_FILE_SCOPE));聲明為final class,由靜態工廠方法創建。對應參數解釋如下:
- id : 唯一值,應該能簡短描述當前問題。利用Java注解或者XML屬性進行屏蔽時,使用的就是這個id。
- summary : 簡短的總結,通常5-6個字符,描述問題而不是修復措施。
- explanation : 完整的問題解釋和修復建議。
- category : 問題類別。詳見下文詳述部分。
- priority : 優先級。1-10的數字,10為最重要/最嚴重。
- severity : 嚴重級別:Fatal, Error, Warning, Informational, Ignore。
- Implementation : 為Issue和Detector提供映射關系,Detector就是當前Detector。聲明掃描檢測的范圍Scope,Scope用來描述Detector需要分析時需要考慮的文件集,包括:Resource文件或目錄、Java文件、Class文件。
與Lint HTML報告對應關系
Category詳述
系統現在已有的類別如下:
- Lint
- Correctness (incl. Messages)
- Security
- Performance
- Usability (incl. Icons, Typography)
- Accessibility
- Internationalization
- Bi-directional text
自定義Category
public class MTCategory {public static final Category NAMING_CONVENTION = Category.create("命名規范", 101); }使用
public static final Issue ISSUE = Issue.create("IntentExtraKey","intent extra key 命名不規范","請在接受此參數中的Activity中定義一個按照EXTRA_<name>格式命名的常量",MTCategory.NAMING_CONVENTION , 5, Severity.ERROR,new Implementation(IntentExtraKeyDetector.class, Scope.JAVA_FILE_SCOPE));IssueRegistry
提供需要被檢測的Issue列表
public class MTIssueRegistry extends IssueRegistry {@Overridepublic synchronized List<Issue> getIssues() {System.out.println("==== MT lint start ====");return Arrays.asList(DuplicatedActivityIntentFilterDetector.ISSUE,//IntentExtraKeyDetector.ISSUE,//FragmentArgumentsKeyDetector.ISSUE,LogDetector.ISSUE,PrivateModeDetector.ISSUE,WebViewSafeDetector.ON_RECEIVED_SSL_ERROR,WebViewSafeDetector.SET_SAVE_PASSWORD,WebViewSafeDetector.SET_ALLOW_FILE_ACCESS,WebViewSafeDetector.WEB_VIEW_USE,HashMapForJDK7Detector.ISSUE);} }在getIssues()方法中返回需要被檢測的Issue List。
在build.grade中聲明Lint-Registry屬性
jar {manifest {attributes("Lint-Registry": "com.meituan.android.lint.core.MTIssueRegistry")} }至此,自定義Lint的編碼部分就完成了。
之前提到自定義Lint是一個Java工程,那么打出的jar包如何使用呢?
jar包使用
Google方案
將jar拷貝到~/.android/lint中
$ mkdir ~/.android/lint/ $ cp customrule.jar ~/.android/lint/缺點:針對所有工程,會影響同一臺機器其他工程的Lint檢查。即便觸發工程時拷貝過去,執行完刪除,但其他進程或線程使用./gradlew lint仍可能會受到影響。
LinkedIn方案
LinkedIn提供了另一種思路 : 將jar放到一個aar中。這樣我們就可以針對工程進行自定義Lint,lint.jar只對當前工程有效。
詳細介紹請看LinkedIn博客: Writing Custom Lint Checks with Gradle。
我們對此方案進行調研,得出以下結論:
可行性
AAR Format 中寫明可以有lint.jar。
從Google Groups adt-dev論壇討論來看是官方目前的推薦方案,詳見:Specify custom lint JAR outside of lint tools settings directory
測試后發現aar中有lint.jar ,最終APK中并不會引起包體積變化。
缺點
官方plugin偶爾出bug,給人一種不太重視的感覺。
目前plugin的支持情況是:1.1.x正常,1.2.x不支持,1.3.x修復問題,1.5.x正常。
1.2.x Gradle plugin遇到的兩個問題:
- Issue 174808:custom lint in AAR doesn’t work - Issue 178699:lint.jar in AAR doesn’t work sometimes
經過對比,我們最終選擇了LinkedIn的方案。
在確定方案后,我們為Lint增加了很多功能,包括編碼規范和原生Lint增強。這里以HashMap檢測為例,介紹一下美團Lint。
增強HashMap檢測
Lint檢測中有一項是Java性能檢測,常見的就是:HashMap can be replaced with SparseArray。
public static void testHashMap() {HashMap<Integer, String> map1 = new HashMap<Integer, String>();map1.put(1, "name");HashMap<Integer, String> map2 = new HashMap<>();map2.put(1, "name");Map<Integer, String> map3 = new HashMap<>();map3.put(1, "name"); }對于上述代碼,原生Lint只能檢測第一種情況,JDK 7泛型新寫法還檢測不到。
了解到這點之后,我們決定為HashMap提供增強檢測。
分析源碼后發現,HashMap檢測是根據new HashMap處的泛型來判斷是否符合條件。 于是我們想到,在發現new HashMap后去找前面的泛型,因為本身Java就是靠類型推斷的,我們可以直接根據前面的泛型來確定是否使用SparseArray。當然,是不是HashMap還需要通過后面的new HashMap來判斷,否則容易出現問題。
代碼如下:
@Override public List<Class<? extends Node>> getApplicableNodeTypes() {return Collections.<Class<? extends Node>>singletonList(ConstructorInvocation.class); }private static final String INTEGER = "Integer"; //$NON-NLS-1$ private static final String BOOLEAN = "Boolean"; //$NON-NLS-1$ private static final String BYTE = "Byte"; //$NON-NLS-1$ private static final String LONG = "Long"; //$NON-NLS-1$ private static final String HASH_MAP = "HashMap"; //$NON-NLS-1$@Override public AstVisitor createJavaVisitor(@NonNull JavaContext context) {return new ForwardingAstVisitor() {@Overridepublic boolean visitConstructorInvocation(ConstructorInvocation node) {TypeReference reference = node.astTypeReference();String typeName = reference.astParts().last().astIdentifier().astValue();// TODO: Should we handle factory method constructions of HashMaps as well,// e.g. via Guava? This is a bit trickier since we need to infer the type// arguments from the calling context.if (typeName.equals(HASH_MAP)) {checkHashMap(context, node, reference);}return super.visitConstructorInvocation(node);}}; }/*** Checks whether the given constructor call and type reference refers* to a HashMap constructor call that is eligible for replacement by a* SparseArray call instead*/ private void checkHashMap(JavaContext context, ConstructorInvocation node, TypeReference reference) {StrictListAccessor<TypeReference, TypeReference> types = reference.getTypeArguments();if (types == null || types.size() != 2) {/*JDK 7 新寫法HashMap<Integer, String> map2 = new HashMap<>();map2.put(1, "name");Map<Integer, String> map3 = new HashMap<>();map3.put(1, "name");*/Node variableDefinition = node.getParent().getParent();if (variableDefinition instanceof VariableDefinition) {TypeReference typeReference = ((VariableDefinition) variableDefinition).astTypeReference();checkCore(context, variableDefinition, typeReference);// 此方法即原HashMap檢測邏輯}}// else --> lint本身已經檢測 }代碼很簡單,總體就是獲取變量定義的地方,將泛型值傳入原先的檢測邏輯。
當然這里的增強也是有局限的,比如這個變量是成員變量,向前的推斷就會有問題,這點我們還在持續的優化中。
總結一下實踐過程中的技巧:
- 因為沒有好的文檔,我們更多地是要從源碼的檢測中學習,多看lint-checks。
- 需要的時候使用SdkConstants,充分利用LintUtils,Lint給我們提供了很多方便的工具。
為自定義Lint開發plugin
aar雖然很方便,但是在團隊內部推廣中我們遇到了以下問題:
- 配置繁瑣,不易推廣。每個庫都需要自行配置lint.xml、lintOptions,并且compile aar。 - 不易統一。各庫之間需要使用相同的配置,保證代碼質量。但現在手動來回拷貝規則,且配置文件可以自己修改。
于是我們想到開發一個plugin,統一管理lint.xml和lintOptions,自動添加aar。
統一lint.xml
我們在plugin中內置lint.xml,執行前拷貝過去,執行完成后刪除。
lintTask.doFirst {if (lintFile.exists()) {lintOldFile = project.file("lintOld.xml")lintFile.renameTo(lintOldFile)}def isLintXmlReady = copyLintXml(project, lintFile)if (!isLintXmlReady) {if (lintOldFile != null) {lintOldFile.renameTo(lintFile)}throw new GradleException("lint.xml不存在")}}project.gradle.taskGraph.afterTask { task, TaskState state ->if (task == lintTask) {lintFile.delete()if (lintOldFile != null) {lintOldFile.renameTo(lintFile)}} }統一lintOptions
Android plugin在1.3以后允許我們替換Lint Task的lintOptions:
def newOptions = new LintOptions() newOptions.lintConfig = lintFile newOptions.warningsAsErrors = true newOptions.abortOnError = true newOptions.htmlReport = true //不放在build下,防止被clean掉 newOptions.htmlOutput = project.file("${project.projectDir}/lint-report/lint-report.html") newOptions.xmlReport = falselintTask.lintOptions = newOptions自動添加最新aar
這里還涉及一個問題:當我們plugin開發完成提供給團隊使用的時候,假設我們需要修改lint aar,那么團隊的plugin就要統一升級。這點就比較繁瑣。
考慮到plugin只是一個檢查代碼插件,它最需要的應該是實時更新。 我們引入了Gradle Dynamic Versions:
plugin開發完成,就可以提供給團隊部署了。
當然為了團隊更方便地接入檢查,我們在檢查流程中內置了腳本來自動添加plugin,這樣團隊就可以在不添加任何代碼的情況下,實現自定義Lint檢查。
- Google. Writing Custom Lint Rules. Android Tools Project Site.
- Google. Writing a Lint Check. Android Tools Project Site.
- Prengemann M. The Power of Custom Lint Checks. SpeakerDeck.
- Diermann A. Custom Lint Rules. GitHub.
- Diermann A. Android Lint API Reference Guide. GitHub.
- Google. Android Custom Lint Rules Sample Code. GitHub.
- Cheng Yang. Writing Custom Lint Checks with Gradle. LinkedIn.
總結
以上是生活随笔為你收集整理的Android自定义Lint实践的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Spring Cloud构建微服务架构:
- 下一篇: Android官方开发文档Trainin