Android靜態程式碼掃描效率優化與實踐

  • 2019 年 11 月 10 日
  • 筆記

小夥伴們,美美又來推薦乾貨文章啦~本文主要介紹Android靜態掃描工具Lint、CheckStyle、FindBugs在掃描效率優化上的一些探索和實踐,希望大家喜歡鴨。

背景與問題

DevOps實踐中,我們在CI(Continuous Integration)持續集成過程主要包含了程式碼提交、靜態檢測、單元測試、編譯打包環節。其中靜態程式碼檢測可以在編碼規範,程式碼缺陷,性能等問題上提前預知,從而保證項目的交付品質。Android項目常用的靜態掃描工具包括CheckStyle、Lint、FindBugs等,為降低接入成本,美團內部孵化了靜態程式碼掃描插件,集合了以上常用的掃描工具。項目初期引入集團內部基建時我們接入了程式碼掃描插件,在PR(Pull Request)流程中藉助Jenkins插件來觸發自動化構建,從而達到監控程式碼品質的目的。初期單次構建耗時平均在1~2min左右,對研發效率影響甚少。但是隨著時間推移,程式碼量隨業務倍增,項目也開始使用Flavor來滿足複雜的需求,這使得我們的單次PR構建達到了8~9min左右,其中靜態程式碼掃描的時長約佔50%,持續集成效率不高,對我們的研發效率帶來了挑戰。

思考與策略

針對以上的背景和問題,我們思考以下幾個問題:

思考一:現有插件包含的掃描工具是否都是必需的?

掃描工具對比

為了驗證掃描工具的必要性,我們關心以下一些維度:

  • 掃碼側重點,對比各個工具分別能針對解決什麼類型的問題;
  • 內置規則種類,列舉各個工具提供的能力覆蓋範圍;
  • 掃描對象,對比各個工具針對什麼樣的文件類型掃描;
  • 原理簡介,簡單介紹各個工具的掃描原理;
  • 優缺點,簡單對比各個工具掃描效率、擴展性、訂製性、全面性上的表現。

註:FindBugs只支援Java1.0~1.8,已經被SpotBugs替代。鑒於部分老項目並沒有遷移到Java8,目前我們並沒有使用SpotBugs代替FindBugs的原因如下,詳情參考官方文檔

同時,SpotBugs的作者也在討論是否讓SpotBugs支援老的Java版本,結論是不提供支援。

經過以上的對比分析我們發現,工具的誕生都能針對性解決某一領域問題。CheckStyle的掃描速度快效率高,對程式碼風格和圈複雜度支援友好;FindBugs針對Java程式碼潛在問題,能幫助我們發現編碼上的一些錯誤實踐以及部分安全問題和性能問題;Lint是官方深度訂製,功能極其強大,且可訂製性和擴展性以及全面性都表現良好。所以綜合考慮,針對思考一,我們的結論是整合三種掃描工具,充分利用每一個工具的領域特性。

思考二:是否可以優化掃描過程?

既然選擇了整合這幾種工具,我們面臨的挑戰是整合工具後掃描效率的問題,首先來分析目前的插件到底耗時在哪裡。

靜態程式碼掃描耗時分析

Android項目的構建依賴Gradle工具,一次構建過程實際上是執行所有的Gradle Task。由於Gradle的特性,在構建時各個Module都需要執行CheckStyle、FindBugs、Lint相關的Task。對於Android來說,Task的數量還與其構建變體Variant有關,其中Variant = Flavor * BuildType。所以一個Module執行的相關任務可以由以下公式來描述:Flavor * BuildType *(Lint,CheckStyle,Findbugs),其中*為笛卡爾積。如下圖所示:

可以看到,一次構建全量掃描執行的Task跟Varint個數正相關。對於現有工程的任務,我們可以看一下目前各個任務的耗時情況:(以實際開發中某一次掃描為例)

通過對Task耗時排序,主要的耗時體現在FindBugs和Lint對每一個Module的掃描任務上,CheckStyle任務並不佔主要影響。整體來看,除了工具本身的掃描時間外,耗時主要分為多Module、多Variant帶來的任務數量耗時

優化思路分析

對於工具本身的掃描時間,一方面受工具自身掃描演算法和檢測規則的影響,另一方面也跟掃描的文件數量相關。針對源碼類型的工具比如CheckStyle和Lint,需要經過詞法分析、語法分析生成抽象語法樹,再遍歷抽象語法樹跟定義的檢測規則去匹配;而針對位元組碼文件的工具FindBugs,需要先編譯源碼成Class文件,再通過BCEL分析位元組碼指令並與探測器規則匹配。如果要在工具本身演算法上去尋找優化點,代價比較大也不一定能找到有效思路,投入產出比不高,所以我們把精力放在減少Module和Variant帶來的影響上。

