美團外賣Android Lint程式碼檢查實踐

美團技術團隊發表於2018-04-12

概述

Lint是Google提供的Android靜態程式碼檢查工具,可以掃描並發現程式碼中潛在的問題,提醒開發人員及早修正,提高程式碼質量。除了Android原生提供的幾百個Lint規則,還可以開發自定義Lint規則以滿足實際需要。

為什麼要使用Lint

在美團外賣Android App的迭代過程中,線上問題頻繁發生。開發時很容易寫出一些問題程式碼,例如Serializable的使用:實現了Serializable介面的類,如果其成員變數引用的物件沒有實現Serializable介面,序列化時就會Crash。我們對一些常見問題的原因和解決方法做分析總結,並在開發人員組內或跟測試人員一起分享交流,幫助相關人員主動避免這些問題。

為了進一步減少問題發生,我們逐步完善了一些規範,包括制定程式碼規範,加強程式碼Review,完善測試流程等。但這些措施仍然存在各種不足,包括程式碼規範難以實施,溝通成本高,特別是開發人員變動頻繁導致反覆溝通等,因此其效果有限,相似問題仍然不時發生。另一方面,越來越多的總結、規範文件,對於組內新人也產生了不小的學習壓力。

有沒有辦法從技術角度減少或減輕上述問題呢?

我們調研發現,靜態程式碼檢查是一個很好的思路。靜態程式碼檢查框架有很多種,例如FindBugs、PMD、Coverity,主要用於檢查Java原始檔或class檔案;再例如Checkstyle,主要關注程式碼風格;但我們最終選擇從Lint框架入手,因為它有諸多優勢:

  1. 功能強大,Lint支援Java原始檔、class檔案、資原始檔、Gradle等檔案的檢查。
  2. 擴充套件性強,支援開發自定義Lint規則。
  3. 配套工具完善,Android Studio、Android Gradle外掛原生支援Lint工具。
  4. Lint專為Android設計,原生提供了幾百個實用的Android相關檢查規則。
  5. 有Google官方的支援,會和Android開發工具一起升級完善。

在對Lint進行了充分的技術調研後,我們根據實際遇到的問題,又做了一些更深入的思考,包括應該用Lint解決哪些問題,怎麼樣更好的推廣實施等,逐步形成了一套較為全面有效的方案。

Lint API簡介

為了方便後文的理解,我們先簡單看一下Lint提供的主要API。

主要API

Lint規則通過呼叫Lint API實現,其中最主要的幾個API如下。

  1. Issue:表示一個Lint規則。

  2. Detector:用於檢測並報告程式碼中的Issue,每個Issue都要指定Detector。

  3. Scope:宣告Detector要掃描的程式碼範圍,例如JAVA_FILE_SCOPECLASS_FILE_SCOPERESOURCE_FILE_SCOPEGRADLE_SCOPE等,一個Issue可包含一到多個Scope。

  4. Scanner:用於掃描並發現程式碼中的Issue,每個Detector可以實現一到多個Scanner。

  5. IssueRegistry:Lint規則載入的入口,提供要檢查的Issue列表。

舉例來說,原生的ShowToast就是一個Issue,該規則檢查呼叫Toast.makeText()方法後是否漏掉了Toast.show()的呼叫。其Detector為ToastDetector,要檢查的Scope為JAVA_FILE_SCOPE,ToastDetector實現了JavaPsiScanner,示意程式碼如下。

public class ToastDetector extends Detector implements JavaPsiScanner {
    public static final Issue ISSUE = Issue.create(
            "ShowToast",
            "Toast created but not shown",
            "...",
            Category.CORRECTNESS,
            6,
            Severity.WARNING,
            new Implementation(
                    ToastDetector.class,
                    Scope.JAVA_FILE_SCOPE));
    // ...
}
複製程式碼

IssueRegistry的示意程式碼如下。

public class MyIssueRegistry extends IssueRegistry {

    @Override
    public List<Issue> getIssues() {
        return Arrays.asList(
                ToastDetector.ISSUE,
                LogDetector.ISSUE,
                // ...
        );
    }
}
複製程式碼

Scanner

