如何讓你的lint檢查更加高效?

前言

先來講說爲何會有這樣一個題目吧。最近這大半年都在作項目crash收斂的事情,說到crash收斂,最簡單的應該是Java相關的Crash了。在作的過程當中就發現,其實不少Java Crash的產生都是開發同窗犯的低級錯誤,好比數組越界、parseInt的裸調等等。那有沒有一種方式能夠避免開發同窗犯這樣的錯誤呢?後來就嘗試接入靜態代碼掃描。公司級的靜態代碼掃描有CodeDog和CodeCC,當時CodeCC不支持kotlin,就選擇了CodeDog,而CodeDog上的規則能夠避免一部分問題,但不少項目相關的問題規避須要自定義規則才能解決,而CodeDog在自定義規則上的支持並非特別友好。後來就開始調研如何本身作自定義規則,支持Kotlin的靜態代碼掃描工具主要有如下幾種:html

  1. Ktlint:只支持代碼風格檢查,若是要支持代碼性能檢查的話,須要大量擴展代碼性能規則集。
  2. Detekt:支持代碼風格檢查和代碼性能檢查,代碼風格檢查徹底複用Ktlint,代碼性能檢查規則集也比較完善,且支持規則集擴展。
  3. Lint:這個是Google官方提供的靜態代碼掃描工具。支持Kotlin和Java等多種語言,支持擴展規則集。

由於咱們的項目實際上是使用了Kotlin和Java混合開發,項目中有至關一部分使用Java開發的代碼,而lint能同時支持Java和Kotlin,因此最後咱們選擇了lint。 在整個自定義lint規則的實踐過程當中,咱們發現lint掃描的效率很是低,好比在項目中進行一次lint全量掃描,平均須要5分鐘左右,並且這是在僅掃描自定義規則的狀況下。
咱們將lint掃描集成到了編譯流水線中,全部的MR操做都會觸發掃描,並block住MR的流程。常常會發現這樣一種狀況,某個MR僅僅修改了一行代碼,卻仍要掃瞄整個項目,這會嚴重影響MR的效率。因此,大部分狀況下並不須要進行lint的全量掃描,咱們更關心的是新增代碼是否存在問題。因而,咱們須要探索一種lint增量掃描的解決方案。java

目標

經過查閱相關資料,發現Google官方並無提供lint增量掃描能力,網上也沒有相關的解決方案。因而只能本身動手,畢竟每次提交MR後要等好久的lint檢查,實在不是一個很好的體驗。咱們的目標主要有如下兩點:android

  1. 報告增量問題
  2. 增量掃描文件
  3. 能方便的接入持續集成

思路演變

1.baseline

Google雖然沒有提供lint增量掃描的能力,可是在lint2.3.0版本之後,提供了一個baseline的功能。一開始我覺得這個就是增量掃描,但後瞭解後才發現,baseline本質上也是全量掃描,只不過baseline容許你建立一個基準問題集,以後全部的掃描結果集合會與基準問題集作對比,篩選出增量問題寫入報告。所以,baseline的方案本質是沒法提高掃描效率的。git

2.現有的lint使用方式

目前來講,使用lint有如下幾種方式:github

  • Android Studio裏的lint掃描
  • AndroidGradlePlugin裏的lint任務
  • lint命令行工具

下面是幾種使用方式的對比:api

功能 Android Studio AndroidGradlePlugin lint命令行工具
增量問題報告 Yes Yes(2.3.0之後) No
增量掃描 Yes No No
接入持續集成 No Yes Yes

Android Studio的方式能支持增量問題報告和增量掃描,可是沒法應用到流水線中,且沒法強制開發同窗人人去執行;AndroidGradlePlugin和命令行的方式,都能方便地繼承到流水線中,可是它們都沒法實現增量掃描,效率十分低下。所以,並無一種方式能夠完美契合咱們的目標。既然如此,咱們能夠以現有工具爲基礎,開發一款能增量掃描和展現問題,又能方便接入流水線的工具。數組

3.新的解決方案