從上面的耗時分析可以知道,Module和Variant數直接影響任務數量, 一次PR提交的場景是多樣的,比如多Module多Variant都有修改,所以要考慮這些都修改的場景。先分析一個Module多Variant的場景,考慮到不同的Variant下源程式碼有一定差異,並且FindBugs掃描針對的是Class文件,不同的Variant都需要編譯後才能掃描,直接對多Variant做處理比較複雜。我們可以簡化問題,用以空間換時間的方式,在提交PR的時候根據Variant用不同的Jenkins Job來執行每一個Variant的掃描任務。所以接下來的問題就轉變為如何優化在掃描單個Variant的時候多Module任務帶來的耗時。

對於Module數而言,我們可以將其抽取成組件,拆分到獨立倉庫,將掃描任務拆分到各自倉庫的變動時期,以aar的形式集成到主項目來減少Module帶來的任務數。那對於剩下的Module如何優化呢?無論是哪一種工具,都是對其輸入文件進行處理,CheckStyle對Java源程式碼文件處理,FindBugs對Java位元組碼文件處理,如果我們可以通過一次任務收集到所有Module的源碼文件和編譯後的位元組碼文件,我們就可以減少多Module的任務了。所以對於全量掃描,我們的主要目標是來解決如何一次性收集所有Module的目標文件

思考三:是否支援增量掃描?

上面的優化思路都是基於全量掃描的,解決的是多Module多Variant帶來的任務數量耗時。前面提到,工具本身的掃描時間也跟掃描的文件數量有關,那麼是否可以從掃描的文件數量來入手呢?考慮平時的開發場景,提交PR時只是部分文件修改,我們沒必要把那些沒修改過的存量文件再參與掃描,而只針對修改的增量文件掃描,這樣能很大程度降低無效掃描帶來的效率問題。有了思路,那麼我們考慮以下幾個問題:

  • 如何收集增量文件,包括源碼文件和Class文件?
  • 現在業界是否有增量掃描的方案,可行性如何,是否適用我們現狀?
  • 各個掃描工具如何來支援增量文件的掃描?

根據上面的分析與思考路徑,接下來我們詳細介紹如何解決上述問題。

優化探索與實踐

全量掃描優化

搜集所有Module目標文件集

獲取所有Module目標文件集,首先要找出哪些Module參與了掃描。一個Module工程在Gradle構建系統中被描述為一個「Project」,那麼我們只需要找出主工程依賴的所有Project即可。由於依賴配置的多樣性,我們可以選擇在某些Variant下依賴不同的Module,所以獲取參與一次構建時與當前Variant相關的Project對象,我們可以用如下方式:

static Set<Project> collectDepProject(Project project, BaseVariant variant, Set<Project> result = null) {    if (result == null) {      result = new HashSet<>()    }    Set taskSet = variant.javaCompiler.taskDependencies.getDependencies(variant.javaCompiler)    taskSet.each { Task task ->      if (task.project != project && hasAndroidPlugin(task.project)) {        result.add(task.project)        BaseVariant childVariant = getVariant(task.project)        if (childVariant.name == variant.name || "${variant.flavorName}${childVariant.buildType.name}".toLowerCase() == variant.name.toLowerCase()) {          collectDepProject(task.project, childVariant, result)        }      }    }    return result  }

目前文件集分為兩類,一類是源碼文件,另一類是位元組碼文件,分別可以如下處理:

projectSet.each { targetProject ->    if (targetProject.plugins.hasPlugin(CodeDetectorPlugin) && GradleUtils.hasAndroidPlugin(targetProject)) {      GradleUtils.getAndroidExtension(targetProject).sourceSets.all { AndroidSourceSet sourceSet ->        if (!sourceSet.name.startsWith("test") && !sourceSet.name.startsWith(SdkConstants.FD_TEST)) {          source sourceSet.java.srcDirs        }      }    }  }

註:上面的Source是CheckStyle Task的屬性,用其來指定掃描的文件集合;

// 排除掉一些模板程式碼class文件  static final Collection<String> defaultExcludes = (androidDataBindingExcludes + androidExcludes + butterKnifeExcludes + dagger2Excludes).asImmutable()    List<ConfigurableFileTree> allClassesFileTree = new ArrayList<>()  ConfigurableFileTree currentProjectClassesDir = project.fileTree(dir: variant.javaCompile.destinationDir, excludes: defaultExcludes)  allClassesFileTree.add(currentProjectClassesDir)  GradleUtils.collectDepProject(project, variant).each { targetProject ->    if (targetProject.plugins.hasPlugin(CodeDetectorPlugin) && GradleUtils.hasAndroidPlugin(targetProject)) {      // 可能有的工程沒有Flavor只有buildType       GradleUtils.getAndroidVariants(targetProject).each { BaseVariant targetProjectVariant ->       if (targetProjectVariant.name == variant.name || "${targetProjectVariant.name}".toLowerCase() == variant.buildType.name.toLowerCase()) {           allClassesFileTree.add(targetProject.fileTree(dir: targetProjectVariant.javaCompile.destinationDir, excludes: defaultExcludes))        }      }    }  }