Lint開發過程中最主要的工作就是實現Scanner。Lint中包括多種型別的Scanner如下,其中最常用的是掃描Java原始檔和XML檔案的Scanner。

  • JavaScanner / JavaPsiScanner / UastScanner:掃描Java原始檔
  • XmlScanner:掃描XML檔案
  • ClassScanner:掃描class檔案
  • BinaryResourceScanner:掃描二進位制資原始檔
  • ResourceFolderScanner:掃描資原始檔夾
  • GradleScanner:掃描Gradle指令碼
  • OtherFileScanner:掃描其他型別檔案

值得注意的是,掃描Java原始檔的Scanner先後經歷了三個版本。

  1. 最開始使用的是JavaScanner,Lint通過Lombok庫將Java原始碼解析成AST(抽象語法樹),然後由JavaScanner掃描。

  2. 在Android Studio 2.2和lint-api 25.2.0版本中,Lint工具將Lombok AST替換為PSI,同時棄用JavaScanner,推薦使用JavaPsiScanner。

    PSI是JetBrains在IDEA中解析Java原始碼生成語法樹後提供的API。相比之前的Lombok AST,PSI可以支援Java 1.8、型別解析等。使用JavaPsiScanner實現的自定義Lint規則,可以被載入到Android Studio 2.2+版本中,在編寫Android程式碼時實時執行。

  3. 在Android Studio 3.0和lint-api 25.4.0版本中,Lint工具將PSI替換為UAST,同時推薦使用新的UastScanner。

    UAST是JetBrains在IDEA新版本中用於替換PSI的API。UAST更加語言無關,除了支援Java,還可以支援Kotlin。

本文目前仍然基於PsiJavaScanner做介紹。根據UastScanner原始碼中的註釋,可以很容易的從PsiJavaScanner遷移到UastScanner。

Lint規則

我們需要用Lint檢查程式碼中的哪些問題呢?

開發過程中,我們比較關注App的Crash、Bug率等指標。通過長期的整理總結髮現,有不少發生頻率很高的程式碼問題,其原理和解決方案都很明確,但是在寫程式碼時卻很容易遺漏且難以發現;而Lint恰好很容易檢查出這些問題。

Crash預防

Crash率是App最重要的指標之一,避免Crash也一直是開發過程中比較頭疼的一個問題,Lint可以很好的檢查出一些潛在的Crash。例如:

  • 原生的NewApi,用於檢查程式碼中是否呼叫了Android高版本才提供的API。在低版本裝置中呼叫高版本API會導致Crash。

  • 自定義的SerializableCheck。實現了Serializable介面的類,如果其成員變數引用的物件沒有實現Serializable介面,序列化時就會Crash。我們制定了一條程式碼規範,要求實現了Serializable介面的類,其成員變數(包括從父類繼承的)所宣告的型別都要實現Serializable介面。

  • 自定義的ParseColorCheck。呼叫Color.parseColor()方法解析後臺下發的顏色時,顏色字串格式不正確會導致IllegalArgumentException,我們要求呼叫這個方法時必須處理該異常。

Bug預防

有些Bug可以通過Lint檢查來預防。例如:

  • SpUsage:要求所有SharedPrefrence讀寫操作使用基礎工具類,工具類中會做各種異常處理;同時定義SPConstants常量類,所有SP的Key都要在這個類定義,避免在程式碼中分散定義的Key之間衝突。

  • ImageViewUsage:檢查ImageView有沒有設定ScaleType,載入時有沒有設定Placeholder。

  • TodoCheck:檢查程式碼中是否還有TODO沒完成。例如開發時可能會在程式碼中寫一些假資料,但最終上線時要確保刪除這些程式碼。這種檢查項比較特殊,通常在開發完成後提測階段才檢查。

效能/安全問題

一些效能、安全相關問題可以使用Lint分析。例如:

  • ThreadConstruction:禁止直接使用new Thread()建立執行緒(執行緒池除外),而需要使用統一的工具類在公用執行緒池執行後臺操作。

  • LogUsage:禁止直接使用android.util.Log,必須使用統一工具類。工具類中可以控制Release包不輸出Log,提高效能,也避免發生安全問題。

