注册

Android静态代码扫描效率优化与实践(上)



小伙伴们,美美又来推荐干货文章啦~本文为美团研发同学实战经验,主要介绍Android静态扫描工具Lint、CheckStyle、FindBugs在扫描效率优化上的一些探索和实践,希望大家喜欢鸭。

背景与问题

DevOps实践中,我们在CI(Continuous Integration)持续集成过程主要包含了代码提交、静态检测、单元测试、编译打包环节。其中静态代码检测可以在编码规范,代码缺陷,性能等问题上提前预知,从而保证项目的交付质量。Android项目常用的静态扫描工具包括CheckStyle、Lint、FindBugs等,为降低接入成本,美团内部孵化了静态代码扫描插件,集合了以上常用的扫描工具。项目初期引入集团内部基建时我们接入了代码扫描插件,在PR(Pull Request)流程中借助Jenkins插件来触发自动化构建,从而达到监控代码质量的目的。初期单次构建耗时平均在1~2min左右,对研发效率影响甚少。但是随着时间推移,代码量随业务倍增,项目也开始使用Flavor来满足复杂的需求,这使得我们的单次PR构建达到了8~9min左右,其中静态代码扫描的时长约占50%,持续集成效率不高,对我们的研发效率带来了挑战。

思考与策略

针对以上的背景和问题,我们思考以下几个问题:

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

扫描工具对比

为了验证扫描工具的必要性,我们关心以下一些维度:

  • 扫码侧重点,对比各个工具分别能针对解决什么类型的问题;

  • 内置规则种类,列举各个工具提供的能力覆盖范围;

  • 扫描对象,对比各个工具针对什么样的文件类型扫描;

  • 原理简介,简单介绍各个工具的扫描原理;

  • 优缺点,简单对比各个工具扫描效率、扩展性、定制性、全面性上的表现。

Android静态代码扫描效率优化与实践_美团_03

注:FindBugs只支持Java1.0~1.8,已经被SpotBugs替代。鉴于部分老项目并没有迁移到Java8,目前我们并没有使用SpotBugs代替FindBugs的原因如下,详情参考 官方文档
Android静态代码扫描效率优化与实践_美团_04
同时,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),其中为笛卡尔积。如下图所示:

Android静态代码扫描效率优化与实践_美团_05

可以看到,一次构建全量扫描执行的Task跟Varint个数正相关。对于现有工程的任务,我们可以看一下目前各个任务的耗时情况:(以实际开发中某一次扫描为例)

Android静态代码扫描效率优化与实践_Android开发_06

通过对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 collectDepProject(Project project, BaseVariant variant, Setresult = 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 defaultExcludes = (androidDataBindingExcludes + androidExcludes + butterKnifeExcludes + dagger2Excludes).asImmutable()

List 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左右。

Android静态代码扫描效率优化与实践_美团_07

增量扫描优化

由前面的思考分析我们知道,并不是所有的文件每次都需要参与扫描,所以我们可以通过增量扫描的方式来提高扫描效率。

增量扫描技术调研

在做具体技术方案之前,我们先调研一下业界的现有方案,调研如下:

Android静态代码扫描效率优化与实践_Android教程_08

针对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扫描的工作流程:

Android静态代码扫描效率优化与实践_Android教程_09

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来实现扫描。如下图所示:

Android静态代码扫描效率优化与实践_美团_10

为了方便查看源码,新建一个工程,在build.gradle脚本中,添加如下依赖:

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

我们可以得到如下所示的依赖:

Android静态代码扫描效率优化与实践_Android教程_11

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这种运行方式做的封装。最核心的逻辑在LintDriver的Analyze方法中。

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?): EnumSet {
          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 ()
    val generatedFolders = if (isCheckGeneratedSources)
    project.generatedSourceFolders
    else
    emptyList ()
    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 getSubset() {
      return files;
  }

注释也很明确的说明了只要Files不为Null,就会扫描指定文件,否则扫描整个工程。

Android静态代码扫描效率优化与实践(下)

作者:美团技术团队 · 鸿耀
来源:https://blog.51cto.com/u_15197658/2768467

0 个评论

要回复文章请先登录注册