註:收集到位元組碼文件集後,可以用通過FindBugsTask 的 Class 屬性指定掃描,後文會詳細介紹FindBugs Task相關屬性。

對於Lint工具而言,相應的Lint Task並沒有相關屬性可以指定掃描文件,所以在全量掃描上,我們暫時沒有針對Lint做優化。

全量掃描優化數據

通過對CheckStyle和FindBugs全量掃描的優化,我們將整體掃描時間由原來的9min降低到了5min左右。

增量掃描優化

由前面的思考分析我們知道,並不是所有的文件每次都需要參與掃描,所以我們可以通過增量掃描的方式來提高掃描效率。

增量掃描技術調研

在做具體技術方案之前,我們先調研一下業界的現有方案,調研如下:

針對Lint,我們可以借鑒現有實現思路,同時深入分析掃描原理,在3.x版本上尋找出增量掃描的解決方案。對於CheckStyle和FindBugs,我們需要了解工具的相關配置參數,為其指定特定的差異文件集合。

註:業界有一些增量掃描的案例,例如diff_cover,此工具主要是對單元測試整體覆蓋率的檢測,以增量程式碼覆蓋率作為一個指標來衡量項目的品質,但是這跟我們的靜態程式碼分析的需求不太符合。它有一個比較好的思路是找出差異的程式碼行來分析覆蓋率,粒度比較細。但是對於靜態程式碼掃描,僅僅的差異行不足以完成上下文的語義分析,尤其是針對FindBugs這類需要分析位元組碼的工具,獲取的差異行還需要經過編譯成Class文件才能進行分析,方案並不可取。

尋找增量修改文件

增量掃描的第一步是獲取待掃描的目標文件。我們可以通過git diff命令來獲取差異文件,值得注意的是對於刪除的文件和重命名的文件需要忽略,我們更關心新增和修改的文件,並且只需要獲取差異文件的路徑就好了。舉個例子:git diff –name-only –diff-filter=dr commitHash1 commitHash2,以上命令意思是對比兩次提交記錄的差異文件並獲取路徑,過濾刪除和重命名的文件。對於尋找本地倉庫的差異文件上面的命令已經足夠了,但是對於PR的情況還有一些複雜,需要對比本地程式碼與遠程倉庫目標分支的差異。集團的程式碼管理工具在Jenkins上有相應的插件,該插件默認提供了幾個參數,我們需要用到以下兩個:

  • ${targetBranch}:需要合入程式碼的目標分支地址;
  • ${sourceCommitHash}:需要提交的程式碼hash值。

通過這兩個參數執行以下一系列命令來獲取與遠程目標分支的差異文件。

git remote add upstream ${upstreamGitUrl}  git fetch upstream ${targetBranch}  git diff --name-only --diff-filter=dr $sourceCommitHash upstream/$targetBranch

1. 配置遠程分支別名為UpStream,其中upstreamGitUrl可以在插件提供的配置屬性中設置;

2. 獲取遠程目標分支的更新;

3. 比較分支差異獲取文件路徑。

通過以上方式,我們找到了增量修改文件集。

Lint掃描原理分析

在分析Lint增量掃描原理之前,先介紹一下Lint掃描的工作流程:

App Source Files

項目中的源文件,包括Java、XML、資源文件、proGuard等。

lint.xml

用於配置希望排除的任何 Lint 檢查以及自定義問題嚴重級別,一般各個項目都會根據自身項目情況自定義的lint.xml來排除一些檢查項。

lint Tool

一套完整的掃描工具用於對Android的程式碼結構進行分析,可以通過命令行、IDEA、Gradle命令三種方式運行lint工具。

lint Output

Lint掃描的輸出結果。

從上面可以看出,Lint Tool就像一個加工廠,對投入進來的原料(源程式碼)進行加工處理(各種檢測器分析),得到最終的產品(掃描結果)。Lint Tool作為一個掃描工具集,有多種使用方式。Android為我們提供了三種運行方式,分別是命令行、IDEA、Gradle任務。這三種方式最終都殊途同歸,通過LintDriver來實現掃描。如下圖所示:

為了方便查看源碼,新建一個工程,在build.gradle腳本中,添加如下依賴:

compile 'com.android.tools.build:gradle:3.1.1'  compile 'com.android.tools.lint:lint-gradle:26.1.1'