程式碼規範

除了程式碼風格方面的約束,程式碼規範更多的是用於減少或防止發生Bug、Crash、效能、安全等問題。很多問題在技術上難以直接檢查,我們通過封裝統一的基礎庫、制定程式碼規範的方式間接解決,而Lint檢查則用於減少組內溝通成本、新人學習成本,並確保程式碼規範的落實。例如:

  • 前面提到的SpUsage、ThreadConstruction、LogUsage等。

  • ResourceNaming:資原始檔命名規範,防止不同模組之間的資原始檔名衝突。

程式碼檢查的實施

當檢查出程式碼問題時,如何提醒開發者及時修正呢?

早期我們將靜態程式碼檢查配置在Jenkins上,打包釋出AAR/APK時,檢查程式碼中的問題並生成報告。後來發現雖然靜態程式碼檢查能找出來不少問題,但是很少有人主動去看報告,特別是報告中還有過多無關緊要的、優先順序很低的問題(例如過於嚴格的程式碼風格約束)。

因此,一方面要確定檢查哪些問題,另一方面,何時、通過什麼樣的技術手段來執行程式碼檢查也很重要。我們結合技術實現,對此做了更多思考,確定了靜態程式碼檢查實施過程中的主要目標:

  1. 重點關注高優先順序問題,遮蔽低優先順序問題。正如前面所說,如果程式碼檢查報告中夾雜了大量無關緊要的問題,反而影響了關鍵問題的發現。

  2. 高優問題的解決,要有一定的強制性。當檢查發現高優先順序的程式碼問題時,給開發者明確直接的報錯,並通過技術手段約束,強制要求開發者修復。

  3. 某些問題儘可能做到在第一時間發現,從而減少風險或損失。有些問題發現的越早越好,例如業務功能開發中使用了Android高版本API,通過Lint原生的NewApi可以檢查出來。如果在開發期間發現,當時就可以考慮其他技術方案,實現困難時可以及時和產品、設計人員溝通;而如果到提程式碼、提測,甚至發版、上線時才發現,可能為時已晚。

優先順序定義

每個Lint規則都可以配置Sevirity(優先順序),包括Fatal、Error、Warning、Information等,我們主要使用Error和Warning,如下。

  • Error級別:明確需要解決的問題,包括Crash、明確的Bug、嚴重效能問題、不符合程式碼規範等,必須修復。
  • Warning級別:包括程式碼編寫建議、可能存在的Bug、一些效能優化等,適當放鬆要求。

執行時機

Lint檢查可以在多個階段執行,包括在本地手動檢查、編碼實時檢查、編譯時檢查、commit檢查,以及在CI系統中提Pull Request時檢查、打包發版時檢查等,下面分別介紹。

手動執行

在Android Studio中,自定義Lint可以通過Inspections功能(Analyze - Inspect Code)手動執行。

在Gradle命令列環境下,可直接用./gradlew lint執行Lint檢查。

手動執行簡單易用,但缺乏強制性,容易被開發者遺漏。

編碼階段實時檢查

編碼時檢查即在Android Studio中寫程式碼時在程式碼視窗實時報錯。其好處很明顯,開發者可以第一時間發現程式碼問題。但受限於Android Studio對自定義Lint的支援不完善,開發人員IDE的配置不同,需要開發者主動關注報錯並修復,這種方式不能完全保證效果。

IDEA提供了Inspections功能和相應的API來實現程式碼檢查,Android原生Lint就是通過Inspections整合到了Android Studio中。對於自定義Lint規則,官方似乎沒有給出明確說明,但實際研究發現,在Android Studio 2.2+版本和基於JavaPsiScanner開發的條件下(或Android Studio 3.0+和JavaPsiScanner/UastScanner),IDE會嘗試載入並實時執行自定義Lint規則。

