Android 静态代码扫描效率优化与实践,2021 大厂 Android 面试经历
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 || "{childVariant.buildType.name}".toLowerCase() == variant.name.toLowerCase()) {
collectDepProject(task.project, childVariant, result)
}
}
}
returnresult}
目前文件集分为两类,一类是源码文件,另一类是字节码文件,分别可以如下处理:
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 文件 staticfinal 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.getAndroidVa
riants(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 上有相应的插件,该插件默认提供了几个参数,我们需要用到以下两个: - {sourceCommitHash}:需要提交的代码 hash 值;
通过这两个参数执行以下一系列命令来获取与远程目标分支的差异文件。
git remote add upstream {targetBranch}git diff --name-only --diff-filter=dr targetBranch
配置远程分支别名为 UpStream,其中 upstreamGitUrl 可以在插件提供的配置属性中设置;
获取远程目标分支的更新;
比较分支差异获取文件路径。
通过以上方式,我们找到了增量修改文件集。
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 这种运行方式做的封装。最核心的逻辑在 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 projectscomputeDetectors(project)
if (applicableDetectors.isEmpty()) {// No detectors enabled in this project: skip itcontinue}
checkProject(project, main)if (isCanceled) {break}
runExtraPhases(project, main)}} catch (throwable: Throwable) {// Process canceled etcif (!handleDetectorError(null, this, throwable)) {cancel()}}...省略部分代码...}
主要是以下三个重要步骤:
registerCustomDetectors(projects)
Lint 为我们提供了许多内建的检测器,除此之外我们还可以自定义一些检测器,这些都需要注册进 Lint 工具用于对目标文件进行扫描。这个方法主要做以下几件事情:
遍历每一个 Project 和它的依赖 Library 工程,通过 client.findRuleJars 来找出自定义的 jar 包;
通过 client.findGlobalRuleJars 找出全局的自定义 jar 包,可以作用于每一个 Android 工程;
从找到的 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 scopevar scope = EnumSet.noneOf(Scope::class.java)for (project in projects) {val subset = project.subsetif (subset != null) {for (file in subset) {val name = file.nameif (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 scopescope = Scope.ALLbreak}}}
可以看到,如果 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.subsetif (files != null) {checkIndividualJavaFiles(project, main, checks, files)} else {val sourceFolders = project.javaSourceFoldersval testFolders = if (scope.contains(Scope.TEST_SOURCES))project.testSourceFolderselseemptyList<File> ()val generatedFolders = if (isCheckGeneratedSources)project.generatedSourceFolderselseemptyList<File> ()checkJava(project, main, sourceFolders, testFolders, generatedFolders, checks)}}}
这里更加明确,如果 project.subset 不为空,就对单独的 Java 文件扫描,否则,就对源码文件和测试目录以及自动生成的代码目录进行扫描。整个 runFileDetectors 的扫描顺序入下:
Scope.MANIFEST
Scope.ALL_RESOURCE_FILES)|| scope.contains(Scope.RESOURCE_FILE) || scope.contains(Scope.RESOURCE_FOLDER) || scope.contains(Scope.BINARY_RESOURCE_FILE)
scope.contains(Scope.JAVA_FILE) || scope.contains(Scope.ALL_JAVA_FILES)
scope.contains(Scope.CLASS_FILE) || scope.contains(Scope.ALL_CLASS_FILES) || scope.contains(Scope.JAVA_LIBRARIES)
scope.contains(Scope.GRADLE_FILE)
scope.contains(Scope.OTHER)
scope.contains(Scope.PROGUARD_FILE)
scope.contains(Scope.PROPERTY_FILE)
与<u style="text-decoration: none; border-bottom: 1px dashed grey;">官方文档</u>的描述顺序一致。
现在我们已经知道,增量扫描的突破点其实是需要构造 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*/@Nullablepublic 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。最终的扫描任务在 LintGradleExecution 的 runLint 方法中执行,这个类位于 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 reportersflags.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 的<u style="text-decoration: none; border-bottom: 1px dashed grey;">BCEL</u>库来分析 Class,将字节码与一组缺陷模式进行对比以发现问题。FindBugs 自身定义了一套缺陷模式,目前的版本 3.0.1 内置了总计 300 多种缺陷,详细可参考<u style="text-decoration: none; border-bottom: 1px dashed grey;">官方文档</u>。FindBugs 作为一个扫描的工具集,可以非常灵活的集成在各种编译工具中。接下来,我们主要分析在 Gradle 中 FindBugs 的相关内容。
Gradle FindBugs 任务属性分析
在 Gradle 的内置任务中,有一个 FindBugs 的 Task,我们看一下<u style="text-decoration: none; border-bottom: 1px dashed grey;">官方文档</u>对 Gradle 属性的描述。
选几个比较重要的属性介绍:
Classes 该属性表示我们要分析的 Class 文件集合,通常我们会把编译结果的 Class 目录用于扫描。
Classpath 分析目标集合中的 Class 需要用到的所有相关的 Classes 路径,但是并不会分析它们自身,只用于扫描。
Effort 包含 MIN,Default,MAX,级别越高,分析得越严谨越耗时。
findBugs ClasspathFinbugs 库相关的依赖路径,用于配置扫描的引擎库。
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 属性我们可以通过<u style="text-decoration: none; border-bottom: 1px dashed grey;">官方文档</u>或者源码中查到。
配置 AuxClasspath
前文提到,ClassPath 是用来分析目标文件需要用到的相关依赖 Class,但本身并不会被分析,所以我们需要尽可能全的找到所有的依赖库,否则在扫描的时候会报依赖的类库找不到。
FileCollection buildClasses = project.fileTree(dir: "{variant.flavorName}/${variant.buildType.name}",includes: classIncludes)
FileCollection targetClasspath = project.files()
评论