我們可以得到如下所示的依賴:

lint-api-26.1.1

Lint工具集的一個封裝,實現了一組API介面,用於啟動Lint。

lint-checks-26.1.1

一組內建的檢測器,用於對這種描述好Issue進行分析處理。

lint-26.1.1

可以看做是依賴上面兩個jar形成的一個基於命令行的封裝介面形成的腳手架工程,我們的命令行、Gradle任務都是繼承自這個jar包中相關類來做的實現。

lint-gradle-26.1.1

可以看做是針對Gradle任務這種運行方式,基於lint-26.1.1做了一些封裝類。

lint-gradle-api-26.1.1

真正Gradle Lint任務在執行時調用的入口。

在理解清楚了以上幾個jar的關係和作用之後,我們可以發現Lint的核心庫其實是前三個依賴。後面兩個其實是基於腳手架,對Gradle這種運行方式做的封裝。最核心的邏輯在LintDriverAnalyze方法中。

fun analyze() {        ...省略部分程式碼...        for (project in projects) {          fireEvent(EventType.REGISTERED_PROJECT, project = project)      }      registerCustomDetectors(projects)        ...省略部分程式碼...        try {          for (project in projects) {              phase = 1                val main = request.getMainProject(project)                // The set of available detectors varies between projects              computeDetectors(project)                if (applicableDetectors.isEmpty()) {                  // No detectors enabled in this project: skip it                  continue              }                checkProject(project, main)              if (isCanceled) {                  break              }                runExtraPhases(project, main)          }      } catch (throwable: Throwable) {          // Process canceled etc          if (!handleDetectorError(null, this, throwable)) {              cancel()          }      }      ...省略部分程式碼...  }

主要是以下三個重要步驟:

registerCustomDetectors(projects)

Lint為我們提供了許多內建的檢測器,除此之外我們還可以自定義一些檢測器,這些都需要註冊進Lint工具用於對目標文件進行掃描。這個方法主要做以下幾件事情:

  1. 遍歷每一個Project和它的依賴Library工程,通過client.findRuleJars來找出自定義的jar包;
  2. 通過client.findGlobalRuleJars找出全局的自定義jar包,可以作用於每一個Android工程;
  3. 從找到的jarFiles列表中,解析出自定義的規則,並與內建的Registry一起合併為CompositeIssueRegistry;需要注意的是,自定義的Lint的jar包存放位置是build/intermediaters/lint目錄,如果是需要每一個工程都生效,則存放位置為~/.android/lint/

computeDetectors(project)

這一步主要用來收集當前工程所有可用的檢測器。

checkProject(project, main)

接下來這一步是最為關鍵的一步。在此方法中,調用runFileDetectors來進行文件掃描。Lint支援的掃描文件類型很多,因為是官方支援,所以針對Android工程支援的比較友好。一次Lint任務運行時,Lint的掃描範圍主要由Scope來描述。具體表現在:

fun infer(projects: Collection<Project>?): EnumSet<Scope> {              if (projects == null || projects.isEmpty()) {                  return Scope.ALL              }                // Infer the scope              var scope = EnumSet.noneOf(Scope::class.java)              for (project in projects) {                  val subset = project.subset                  if (subset != null) {                      for (file in subset) {                          val name = file.name                          if (name == ANDROID_MANIFEST_XML) {                              scope.add(MANIFEST)                          } else if (name.endsWith(DOT_XML)) {                              scope.add(RESOURCE_FILE)                          } else if (name.endsWith(DOT_JAVA) || name.endsWith(DOT_KT)) {                              scope.add(JAVA_FILE)                          } else if (name.endsWith(DOT_CLASS)) {                              scope.add(CLASS_FILE)                          } else if (name.endsWith(DOT_GRADLE)) {                              scope.add(GRADLE_FILE)                          } else if (name == OLD_PROGUARD_FILE || name == FN_PROJECT_PROGUARD_FILE) {                              scope.add(PROGUARD_FILE)                          } else if (name.endsWith(DOT_PROPERTIES)) {                              scope.add(PROPERTY_FILE)                          } else if (name.endsWith(DOT_PNG)) {                              scope.add(BINARY_RESOURCE_FILE)                          } else if (name == RES_FOLDER || file.parent == RES_FOLDER) {                              scope.add(ALL_RESOURCE_FILES)                              scope.add(RESOURCE_FILE)                              scope.add(BINARY_RESOURCE_FILE)                              scope.add(RESOURCE_FOLDER)                          }                      }                  } else {                      // Specified a full project: just use the full project scope                      scope = Scope.ALL                      break                  }              }  }

可以看到,如果Project的Subset為Null,Scope就為Scope.ALL,表示本次掃描會針對能檢測的所有範圍,相應地在掃描時也會用到所有全部的Detector來掃描文件;

如果Project的Subset不為Null,就遍歷Subset的集合,找出Subset中的文件分別對應哪些範圍。其實到這裡我們已經可以知道,Subset就是我們增量掃描的突破點。接下來我們看一下runFileDetectors:

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 (isCheckGeneratedSources)        project.generatedSourceFolders        else        emptyList<File> ()        checkJava(project, main, sourceFolders, testFolders, generatedFolders, checks)      }    }  }

這裡更加明確,如果project.subset不為空,就對單獨的Java文件掃描,否則,就對源碼文件和測試目錄以及自動生成的程式碼目錄進行掃描。整個runFileDetectors的掃描順序入下:

  1. Scope.MANIFEST
  2. Scope.ALL_RESOURCE_FILES)|| scope.contains(Scope.RESOURCE_FILE) || scope.contains(Scope.RESOURCE_FOLDER) || scope.contains(Scope.BINARY_RESOURCE_FILE)
  3. scope.contains(Scope.JAVA_FILE) || scope.contains(Scope.ALL_JAVA_FILES)
  4. scope.contains(Scope.CLASS_FILE) || scope.contains(Scope.ALL_CLASS_FILES) || scope.contains(Scope.JAVA_LIBRARIES)
  5. scope.contains(Scope.GRADLE_FILE)
  6. scope.contains(Scope.OTHER)
  7. scope.contains(Scope.PROGUARD_FILE)
  8. scope.contains(Scope.PROPERTY_FILE)