技術細節:

  1. 在Android Studio 2.x版本中,選單Preferences - Editor - Inspections - Android - Lint - Correctness - Error from Custom Lint Check(avaliable for Analyze|Inspect Code)中指出,自定義Lint只支援命令列或手動執行,不支援實時檢查。

    Error from Custom Rule When custom (third-party) lint rules are integrated in the IDE, they are not available as native IDE inspections, so the explanation text (which must be statically registered by a plugin) is not available. As a workaround, run the lint target in Gradle instead; the HTML report will include full explanations.

  2. 在Android Studio 3.x版本中,開啟Android工程原始碼後,IDE會載入工程中的自定義Lint規則,在設定選單的Inspections列表裡可以檢視,和原生Lint效果相同(Android Studio會在開啟原始檔時觸發對該檔案的程式碼檢查)。

  3. 分析自定義Lint的IssueRegistry.getIssues()方法呼叫堆疊,可以看到Android Studio環境下,是由org.jetbrains.android.inspections.lint.AndroidLintExternalAnnotator呼叫LintDriver載入執行自定義Lint規則。

    參考程式碼: https://github.com/JetBrains/android/tree/master/android/src/org/jetbrains/android/inspections/lint

在Android Studio中的實際效果如圖:

美團外賣Android Lint程式碼檢查實踐

本地編譯時自動檢查

配置Gradle指令碼可實現編譯Android工程時執行Lint檢查。好處是既可以儘早發現問題,又可以有強制性;缺點是對編譯速度有一定的影響。

編譯Android工程執行的是assemble任務,讓assemble依賴lint任務,即可在編譯時執行Lint檢查;同時配置LintOptions,發現Error級別問題時中斷編譯。

在Android Application工程(APK)中配置如下,Android Library工程(AAR)把applicationVariants換成libraryVariants即可。

android.applicationVariants.all { variant ->
    variant.outputs.each { output ->
        def lintTask = tasks["lint${variant.name.capitalize()}"]
        output.assemble.dependsOn lintTask
    }
}
複製程式碼

LintOptions的配置:

android.lintOptions {
	abortOnError true
}
複製程式碼

本地commit時檢查

利用git pre-commit hook,可以在本地commit程式碼前執行Lint檢查,檢查不通過則無法提交程式碼。這種方式的優勢在於不影響開發時的編譯速度,但發現問題相對滯後。

技術實現方面,可以編寫Gradle指令碼,在每次同步工程時自動將hook指令碼從工程拷貝到.git/hooks/資料夾下。

提程式碼時CI檢查

作為程式碼提交流程規範的一部分,發Pull Request提程式碼時用CI系統檢查Lint問題是一個常見、可行、有效的思路。可配置CI檢查通過後程式碼才能被合併。

CI系統常用Jenkins,如果使用Stash做程式碼管理,可以在Stash上配置Pull Request Notifier for Stash外掛,或在Jenkins上配置Stash Pull Request Builder外掛,實現發Pull Request時觸發Jenkins執行Lint檢查的Job。

在本地編譯和CI系統中做程式碼檢查,都可以通過執行Gradle的Lint任務實現。可以在CI環境下給Gradle傳遞一個StartParameter,Gradle指令碼中如果讀取到這個引數,則配置LintOptions檢查所有Lint問題;否則在本地編譯環境下只檢查部分高優先順序Lint問題,減少對本地編譯速度的影響。

Lint生成報告的效果如圖所示:

美團外賣Android Lint程式碼檢查實踐

美團外賣Android Lint程式碼檢查實踐

打包釋出時檢查

即使每次提程式碼時用CI系統執行Lint檢查,仍然不能保證所有人的程式碼合併後一定沒有問題;另外對於一些特殊的Lint規則,例如前面提到的TodoCheck,還希望在更晚的時候檢查。

於是在CI系統打包釋出APK/AAR用於測試或發版時,還需要對所有程式碼再做一次Lint檢查。

最終確定的檢查時機

綜合考慮多種檢查方式的優缺點以及我們的目標,最終確定結合以下幾種方式做程式碼檢查:

  1. 編碼階段IDE實時檢查,第一時間發現問題。
  2. 本地編譯時,及時檢查高優先順序問題,檢查通過才能編譯。
  3. 提程式碼時,CI檢查所有問題,檢查通過才能合程式碼。
  4. 打包階段,完整檢查工程,確保萬無一失。

