先來講說爲何會有這樣一個題目吧。最近這大半年都在作項目crash收斂的事情,說到crash收斂,最簡單的應該是Java相關的Crash了。在作的過程當中就發現,其實不少Java Crash的產生都是開發同窗犯的低級錯誤,好比數組越界、parseInt的裸調等等。那有沒有一種方式能夠避免開發同窗犯這樣的錯誤呢?後來就嘗試接入靜態代碼掃描。公司級的靜態代碼掃描有CodeDog和CodeCC,當時CodeCC不支持kotlin,就選擇了CodeDog,而CodeDog上的規則能夠避免一部分問題,但不少項目相關的問題規避須要自定義規則才能解決,而CodeDog在自定義規則上的支持並非特別友好。後來就開始調研如何本身作自定義規則,支持Kotlin的靜態代碼掃描工具主要有如下幾種:html
由於咱們的項目實際上是使用了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
Google雖然沒有提供lint增量掃描的能力,可是在lint2.3.0版本之後,提供了一個baseline的功能。一開始我覺得這個就是增量掃描,但後瞭解後才發現,baseline本質上也是全量掃描,只不過baseline容許你建立一個基準問題集,以後全部的掃描結果集合會與基準問題集作對比,篩選出增量問題寫入報告。所以,baseline的方案本質是沒法提高掃描效率的。git
目前來講,使用lint有如下幾種方式:github
下面是幾種使用方式的對比:api
功能 | Android Studio | AndroidGradlePlugin | lint命令行工具 |
---|---|---|---|
增量問題報告 | Yes | Yes(2.3.0之後) | No |
增量掃描 | Yes | No | No |
接入持續集成 | No | Yes | Yes |
Android Studio的方式能支持增量問題報告和增量掃描,可是沒法應用到流水線中,且沒法強制開發同窗人人去執行;AndroidGradlePlugin和命令行的方式,都能方便地繼承到流水線中,可是它們都沒法實現增量掃描,效率十分低下。所以,並無一種方式能夠完美契合咱們的目標。既然如此,咱們能夠以現有工具爲基礎,開發一款能增量掃描和展現問題,又能方便接入流水線的工具。數組
經過上面對三種現有lint使用方式的對比,發現AndroidGradlePlugin基於Gradle,是最易擴展到一種,所以,咱們決定在AndroidGradlePugin的基礎上進行擴展,開發一款完美的lint工具。
其實增量掃描的解決思路很是簡單: android-studio
下面來看下每一步如何實現。bash
目前大多數項目都採用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("\"", "")
}
複製代碼
想要對增量文件進行lint檢查,首先須要弄清楚android的gradle插件自帶的lint任務是如何進行代碼掃描的。經過查閱源碼,能夠看到lint任務的執行流程以下:
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)
}
}
}
...
}
複製代碼
其中:
因此從這裏能夠看出,增量掃描是能夠實現的,只要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檢查。
經過上述分析,咱們能夠完成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規則框架的基礎上,一種實現Lint增量掃描的解決方案,解決了以下兩個問題:
lint 2.3.0新增的baseline能力,也能夠實現lint問題的增量報告,可是其本質也是全量掃描,並不能提高掃描效率。所以在項目的實際應用中,能夠結合baseline和本方案共同使用:對項目中遺留的暫時沒有時間修復的大量lint問題,可使用baseline的功能,生成lint問題基準文件,同時應用本文介紹的方案,提高掃描效率。