經過上面對三種現有lint使用方式的對比,發現AndroidGradlePlugin基於Gradle,是最易擴展到一種,所以,咱們決定在AndroidGradlePugin的基礎上進行擴展,開發一款完美的lint工具。
其實增量掃描的解決思路很是簡單: android-studio

  1. 既然是基於Gradle,天然是經過自定義插件和自定義Task的方式;
  2. Task內首先須要找到增量代碼,須要支持版本號之間的對比和分支之間的對比,MR就須要分支之間的對比;
  3. 最後對這些增量代碼進行lint檢查。

方案實現

下面來看下每一步如何實現。bash

1.尋找增量代碼

目前大多數項目都採用git進行版本控制,因此尋找增量代碼,能夠簡化爲尋找兩次git提交之間的版本差別。考慮到lint檢查的最小單位是單個文件,因此咱們找到增量代碼文件集合便可,而git diff命令恰好可以知足咱們的要求。app

// 計算兩次commit之間的差別文件,diff-filter=d是指除刪除意外全部狀態的文件
git diff --name-only --diff-filter=d <commit-1> <commit-2>

// 計算兩個分支之間的差別文件,適用於MR的增量掃描
git diff --name-only --diff-filter=d <branch-1> <branch-2>
複製代碼

封裝爲工具方法以下:

// 計算兩次git提交之間的差別文件
static List<String> diffFileListFromTwoCommit(String revision, String baseline, String filter) {
    return filterInvalidLine(runCmd("git diff --name-only --diff-filter=$filter $revision $baseline").split('\n'))
}

// 計算兩個git分支之間的差別文件
static List<String> diffFileListFromTwoBranch(String revisionBranch, String baselineBranch, String filter) {
    return filterInvalidLine(runCmd("git diff --name-only --diff-filter=$filter $revisionBranch $baselineBranch").split('\n'))
}

// 執行命令
static String runCmd(String cmd) {
    return Runtime.getRuntime().exec(self).text.trim().replaceAll("\"", "")
}
複製代碼

2.對增量文件進行lint檢查

想要對增量文件進行lint檢查,首先須要弄清楚android的gradle插件自帶的lint任務是如何進行代碼掃描的。經過查閱源碼,能夠看到lint任務的執行流程以下:

其中LintDriver.runFileDetectors()的源碼以下:

private fun runFileDetectors(project: Project, main: Project?) {
    ...
    if (scope.contains(Scope.JAVA_FILE) || scope.contains(Scope.ALL_JAVA_FILES)) {
        val checks = union(
            scopeDetectors[Scope.JAVA_FILE],
            scopeDetectors[Scope.ALL_JAVA_FILES]
        )
        if (checks != null && !checks.isEmpty()) {
            val files = project.subset
            if (files != null) {
                checkIndividualJavaFiles(project, main, checks, files)
            } else {
                val sourceFolders = project.javaSourceFolders
                val testFolders = if (scope.contains(Scope.TEST_SOURCES))
                    project.testSourceFolders
                else emptyList<File>()

                val generatedFolders = if (checkGeneratedSources)
                    project.generatedSourceFolders
                else emptyList<File>()
                checkJava(project, main, sourceFolders, testFolders, generatedFolders, checks)
            }
        }
    }
    ...
}
複製代碼

其中:

  • checkIndividualJavaFiles - 檢查項目文件子集
  • checkJava - 檢查整個項目

因此從這裏能夠看出,增量掃描是能夠實現的,只要project.subset不爲空!那這個subset是哪裏賦值的呢?這裏讓咱們來看下Project的源碼:

/** * The list of files to be checked in this project. If null, the whole project should be * checked. * * @return the subset of files to be checked, or null for the whole project */
@Nullable
public List<File> getSubset() {
    return files;
}

/** * Adds the given file to the list of files which should be checked in this project. If no files * are added, the whole project will be checked. * * @param file the file to be checked */
public void addFile(@NonNull File file) {
    if (files == null) {
        files = new ArrayList<>();
    }
    files.add(file);
}
複製代碼

因此,若是在初始化Project的時候經過addFile方法添加過文件子集,咱們就能夠進行代碼增量掃描了。然而,咱們發現addFile這個方法,居然只在單元測試代碼中調用過!因此這個能力google並無開放出來。那咱們須要本身想辦法,在合適的時機將咱們經過git diff計算出來的增量文件路徑,經過Project.addFile方法添加到Project.subset中,就能夠完成增量掃描的任務了。那什麼時機最合適呢?先看看Project的建立時機,在LintGradleClient的createLintRequest方法中:

@Override
@NonNull
protected LintRequest createLintRequest(@NonNull List<File> files) {
    LintRequest lintRequest = new LintRequest(this, files);
    LintGradleProject.ProjectSearch search = new LintGradleProject.ProjectSearch();
    Project project =
            search.getProject(this, gradleProject, variant != null ? variant.getName() : null);
    lintRequest.setProjects(Collections.singletonList(project));

    registerProject(project.getDir(), project);
    for (Project dependency : project.getAllLibraries()) {
        registerProject(dependency.getDir(), dependency);
    }

    return lintRequest;
}
複製代碼

這是一個protected方法,因此咱們是否是能夠繼承LintGradleClient,重寫createLintReqeust方法來完成增量文件的寫入呢?如今思路清晰多了,因而咱們寫了一個自定義的LintGradleClient:

public class LintGradleClient extends com.android.tools.lint.gradle.LintGradleClient {

    public List<File> incrementFiles = null;

    @Override
    protected LintRequest createLintRequest(List<File> files) {
        LintRequest request =  super.createLintRequest(files);
        if (request != null && incrementFiles != null) {
            for (Project project: request.getProjects()) {
                for (File file: incrementFiles) {
                    project.addFile(file);
                }
            }
        }
        return request;
    }
}
複製代碼

如何將LintGradleClient替換爲咱們自定義的類呢?繼續看源碼,發現LintGradleClient的實例化發生在LintGradleExecution的analyze()->runLint()過程當中,可是這個過程並無很好的時機去替換LintGradleClient的實例化,怎麼辦?那繼續看LintGradleExecution的建立時機,在ReflectiveLintRunner().runLint()方法中,源碼以下:

fun runLint(gradle: Gradle, request: LintExecutionRequest, lintClassPath: Set<File>) {
    try {
        val loader = getLintClassLoader(gradle, lintClassPath)
        val cls = loader.loadClass("com.android.tools.lint.gradle.LintGradleExecution")
        val constructor = cls.getConstructor(LintExecutionRequest::class.java)
        val driver = constructor.newInstance(request)
        val analyzeMethod = driver.javaClass.getDeclaredMethod("analyze")
        analyzeMethod.invoke(driver)
    } catch (e: InvocationTargetException) {
        ...
    } catch (t: Throwable) {
        ...
    }
}

private fun getLintClassLoader(gradle: Gradle, lintClassPath: Set<File>): ClassLoader {
    if (loader == null) {
        ...
        val urls = computeUrlsFromClassLoaderDelta(lintClassPath)
                    ?: computeUrlsFallback(lintClassPath)
        loader = DelegatingClassLoader(urls.toTypedArray())
    }
    return loader
}
複製代碼

看到這段代碼的時候,我立馬眼前一亮,以爲這事妥了!這裏作了一件什麼事情呢:經過DelegateClassLoader去加載com.android.tools.lint.gradle.LintGradleExecution這個類,而後經過反射的方式來實例化LintExecution對象,傳入一個LintExecutionRequest參數,並執行analyze方法。
這裏假如咱們自定義一個LintGradleExecution類,並在這個類中使用咱們以前自定義的LintGradleClient實例替代官方的實例,就能夠達到狸貓換太子的效果,完成增量掃描了。而LintGradleExecution這個類的實例化是經過ClassLoader動態加載完成的,這意味着,咱們能夠hook這個ClassLoader加載類的過程,讓其加載咱們自定義的LintGradleExecution類。
這裏使用的DelegatingClassLoader其實是一個URLClassLoader,而URLClassLoader尋找類的原理,是在一個URL列表中按順序尋找目標類,找到即止。所以,咱們能夠將含有自定義類LintGradleExecution的url插入到url列表的最前面,這樣在執行loader.loadClass("com.android.tools.lint.gradle.LintGradleExecution")時,加載到的class就是咱們自定義的類了。
那如何插入自定義的url?咱們能夠看下DelegatingClassLoader的url列表是如何計算的:

private fun computeUrlsFallback(lintClassPath: Set<File>): List<URL> {
    val urls = mutableListOf<URL>()

    for (file in lintClassPath) {
        val name = file.name

        // The set of jars that lint needs that *aren't* already used/loaded by gradle-core
        if (name.startsWith("uast-") ||
            name.startsWith("intellij-core-") ||
            name.startsWith("kotlin-compiler-") ||
            name.startsWith("asm-") ||
            name.startsWith("kxml2-") ||
            name.startsWith("trove4j-") ||
            name.startsWith("groovy-all-") ||

            // All the lint jars, except lint-gradle-api jar (self)
            name.startsWith("lint-") &&
            // Do *not* load this class in a new class loader; we need to
            // share the same class as the one already loaded by the Gradle
            // plugin
            !name.startsWith("lint-gradle-api-")
        ) {
            urls.add(file.toURI().toURL())
        }
    }

    return urls
}
複製代碼

說白了,就是lintClassPath這個參數裏全部的file轉成List,而且file命名要符合"lint-"開頭的規範。那lintClassPath是怎麼來的?繼續看源碼:

lintTask.lintClassPath = globalScope.getProject().getConfigurations().getByName("lintClassPath");
複製代碼

原來是取的一個名爲"lintClassPath"的配置項下全部的依賴的集合,而"lintClassPath"配置項是在AndroidGradlePlugin配置階段配置的,以下:

project.getDependencies().add("lintClassPath", "com.android.tools.lint:lint-gradle:" +
                Version.ANDROID_TOOLS_BASE_VERSION);
複製代碼

所以,咱們能夠在整個gradle配置完成後,刪除以上配置,新增咱們自定義的配置:

class LintPlugin implements Plugin<Project> {
    @Override
    void apply(Project project) {
        ...
        project.getDependencies().add("lintClassPath", "com.tencent.nijigen:lint-nice-gradle:0.0.1")
        ...
    }
}
複製代碼

這樣,DelegatingClassLoader在loadClass的時候,就會加載到咱們自定義的LintGradleExecution類,從而實例化自定義的LintExecutionClient,完成自定義lint檢查。

3.新增Gradle Task完成增量掃描的入口

經過上述分析,咱們能夠完成lint任務的增量掃描了。可是咱們須要一個自定義Task,做爲增量掃描的任務,能夠方便的經過./gradlew lintIncrement的方式來觸發增量掃描。
經過查閱源碼,能夠知道全部lint任務都有一個父類LintBaseTask,這個類封裝了基本的lint任務的相關配置和執行操做。因此咱們能夠繼承LintBaseTask,派生一個LintIncrementTask子類,源碼以下:

public class LintIncrementTask extends LintBaseTask {
    private VariantInputs variantInputs;

    @TaskAction
    public void lint() {
        // 讀取參數
        String baseline = "";
        String revision = "";
        if (project.hasProperty("revision") && project.hasProperty("baseline")) {
            baseline = (String) project.property("baseline");
            revision = (String) project.property("revision");
        }
        // 尋找變動文件集合
        List<String> files = GitUtil.diffFileListFromTwoBranch(revision, baseline);
        if (files.isEmpty()) {
            getLogger().warn("no files was modified, skip lint incremental task");
        } else {
            LintCheckTaskDescriptor descriptor = new LintCheckTaskDescriptor();
            descriptor.incrementFiles = files;
            // 執行lint檢查
            runLint(descriptor);
        }
    }

    public class LintCheckTaskDescriptor extends LintBaseTaskDescriptor {
        List<File> incrementFiles;
        ...
        public List<File> getIncrementFiles() {
            return incrementFiles;
        }
    }
}
複製代碼

可是當你執行./gradlew lintIncremnt -Prevision=xxxx -Pbaseline=xxxx命令時,會報錯~嗯,理想很豐滿,現實很骨感!參考Lint任務的其餘實現,好比LintPerVariantTask,咱們能夠發現,每一個lint任務都須要進行配置,以下:

public abstract static class BaseConfigAction<T extends LintBaseTask> implements TaskConfigAction<T> {
    @NonNull private final GlobalScope globalScope;

    public BaseConfigAction(@NonNull GlobalScope globalScope) {
        this.globalScope = globalScope;
    }