與[官方文檔]的描述順序一致。

現在我們已經知道,增量掃描的突破點其實是需要構造project.subset對象。

/**       * 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);      }        /**       * 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;      }

注釋也很明確的說明了只要Files不為Null,就會掃描指定文件,否則掃描整個工程。

Lint增量掃描Gradle任務實現

前面分析了如何獲取差異文件以及增量掃描的原理,分析的重點還是側重在Lint工具本身的實現機制上。接下來分析,在Gradle中如何實現一個增量掃描任務。大家知道,通過執行./gradlew lint命令來執行Lint靜態程式碼檢測任務。創建一個新的Android工程,在Gradle任務列表中可以在Verification這個組下面找到幾個Lint任務,如下所示:

這幾個任務就是 Android Gradle插件在載入的時候默認創建的。分別對應於以下幾個Task:

  • lint->LintGlobalTask:由TaskManager創建;
  • lintDebug、lintRelease、lintVitalRelease->LintPerVariantTask:由ApplicationTaskManager或者LibraryTaskManager創建,其中lintVitalRelease只在release下生成。

所以,在Android Gradle 插件中,應用於Lint的任務分別為LintGlobalTask和LintPerVariantTask。他們的區別是前者執行的是掃描所有Variant,後者執行只針對單獨的Variant。而我們的增量掃描任務其實是跟Variant無關的,因為我們會把所有差異文件都收集到。無論是LintGlobalTask或者是LintPerVariantTask,都繼承自LintBaseTask。最終的掃描任務在LintGradleExecutionrunLint方法中執行,這個類位於lint-gradle-26.1.1中,前面提到這個庫是基於Lint的API針對Gradle任務做的一些封裝。

/** Runs lint on the given variant and returns the set of warnings */      private Pair<List<Warning>, LintBaseline> runLint(              @Nullable Variant variant,              @NonNull VariantInputs variantInputs,              boolean report, boolean isAndroid) {          IssueRegistry registry = createIssueRegistry(isAndroid);          LintCliFlags flags = new LintCliFlags();          LintGradleClient client =                  new LintGradleClient(                          descriptor.getGradlePluginVersion(),                          registry,                          flags,                          descriptor.getProject(),                          descriptor.getSdkHome(),                          variant,                          variantInputs,                          descriptor.getBuildTools(),                          isAndroid);          boolean fatalOnly = descriptor.isFatalOnly();          if (fatalOnly) {              flags.setFatalOnly(true);          }          LintOptions lintOptions = descriptor.getLintOptions();          if (lintOptions != null) {              syncOptions(                      lintOptions,                      client,                      flags,                      variant,                      descriptor.getProject(),                      descriptor.getReportsDir(),                      report,                      fatalOnly);          } else {              // Set up some default reporters              flags.getReporters().add(Reporter.createTextReporter(client, flags, null,                      new PrintWriter(System.out, true), false));              File html = validateOutputFile(createOutputPath(descriptor.getProject(), null, ".html",                      null, flags.isFatalOnly()));              File xml = validateOutputFile(createOutputPath(descriptor.getProject(), null, DOT_XML,                      null, flags.isFatalOnly()));              try {                  flags.getReporters().add(Reporter.createHtmlReporter(client, html, flags));                  flags.getReporters().add(Reporter.createXmlReporter(client, xml, false));              } catch (IOException e) {                  throw new GradleException(e.getMessage(), e);              }          }          if (!report || fatalOnly) {              flags.setQuiet(true);          }          flags.setWriteBaselineIfMissing(report && !fatalOnly);            Pair<List<Warning>, LintBaseline> warnings;          try {              warnings = client.run(registry);          } catch (IOException e) {              throw new GradleException("Invalid arguments.", e);          }            if (report && client.haveErrors() && flags.isSetExitCode()) {              abort(client, warnings.getFirst(), isAndroid);          }            return warnings;      }

我們在這個方法中看到了warnings = client.run(registry),這就是Lint掃描得到的結果集。總結一下這個方法中做了哪些準備工作用於Lint掃描:

1. 創建IssueRegistry,包含了Lint內建的BuiltinIssueRegistry;

2. 創建LintCliFlags;

3. 創建LintGradleClient,這裡面傳入了一大堆參數,都是從Gradle Android 插件的運行環境中獲得;

4. 同步LintOptions,這一步是將我們在build.gralde中配置的一些Lint相關的DSL屬性,同步設置給LintCliFlags,給真正的Lint 掃描核心庫使用;

5. 執行Client的Run方法,開始掃描。

掃描的過程上面的原理部分已經分析了,現在我們思考一下如何構造增量掃描的任務。我們已經分析到掃描的關鍵點是client.run(registry),所以我們需要構造一個Client來執行掃描。一個想法是通過反射來獲取Client的各個參數,當然這個思路是可行的,我們也驗證過實現了一個用反射方式構造的Client。但是反射這種方式有個問題是丟失了從Gradle任務執行到調用Lint API開始掃描這一過程中做的其他事情,侵入性比較高,所以我們最終採用繼承LintBaseTask自行實現增量掃描任務的方式。

FindBugs掃描簡介

FindBugs是一個靜態分析工具,它檢查類或者JAR 文件,通過Apache的[BCEL]庫來分析Class,將位元組碼與一組缺陷模式進行對比以發現問題。FindBugs自身定義了一套缺陷模式,目前的版本3.0.1內置了總計300多種缺陷,詳細可參考[官方文檔]。FindBugs作為一個掃描的工具集,可以非常靈活的集成在各種編譯工具中。接下來,我們主要分析在Gradle中FindBugs的相關內容。

Gradle FindBugs任務屬性分析

在Gradle的內置任務中,有一個FindBugs的Task,我們看一下[官方文檔]對Gradle屬性的描述。

選幾個比較重要的屬性介紹:

  • Classes 該屬性表示我們要分析的Class文件集合,通常我們會把編譯結果的Class目錄用於掃描。
  • Classpath 分析目標集合中的Class需要用到的所有相關的Classes路徑,但是並不會分析它們自身,只用於掃描。
  • Effort 包含MIN,Default,MAX,級別越高,分析得越嚴謹越耗時。
  • findbugsClasspath Finbugs庫相關的依賴路徑,用於配置掃描的引擎庫。
  • reportLevel 報告級別,分為Low,Medium,High。如果為Low,所有Bug都報告,如果為High,僅報告High優先順序。
  • Reports 掃描結果存放路徑。

通過以上屬性解釋,不難發現要FindBugs增量掃描,只需要指定Classes的文件集合就可以了。

FindBugs任務增量掃描分析

在做增量掃描任務之前,我們先來看一下FindBugs IDEA插件是如何進行單個文件掃描的。

我們選擇Analyze Current File對當前文件進行掃描,掃描結果如下所示:

可以看到確實只掃描了一個文件。那麼掃描到底使用了哪些輸入數據呢,我們可以通過掃描結果的提示清楚看到:

這裡我們能看到很多有用的資訊:

  • 源碼目錄列表,包含了工程中的Java目錄,res目錄,以及編譯過程中生成的一些類目錄;
  • 需要分析的目標Class集合,為編譯後的Build目錄下的當前Java文件對應的Class文件;
  • Aux Classpath Entries,表示分析上面的目標文件需要用到的類路徑。

所以,根據IDEA的掃描結果來看,我們在做增量掃描的時候需要解決上面這幾個屬性的獲取。在前面我們分析的屬性是Gradle在FindBugs lib的基礎上,定義的一套對應的Task屬性。真正的Finbugs屬性我們可以通過[官方文檔]或者源碼中查到。

配置AuxClasspath

前文提到,ClassPath是用來分析目標文件需要用到的相關依賴Class,但本身並不會被分析,所以我們需要儘可能全的找到所有的依賴庫,否則在掃描的時候會報依賴的類庫找不到。

FileCollection buildClasses = project.fileTree(dir: "${project.buildDir}/intermediates/classes/${variant.flavorName}/${variant.buildType.name}",includes: classIncludes)    FileCollection targetClasspath = project.files()  GradleUtils.collectDepProject(project, variant).each { targetProject ->      GradleUtils.getAndroidVariants(targetProject).each { targetVariant ->          if (targetVariant.name.capitalize().equalsIgnoreCase(variant.name.capitalize())) {              targetClasspath += targetVariant.javaCompile.classpath          }      }  }    classpath = variant.javaCompile.classpath + targetClasspath + buildClasses

FindBugs增量掃描誤報優化

對於增量文件掃描,參與的少數文件掃描在某些模式規則上可能會出現誤判,但是全量掃描不會有問題,因為參與分析的目標文件是全集。舉一個例子:

class A {    public static String buildTime = "";    ....  }

靜態變數buildTime會被認為應該加上Final,但是其實其他類會對這個變數賦值。如果單獨掃描類A文件,就會報缺陷BUG_TYPE_MS_SHOULD_BE_FINAL。我們通過FindBugs-IDEA插件來掃描驗證,也同樣會有一樣的問題。要解決此類問題,需要找到誰依賴了類A,並且一同參與掃描,同時也需要找出類A依賴了哪些文件,簡單來說:需要找出與類A有直接關聯的類。為了解決這個問題,我們通過ASM來找出相關的依賴,具體如下:

void findAllScanClasses(ConfigurableFileTree allClass) {      allScanFiles = [] as HashSet      String buildClassDir = "${project.buildDir}/$FINDBUGS_ANALYSIS_DIR/$FINDBUGS_ANALYSIS_DIR_ORIGIN"        Set<File> moduleClassFiles = allClass.files      for (File file : moduleClassFiles) {          String[] splitPath = file.absolutePath.split("$FINDBUGS_ANALYSIS_DIR/$FINDBUGS_ANALYSIS_DIR_ORIGIN/")          if (splitPath.length > 1) {              String className = getFileNameNoFlag(splitPath[1],'.')              String innerClassPrefix = ""              if (className.contains('$')) {                  innerClassPrefix = className.split('\$')[0]              }              if (diffClassNamePath.contains(className) || diffClassNamePath.contains(innerClassPrefix)) {                  allScanFiles.add(file)              } else {                  Iterable<String> classToResolve = new ArrayList<String>()                  classToResolve.add(file.absolutePath)                  Set<File> dependencyClasses = Dependencies.findClassDependencies(project, new ClassAcceptor(), buildClassDir, classToResolve)                  for (File dependencyClass : dependencyClasses) {                      if (diffClassNamePath.contains(getPackagePathName(dependencyClass))) {                          allScanFiles.add(file)                          break                      }                  }              }          }      }  }

通過以上方式,我們可以解決一些增量掃描時出現的誤報情況,相比IDEA工具,我們更進一步降低了掃描部分文件的誤報率。

CheckStyle增量掃描

相比而言,CheckStyle的增量掃描就比較簡單了。CheckStyle對源碼掃描,根據[官方文檔]各個屬性的描述,我們發現只要指定Source屬性的值就可以指定掃描的目標文件。

void configureIncrementScanSource() {      boolean isCheckPR = false      DiffFileFinder diffFileFinder        if (project.hasProperty(CodeDetectorExtension.CHECK_PR)) {          isCheckPR = project.getProperties().get(CodeDetectorExtension.CHECK_PR)      }        if (isCheckPR) {          diffFileFinder = new DiffFileFinderHelper.PRDiffFileFinder()      } else {          diffFileFinder = new DiffFileFinderHelper.LocalDiffFileFinder()      }        source diffFileFinder.findDiffFiles(project)        if (getSource().isEmpty()) {          println '沒有找到差異java文件,跳過checkStyle檢測'      }  }

優化結果數據

經過全量掃描和增量掃描的優化,我們整個掃描效率得到了很大提升,一次PR構建掃描效率整體提升50%+。優化數據如下:

落地與沉澱

掃描工具通用性

解決了掃描效率問題,我們想怎麼讓更多的工程能低成本的使用這個掃描插件。對於一個已經存在的工程,如果沒有使用過靜態程式碼掃描,我們希望在接入掃描插件後續新增的程式碼能夠保證其經過增量掃描沒有問題。而老的存量程式碼,由於程式碼量過大增量掃描並沒有效率上的優勢,我們希望可以使用全量掃描逐步解決存量程式碼存在的問題。同時,為了配置工具的靈活,也提供配置來讓接入方自己決定選擇接入哪些工具。這樣可以讓掃描工具同時覆蓋到新老項目,保證其通用。所以,要同時支援配置使用增量或者全量掃描任務,並且提供靈活的選擇接入哪些掃描工具

掃描完整性保證

前面提到過,在FindBugs增量掃描可能會出現因為參與分析的目標文件集不全導致的某類匹配規則誤報,所以在保證掃描效率的同時,也要保證掃描的完整性和準確性。我們的策略是以增量掃描為主,全量掃描為輔,PR提交使用增量掃描提高效率,在CI配置Daily Build使用全量掃描保證掃描完整和不遺漏

我們在自己的項目中實踐配置如下:

apply plugin: 'code-detector'    codeDetector {      // 配置靜態程式碼檢測報告的存放位置      reportRelativePath = rootProject.file('reports')        /**       * 遠程倉庫地址,用於配置提交pr時增量檢測       */      upstreamGitUrl = "ssh://[email protected]"        checkStyleConfig {          /**           * 開啟或關閉 CheckStyle 檢測           * 開啟:true           * 關閉:false           */          enable = true          /**           * 出錯後是否要終止檢查           * 終止:false           * 不終止:true。配置成不終止的話 CheckStyleTask 不會失敗,也不會拷貝錯誤報告           */          ignoreFailures = false          /**           * 是否在日誌中展示違規資訊           * 顯示:true           * 不顯示:false           */          showViolations = true          /**           * 統一配置自定義的 checkstyle.xml 和 checkstyle.xsl 的 uri           * 配置路徑為:           *      "${checkStyleUri}/checkstyle.xml"           *      "${checkStyleUri}/checkstyle.xsl"           *           * 默認為 null,使用 CodeDetector 中的默認配置           */          checkStyleUri = rootProject.file('codequality/checkstyle')      }        findBugsConfig {          /**           * 開啟或關閉 Findbugs 檢測           * 開啟:true           * 關閉:false           */          enable = true          /**           * 可選項,設置分析工作的等級,默認值為 max           * min, default, or max. max 分析更嚴謹,報告的 bug 更多. min 略微少些           */          effort = "max"          /**           * 可選項,默認值為 high           * low, medium, high. 如果是 low 的話,那麼報告所有的 bug           */          reportLevel = "high"          /**           * 統一配置自定義的 findbugs_include.xml 和 findbugs_exclude.xml 的 uri           * 配置路徑為:           *      "${findBugsUri}/findbugs_include.xml"           *      "${findBugsUri}/findbugs_exclude.xml"           * 默認為 null,使用 CodeDetector 中的默認配置           */          findBugsUri = rootProject.file('codequality/findbugs')      }        lintConfig {            /**           * 開啟或關閉 lint 檢測           * 開啟:true           * 關閉:false           */          enable = true            /**           * 統一配置自定義的 lint.xml 和 retrolambda_lint.xml 的 uri           * 配置路徑為:           *      "${lintConfigUri}/lint.xml"           *      "${lintConfigUri}/retrolambda_lint.xml"           * 默認為 null,使用 CodeDetector 中的默認配置           */          lintConfigUri = rootProject.file('codequality/lint')      }  }

我們希望掃描插件可以靈活指定增量掃描還是全量掃描以應對不同的使用場景,比如已存在項目的接入、新項目的接入、打包時的檢測等。

執行腳本示例:

./gradlew ":${appModuleName}:assemble${ultimateVariantName}" -PdetectorEnable=true -PcheckStyleIncrement=true -PlintIncrement=true -PfindBugsIncrement=true -PcheckPR=${checkPR} -PsourceCommitHash=${sourceCommitHash} -PtargetBranch=${targetBranch} --stacktrace

希望一次任務可以暴露所有掃描工具發現的問題,當某一個工具掃描到問題後不終止任務,如果是本地運行在發現問題後可以自動打開瀏覽器方便查看問題原因。

def finalizedTaskArray = [lintTask,checkStyleTask,findbugsTask]  checkCodeTask.finalizedBy finalizedTaskArray    "open ${reportPath}".execute()

為了保證提交的PR不會引起打包問題影響包的交付,在PR時觸發的任務實際為打包任務,我們將靜態程式碼掃描任務掛接在打包任務中。由於我們的項目是多Flavor構建,在CI上我們將觸發多個Job同時執行對應Flavor的增量掃描和打包任務。同時為了保證程式碼掃描的完整性,我們在真正的打包Job上執行全量掃描。

總結與展望

本文主要介紹了在靜態程式碼掃描優化方面的一些思路與實踐,並重點探討了對Lint、FindBugs、CheckStyle增量掃描的一些嘗試。通過對掃描插件的優化,我們在程式碼掃描的效率上得到了提升,同時在實踐過程中我們也積累了自定義Lint檢測規則的方案,未來我們將配合基礎設施標準化建設,結合靜態掃描插件制定一些標準化檢測規則來更好的保證我們的程式碼規範以及品質。