配置檔案支援

為了方便程式碼管理,我們給自定義Lint建立了一個獨立的工程,該工程打包生成一個AAR釋出到Maven倉庫,而被檢查的Android工程依賴這個AAR(具體開發過程可以參考文章末尾連結)。

自定義Lint雖然在獨立工程中,但和被檢查的Android工程中的程式碼規範、基礎元件等存在較多耦合。

例如我們使用正規表示式檢查Android工程的資原始檔命名規範,每次業務邏輯變動要新增資原始檔字首時,都要修改Lint工程,釋出新的AAR,再更新到Android工程中,非常繁瑣。另一方面,我們的Lint工程除了在外賣C端Android工程中使用,也希望能直接用在其他端的其他Android工程中,而不同工程之間存在差異。

於是我們嘗試使用配置檔案來解決這一問題。以檢查Log使用的LogUsage為例,不同工程封裝了不同的Log工具類,報錯時提示資訊也應該不一樣。定義配置檔名為custom-lint-config.json,放在被檢查Android工程的模組目錄下。在Android工程A中的配置檔案是:

{
	"log-usage-message": "請勿使用android.util.Log,建議使用LogUtils工具類"
}
複製程式碼

而Android工程B的配置檔案是:

{
	"log-usage-message": "請勿使用android.util.Log,建議使用Logger工具類"
}
複製程式碼

從Lint的Context物件可獲取被檢查工程目錄從而讀取配置檔案,關鍵程式碼如下:

import com.android.tools.lint.detector.api.Context;

public final class LintConfig {

    private LintConfig(Context context) {
        File projectDir = context.getProject().getDir();
        File configFile = new File(projectDir, "custom-lint-config.json");
        if (configFile.exists() && configFile.isFile()) {
            // 讀取配置檔案...
        }
    }
}
複製程式碼

配置檔案的讀取,可以在Detector的beforeCheckProject、beforeCheckLibraryProject回撥方法中進行。LogUsage中檢查到錯誤時,根據配置檔案定義的資訊報錯。

public class LogUsageDetector extends Detector implements Detector.JavaPsiScanner {
	// ...

	private LintConfig mLintConfig;

    @Override
    public void beforeCheckProject(@NonNull Context context) {
        // 讀取配置
        mLintConfig = new LintConfig(context);
    }

    @Override
    public void beforeCheckLibraryProject(@NonNull Context context) {
        // 讀取配置
        mLintConfig = new LintConfig(context);
    }

    @Override
    public List<String> getApplicableMethodNames() {
        return Arrays.asList("v", "d", "i", "w", "e", "wtf");
    }

    @Override
    public void visitMethod(JavaContext context, JavaElementVisitor visitor, PsiMethodCallExpression call, PsiMethod method) {
        if (context.getEvaluator().isMemberInClass(method, "android.util.Log")) {
        	// 從配置檔案獲取Message
        	String msg = mLintConfig.getConfig("log-usage-message");
            context.report(ISSUE, call, context.getLocation(call.getMethodExpression()), msg);
        }
    }
}
複製程式碼

模板Lint規則

Lint規則開發過程中,我們發現了一系列相似的需求:封裝了基礎工具類,希望大家都用起來;某個方法很容易丟擲RuntimeException,有必要做處理,但Java語法上RuntimeException並不強制要求處理從而經常遺漏……

這些相似的需求,每次在Lint工程中開發同樣會很繁瑣。我們嘗試實現了幾個模板,可以直接在Android工程中通過配置檔案配置Lint規則。

如下為一個配置檔案示例:

{
  "lint-rules": {
    "deprecated-api": [{
      "method-regex": "android\\.content\\.Intent\\.get(IntExtra|StringExtra|BooleanExtra|LongExtra|LongArrayExtra|StringArrayListExtra|SerializableExtra|ParcelableArrayListExtra).*",
      "message": "避免直接呼叫Intent.getXx()方法,特殊機型可能發生Crash,建議使用IntentUtils",
      "severity": "error"
    },
    {
      "field": "java.lang.System.out",
      "message": "請勿直接使用System.out,應該使用LogUtils",
      "severity": "error"
    },
    {
      "construction": "java.lang.Thread",
      "message": "避免單獨建立Thread執行後臺任務,存在效能問題,建議使用AsyncTask",
      "severity": "warning"
    },
    {
      "super-class": "android.widget.BaseAdapter",
      "message": "避免直接使用BaseAdapter,應該使用統一封裝的BaseListAdapter",
      "severity": "warning"
    }],
    "handle-exception": [{
      "method": "android.graphics.Color.parseColor",
      "exception": "java.lang.IllegalArgumentException",
      "message": "Color.parseColor需要加try-catch處理IllegalArgumentException異常",
      "severity": "error"
    }]
  }
}
複製程式碼

示例配置中定義了兩種型別的模板規則:

  • DeprecatedApi:禁止直接呼叫指定API
  • HandleException:呼叫指定API時,需要加try-catch處理指定型別的異常

問題API的匹配,包括方法呼叫(method)、成員變數引用(field)、建構函式(construction)、繼承(super-class)等型別;匹配字串支援glob語法或正規表示式(和lint.xml中ignore的配置語法一致)。

實現方面,主要是遍歷Java語法樹中特定型別的節點並轉換成完整字串(例如方法呼叫android.content.Intent.getIntExtra),然後檢查是否有模板規則與其匹配。匹配成功後,DeprecatedApi規則直接輸出message報錯;HandleException規則會檢查匹配到的節點是否處理了特定Exception(或Exception的父類),沒有處理則報錯。

按Git版本檢查新增檔案

隨著Lint新規則的不斷開發,我們又遇到了一個問題。Android工程中存在大量歷史程式碼,不符合新增Lint規則的要求,但也沒有導致明顯問題,這時接入新增Lint規則要求修改所有歷史程式碼,成本較高而且有一定風險。例如新增程式碼規範,要求使用統一的執行緒工具類而不允許直接用Handler以避免記憶體洩露等。

我們嘗試了一個折中的方案:只檢查指定git commit之後新增的檔案。在配置檔案中新增配置項,給Lint規則配置git-base屬性,其值為commit ID,只檢查此次commit之後新增的檔案。

實現方面,執行git rev-parse --show-toplevel命令獲取git工程根目錄的路徑;執行git ls-tree --full-tree --full-name --name-only -r <commit-id>命令獲取指定commit時已有檔案列表(相對git根目錄的路徑)。在Scanner回撥方法中通過Context.getLocation(node).getFile()獲取節點所在檔案,結合git檔案列表判斷是否需要檢查這個節點。需要注意的是,程式碼量較大時要考慮Lint檢查對電腦的效能消耗。

美團外賣Android Lint程式碼檢查實踐

總結

經過一段時間的實踐發現,Lint靜態程式碼檢查在解決特定問題時的效果非常好,例如發現一些語言或API層面比較明確的低階錯誤、幫助進行程式碼規範的約束。使用Lint前,不少這類問題恰好對開發人員來說又很容易遺漏(例如原生的NewApi檢查、自定義的SerializableCheck);相同問題反覆出現;程式碼規範的執行,特別是有新人蔘與開發時,需要很高的學習和溝通成本,還經常出現新人提交程式碼時由於沒有遵守程式碼規範反覆被要求修改。而使用Lint後,這些問題都能在第一時間得到解決,節省了大量的人力,提高了程式碼質量和開發效率,也提高了App的使用體驗。

參考資料與擴充套件閱讀

參考資料:

Lint和Gradle相關技術細節還可以閱讀我的個人部落格:

作者簡介

子健,Android高階工程師,2015年畢業於西安電子科技大學並校招加入美團外賣。前期先後負責過外賣App首頁、商家容器、評價等核心業務模組的開發維護,目前重點負責參與外賣打包自動化、程式碼檢查、平臺化等技術工作。

招聘

對我們團隊感興趣,可以關注我們的專欄。美團外賣App團隊誠招Android/iOS高階工程師/技術專家,工作地北京/上海可選,歡迎有興趣的同學投遞簡歷到wukai05#meituan.com。

美團外賣Android Lint程式碼檢查實踐

相關文章