    @Override
    public void execute(@NonNull T lintTask) {
        lintTask.setGroup(JavaBasePlugin.VERIFICATION_GROUP);
        lintTask.lintOptions = globalScope.getExtension().getLintOptions();
        File sdkFolder = globalScope.getSdkHandler().getSdkFolder();
        if (sdkFolder != null) {
            lintTask.sdkHome = sdkFolder;
        }

        lintTask.toolingRegistry = globalScope.getToolingRegistry();
        lintTask.reportsDir = globalScope.getReportsDir();
        lintTask.setAndroidBuilder(globalScope.getAndroidBuilder());

        lintTask.lintClassPath = globalScope.getProject().getConfigurations()
                .getByName(LINT_CLASS_PATH);
    }
}
複製代碼

經過源碼發現,每一個Lint任務須要配置sdkHome/toolingregistry/androidBuilder等一系列Android環境相關的變量,而繼續對這些變量進行追本溯源,發現它們是在AndroidGradlePlugin在配置階段就已經設置好的,而且設置代碼至關複雜。 我最開始的思路是針對每個變量,參考AndroidGradlePlugin的實現對其進行賦值,發現須要拷貝大量AndroidGradlePlugin裏的代碼實現,而且通過屢次嘗試,總有賦值錯誤或者賦值不徹底的狀況存在。爲何這三個變量的設置會很是複雜呢?由於每一個變量的類型裏又有不少其餘的屬性須要設置,層層嵌套以後,對這些屬性賦值就變得異常繁瑣。最終這種方案以失敗了結。
有沒有一種省時省力又不會出錯的方案呢?固然有了。通過屢次嘗試和摸索以後,我試着換了一種思路。由於LintIncrementTask和其餘標準的LintTask同樣,都是繼承了LintBaseTask,因此說其餘LintTask在配置完成後,都會將sdkHome/toolingregistry/androidBuilder等一系列變量都設置好,而自定義的LintIncrementTask的這些變量能和這些標準LintTask的變量值一致就能夠了。後來想到gradle任務都有配置和執行兩個階段,而這些變量的設置都是在配置階段完成的,因此在整個gradle的配置階段完成後,取到標準LintTask的這些變量值,直接賦值給LintIncrementTask就行了!什麼?你說這些變量值都是私有的,怎麼取?哈哈,反射大法好呀。不廢話,上代碼:

public void config() {
    Object lintTask = getProject().getTasks().getByName("lintDebug");
    sdkHome = ReflectiveUtils.getFieldValue(LintBaseTask.class, "sdkHome", lintTask);
    reportsDir = ReflectiveUtils.getFieldValue(LintBaseTask.class, "reportsDir", lintTask);
    toolingRegistry = ReflectiveUtils.getFieldValue(LintBaseTask.class, "toolingRegistry", lintTask);
    variantInputs = ReflectiveUtils.getFieldValue(LintPerVariantTask.class, "variantInputs", lintTask);
    setAndroidBuilder(ReflectiveUtils.getFieldValue(AndroidBuilderTask.class, "androidBuilder", lintTask));
    }
複製代碼

完美搞定!如今就能夠正常運行lintIncremnt任務了~

數據對比

經過在項目中應用lint全量掃描和增量掃描,耗時數據對好比下:

能夠發現,對項目進行一次lint全量掃描的平均耗時在5分鐘左右,而使用lint增量掃描平均耗時僅需20s,效率提高了15倍以上。

總結

本文主要討論了在自定義lint規則框架的基礎上,一種實現Lint增量掃描的解決方案,解決了以下兩個問題:

  1. 生成lint問題的增量報告
  2. lint增量檢查,提高效率

lint 2.3.0新增的baseline能力,也能夠實現lint問題的增量報告,可是其本質也是全量掃描,並不能提高掃描效率。所以在項目的實際應用中,能夠結合baseline和本方案共同使用:對項目中遺留的暫時沒有時間修復的大量lint問題,可使用baseline的功能,生成lint問題基準文件,同時應用本文介紹的方案,提高掃描效率。

參考文檔

  1. Lint介紹文檔:developer.android.com/studio/writ…
  2. Lint源碼:android.googlesource.com/platform/to…
  3. 官方Lint規則:android.googlesource.com/platform/to…
  4. Google官方自定義lint規則指南:tools.android.com/tips/lint-c…
  5. 美團的Android自定義Lint實踐:tech.meituan.com/android_cus…
  6. Google官方自定義lint升級版本(使用lintChecks):github.com/googlesampl…
相關文章
相關標籤/搜索