注册

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


Android静态代码扫描效率优化与实践(下)
Lint增量扫描Gradle任务实现

前面分析了如何获取差异文件以及增量扫描的原理,分析的重点还是侧重在Lint工具本身的实现机制上。接下来分析,在Gradle中如何实现一个增量扫描任务。大家知道,通过执行./gradlew lint命令来执行Lint静态代码检测任务。创建一个新的Android工程,在Gradle任务列表中可以在Verification这个组下面找到几个Lint任务,如下所示:

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

这几个任务就是 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。最终的扫描任务在LintGradleExecution的runLint方法中执行,这个类位于lint-gradle-26.1.1中,前面提到这个库是基于Lint的API针对Gradle任务做的一些封装。

/** Runs lint on the given variant and returns the set of warnings */
  private Pair, 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, 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插件是如何进行单个文件扫描的。

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

我们选择Analyze Current File对当前文件进行扫描,扫描结果如下所示:

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

可以看到确实只扫描了一个文件。那么扫描到底使用了哪些输入数据呢,我们可以通过扫描结果的提示清楚看到:

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

这里我们能看到很多有用的信息:

  • 源码目录列表,包含了工程中的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 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 classToResolve = new ArrayList()
              classToResolve.add(file.absolutePath)
              Set 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%+。优化数据如下:

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

落地与沉淀

扫描工具通用性

解决了扫描效率问题,我们想怎么让更多的工程能低成本的使用这个扫描插件。对于一个已经存在的工程,如果没有使用过静态代码扫描,我们希望在接入扫描插件后续新增的代码能够保证其经过增量扫描没有问题。而老的存量代码,由于代码量过大增量扫描并没有效率上的优势,我们希望可以使用全量扫描逐步解决存量代码存在的问题。同时,为了配置工具的灵活,也提供配置来让接入方自己决定选择接入哪些工具。这样可以让扫描工具同时覆盖到新老项目,保证其通用。所以,要同时支持配置使用增量或者全量扫描任务,并且提供灵活的选择接入哪些扫描工具

扫描完整性保证

前面提到过,在FindBugs增量扫描可能会出现因为参与分析的目标文件集不全导致的某类匹配规则误报,所以在保证扫描效率的同时,也要保证扫描的完整性和准确性。我们的策略是以增量扫描为主,全量扫描为辅,PR提交使用增量扫描提高效率,在CI配置Daily Build使用全量扫描保证扫描完整和不遗漏

我们在自己的项目中实践配置如下:

apply plugin: 'code-detector'

codeDetector {
  // 配置静态代码检测报告的存放位置
  reportRelativePath = rootProject.file('reports')

  /**
    * 远程仓库地址,用于配置提交pr时增量检测
    */
  upstreamGitUrl = "ssh://git@xxxxxxxx.git"

  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检测规则的方案,未来我们将配合基础设施标准化建设,结合静态扫描插件制定一些标准化检测规则来更好的保证我们的代码规范以及质量。

参考资料

作者简介

鸿耀,美团餐饮生态技术团队研发工程师。

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

0 个评论

要回复文章请先登录注册