Android Lint增量掃描實戰紀要

前言

先來說我為什么要做增量掃描這個事情,畢竟代碼掃描已經老生常談了,業界方案一搜一大堆,有什么好講的,大部人看到這篇文章的時候肯定這么想吧,但是注意今天我要分享的不是全量掃描,我分享的是從無到有實現增量掃描的過程,有的時候實現一個方案從來不是重點,我們對于方案的認知程度才是我們自己最重要的收獲 ̄▽ ̄ 。

再來說說怎么樣的代碼掃描才算是高效的,我是這么理解的:

不能增量檢查的代碼掃描都是耍流氓,以前的代碼一大堆問題,誰有耐心全部去解決
不能自動化的代碼掃描都是欺騙我們感情,不是每個人都有良好的意識每次都去檢查的
不能撤銷提交的代碼掃描都是自己騙自己,檢查出來問題不改,這樣的代碼掃描要來何用
不能持續集成的代碼掃描都是不專業的,問題要快上線了才發現,這樣的代碼掃描風險多高
開發缺的從來就不是工具,我們缺的是無縫嵌入的自動化流程、自我Code Review的意識,意識比工具重要。

這里扯了一些大道理,大家諒解,口號喊得響,大家才有興趣看嘛。后面全是干貨,大家放心,嘿嘿。

方案介紹

OkLint作為一個Gradle插件,使用起來超簡單,他能在你提交時發現增量問題,撤銷提交并給你發郵件。

在根目錄下的build.gradle寫

allprojects {
    apply plugin: 'oklint'
}

方案思考

在講具體實現之前,先來講講我對于高效的代碼掃描是怎么想的。

高效的代碼掃描我覺得有五個方案:

  1. 方案一是Android Studio自帶的錯誤提示功能,他有個好處就是實時發現問題,缺點就是有些問題隱藏在花花綠綠的代碼里,你要指定你想檢查的問題為error才能暴露出來,這樣就需要在每臺電腦上都改動一下,太麻煩了。

  2. 方案二是Android Studio的增量代碼掃描功能,缺點就是不能自動化,不能在團隊內很好落實,不利于統計問題和持續集成。

    image.png

  3. 方案三是用Sonar持續集成,但是他有個問題是不能增量,我們團隊用過,最后因為以前問題太多根本推行不起來,相信好多團隊都是這樣吧。

  4. 方案四是用Android Gradle插件2.3.0以后提供的新功能 baseline,他也是全量掃描,但是他能增量顯示問題,這個方案后期和Sonar持續集成,可以作為Plan B。

  5. 方案五是我現在用的方案,增量代碼掃描和git hooks搭配使用,味道更好。剛開始的思路是在git commit之前掃描增量代碼,結果發現lint掃描比較慢(我嘗試改了,改了以后確實快了但是有些問題就掃描不到了,畢竟掃描代碼還是需要整個項目的代碼才能更好的找到問題)。后面我聽取了同事峰哥的意見,采用另外一個思路,偷偷在git commit之后去掃描。有些人要問了為什么不在gitlab上的webhook里面執行,嗯你很機智,這樣實現也有很大的優點,但是我更想及時檢查每一次改動,越早發現越好解決問題。

個人覺得上面五個方案,方案四和方案五雙管齊下效果更好。方案五負責及時檢查每一次改動,方案四負責發現全量代碼潛在的問題。

方案對比

方案做出來了,要是不對比一下,就沒辦法愉快地吹NB了。

功能 OkLint Lint命令行 Android Gradle 插件 Android Studio
增量 可以 不行 不行,2.3.0后支持全量掃描增量顯示 可以
自動化 可以 不行 不行 不行
持續集成 可以 不行 不行 不行
代碼回滾 可以 不行 不行 可以
自動發送企業微信和郵件 可以 不行 不行 不行
只掃描優先級高的問題 可以 配置麻煩 配置麻煩 配置麻煩

Android有自己的Code Lint,但是他只能全量掃描,而且沒法只掃描優先級高的。固然Android Studio可以在提交前面執行code analysis,但是作為一個團隊你很難落實讓每個人每次提交代碼都去執行,就算執行了你也不能保證他一定去改正這個問題,就算他改了這個問題,你也不能保證多個分支合并的代碼沒有問題,所以一個能自動在git commit時掃描增量代碼的工具還是很有必要的。

方案實現


思路其實很簡單的,流程很簡單

gradle插件copy git hooks------> git hooks自動執行增量掃描的任務------> git diff找到增量代碼------> lint-api.jar調用project.addfile() 掃描增量代碼------>javamail發送問題郵件------>git reset回滾代碼

好了現在你已經得到我的大乘佛法了,你可以屁顛屁顛地回大唐娶妻生子走向人生巔峰了,我保證我不阻止你。

找到增量代碼

這個命令感謝我的另一個同事馬老板,他坐為旁邊,我每次急躁的時候他都耐心幫我找答案。

 private List<String> getPostCommitChange() {
        ArrayList<String> filterList = new ArrayList<String>()
        try {
            String projectDir = getProject().getProjectDir()
            String commond = "git diff --name-only --diff-filter=ACMRTUXB  HEAD~1 HEAD~0 $projectDir"
            String changeInfo = commond.execute(null, project.getRootDir()).text.trim()
            if (changeInfo == null || changeInfo.empty) {
                return filterList
            }
            String[] lines = changeInfo.split("\\n")
            return lines.toList()
        } catch (Exception e) {
            return filterList
        }
    }

用git diff命令找到剛提交的commit都改動了哪些文件,我講一下他的每個參數的意思

  • git diff 比較兩個commit
  • HEAD~1是前一個commit,HEAD~0是當前的commit,有個注意點HEAD~1 HEAD~0 的先后順序,剛開始寫反了,增加的文件變成了刪除的文件
  • diff-filter是篩選文件類型,沒寫D用來去除刪除的文件
  • name-only用來只列出文件名
  • projectDir一定要寫,不然git不知道要找哪個項目,而且注意我這里寫的是當前module dir,確保每個module只檢查自己的改動,用來加快掃描速度和防止掃描出來重復的問題。

這里著重說一下在gralde里寫命令的一個注意點,要執行帶有單引號的命令會執行為空的問題
譬如

git status  -s  | grep -v '^D'//列出當前要提交的commit變動了哪些文件并排除刪除的文件

你以為"git status -s | grep -v '^D'".execute就行了嗎,太天真了,執行結果為空,剛開始我以為只要加上轉義符就行,結果還是不行。后面反復實驗發現要這么寫

["/bin/bash", "-c", "git status  -s | grep -v '^D'"].execute()

增量代碼掃描具體實現

原理比較長,怕大家看的似懂非懂,我先給結果,這樣比較好。看到一些不明白的名詞可以先忽略掉,后面原理里面會提,我盡量講的淺顯易懂。
我寫了一個增量掃描的task,然后寫了一個LintClinet,這個LintClient會掃描代碼,它繼承android gradle的LintGradleClient,task會調用這個client的run方法,run方法就是掃描方法。
而增量掃描的關鍵性代碼是修改LintGradleClientcreateLintRequest方法,往project加入要掃描的文件

@Override
    protected LintRequest createLintRequest(@NonNull List<File> files) {
//注意這個project是com.android.tools.lint.detector.api.project
  LintRequest lintRequest = super.createLintRequest(files);
        for (Project project : lintRequest.getProjects()) {
                 project.addFile(changefile);//加入要掃描的文件
                addChangeFiles(project);
        }
     return lintRequest;
    }

有個注意點我要提一下
LintGradleClient構造函數需要參數,除了variant可以為空,其他都不能為空。因為不在android gradle插件內部,所以有些參數獲取需要動一些腦筋。

LintGradleClient(
             IssueRegistry registry,//掃描規則
            LintCliFlags flags,
            org.gradle.api.Project gradleProject,//gradle 項目
            AndroidProject modelProject,// android項目
            File sdkHome,// android sdk目錄
            Variant variant,//編譯的Variant
            BuildToolInfo buildToolInfo) {//編譯工具包

篇幅有限,參數講太多反而把大家搞糊涂,我就講一個參數,如何獲取AndroidProject

 private AndroidProject getAndroidProject() {
        GradleConnector gradleConn = GradleConnector.newConnector()
        gradleConn.forProjectDirectory(getProject().getProjectDir())
        AndroidProject modelProject = gradleConn.connect().getModel(AndroidProject.class)
        return modelProject
    }

增量代碼掃描原理分析

剛開始想的很簡單呀,命令行 Lint不是也能掃描代碼嗎,那里面肯定有指定掃描文件和目錄的參數吧,別說還真有, --sources <dir> ,結果一試,發現是有結果,但是掃描出來的問題根本不是那個文件的問題呀,然后我同事說在他電腦卻提示不能掃描gradle項目,一下子就蒙蔽了,無從下手的感覺,剛開始我以為命令沒用對,但是改來改去都不對,后面我嘗試去除里面的gradle project判斷限制,然后指定掃描文件,還是掃描不出該有的問題,我就先暫停這個方案的研究。

既然上面這條路走不通,我就去找android studio的源碼看他是怎么實現增量掃描的,結果在Android Studio源碼里面,搜索lint根本沒有找到任何相關的代碼,后面發現其實是在另外的Plugin源碼里。不過他依賴于Intellij Module,Module會找到每個類,那我又沒有Module這個上下文,這么說這個方案還是走不通。

那就再換一個思路,Android Gradle插件不是也可以實現Lint掃描,那我改一改不就可以增量掃描,結果一拿到他的代碼就感覺無從下手,改來改去都不對呀,不知道哪一行代碼可以實現增量掃描,就算后面完成了增量掃碼,掃描也很慢。

帶著上面的幾個坑,我研究了Lint內部的實現原理找到了增量代碼掃描的實現方法

  1. 為什么命令行Lint 掃描不出增量代碼的問題
  2. android studio是怎么實現lint增量掃描的

我先講一下關于Lint的預備知識,然后再來講上面幾個問題,方便大家更好理解

Lint掃描內部原理

其實無論是Lint命令行、android gradle插件、android studio都依賴了兩個jar

  • lint-api.jar:lint-api是代碼掃描的具體實現
  • lint-check.jar:lint-check是默認的掃描規則

lint-api.jar內部實現原理:
LintDriver調用analyze()分析LintRequest中的文件------>checkProject----->runFileDetectors----->check對應文件的Visitor,譬如JavaPsiVisitor分析java文件,AsmVisitor分析class文件等

下面講講三種方式分別怎么實現的

Lint命令行:
lint.sh------>lint.jar------>LintCliClient 的run(IssueRegistry registry, List<File> files)------>LintDriver analyze分析 project

Lint Gradle Task:
Lint.groovy------>LintGradleClient的run(IssueRegistry registry)------>LintDriver analyze分析 LintGradleProject

Android Studio:
AndroidLintGlobalInspectionContext------> performPreRunActivities-----> LintDriver analyze分析IntellijLintProject

明白了原理,我們回到上面兩個問題

  1. 為什么命令行Lint 掃描不出增量代碼的問題
    我舉個例子:
    譬如有個TestActivity里面寫了靜態的activity變量,LeakDetector會去檢查這個情況,但是直接lint --sources app/src/com/demo/TestActivity.java .你會發現掃描不出這個錯誤或者提示'app' is a Gradle project. To correctly analyze Gradle projects, you should run "gradlew :lint" instead. [LintError],其實這兩個問題都是同一個原因。
    LeakDetector會去判斷靜態變量是不是Activity類,但是變量的PsiField卻是com.demo.TestActivity不是'android'開頭,這樣就掃描不出問題了。
 @Override
        public void visitField(PsiField field) {
         String fqn= field.getType().getCanonicalText();
           if (fqn.startsWith("android.")) {//fqn變量是com.demo.TestActivity
                if (isLeakCandidate(cls, mContext.getEvaluator())
                        && !isAppContextName(cls, field)) {
                    String message = "Do not place Android context classes in static fields; "
                            + "this is a memory leak (and also breaks Instant Run)";
                    report(field, modifierList, message);
                }
            }
}

那為什么fqn不是android.app.activity呢,因為lint命令行會把lib目錄下面jar的class加入掃描形成抽象語法樹,但是gradle項目是compile jar的,不在lib目錄下面,這就是為什么高版本的lint里面提示不能掃描gradle項目。這也側面說明了命令行lint走不通

  1. android studio是怎么實現lint增量掃描的
    android studio內部會掃描IntellijLintProject中的文件,IntellijLintProject是由
    create(IntellijLintClient client, List<VirtualFile> files,Module... modules)生成的,那就只要找到文件加入project的代碼就能找到增量代碼掃描的方案了。
if (project != null) {
      project.setDirectLibraries(Collections.<Project>emptyList());
      if (file != null) {
        project.addFile(VfsUtilCore.virtualToIoFile(file));
      }
}

那為什么addfile以后LintDriver會增量掃描呢,拿java文件掃描舉個例子,LintDriver會判斷subset是不是為空,不為空就不掃描JavaSourceFolders,只掃描增量文件。

  List<File> files = project.getSubset();
                if (files != null) {//判斷是不是要增量掃描
                    checkIndividualJavaFiles(project, main, checks, files);
                } else {
                    List<File> sourceFolders = project.getJavaSourceFolders();
                    List<File> testFolders = scope.contains(Scope.TEST_SOURCES)
                            ? project.getTestSourceFolders() : Collections.emptyList();
                    checkJava(project, main, sourceFolders, testFolders, checks);
                }

只掃描優先級高的問題

雖然Lint支持配置lint.xml去忽略Issue,但是只能一個個忽略,我的方案是設置優先級低的規則為Severity.IGNORE,LintDirver會忽略Severity.IGNORE的規則

@Override
            public Severity getSeverity(Issue issue) {
                Severity severity = super.getSeverity(issue);
                if (onlyHighPriority) {
                    if (issue.getCategory().compareTo(Category.USABILITY) < 0 && issue.getPriority() > 4) {//只掃描優先級比較高的規則
                        return severity;
                    }
                    return Severity.IGNORE;
                }
                return severity;
            }

自動執行代碼掃描

Git Hooks提供了post-commit實現commit之后自動執行任務,但是你會發現在post-commit里寫 ./gradlew Lint,還是要等lint任務執行完了才commit成功。我發現只要在shell腳本里加入&>/dev/null就可以后臺執行了。

nohup ./gradlew  LintIncrement  &>/dev/null &

自動同步Git Hooks

如果Git Hooks腳本需要每臺電腦自己去復制,這明顯不利于團隊合作,而且不方便后面更新腳本,我選擇用Gradle命令復制到指定目錄,但是這里有個問題,gradle插件能帶資源文件嗎,如果沒有專門學過gradle說不定一時無從下手,還好我剛好以前看過fastdex里面是怎么解決的,通過getResourceAsStream可以復制Gradle插件resources下面的文件

public static void copyResourceFile(String name, File dest) throws IOException {
        FileOutputStream os = null;
        File parent = dest.getParentFile();
        if (parent != null && (!parent.exists())) {
            parent.mkdirs();
        }
        InputStream is = null;

        try {
            is = FileUtils.class.getResourceAsStream("/" + name);
            os = new FileOutputStream(dest, false);

            byte[] buffer = new byte[BUFFER_SIZE];
            int length;
            while ((length = is.read(buffer)) > 0) {
                os.write(buffer, 0, length);
            }
        } finally {
            if (is != null) {
                is.close();
            }
            if (os != null) {
                os.close();
            }
        }
    }

復制腳本installGitHooks是這樣實現的,finalizedBy保證它在build任務后面自動執行,它會把/resource/post-commit文件復制到工程.git/hooks/post-commit。chmod -R +x .git/hooks/一定要寫,不然沒有權限

private void createGitHooksTask(Project project) {
        def preBuild = project.tasks.findByName("preBuild")

        if (preBuild == null) {
            throw new GradleException("lint  need depend on preBuild and clean task")
            return
        }

        def installGitHooks = project.getTasks().create("installGitHooks")
                .doLast {
   
                    File postCommitFile = new File(project.rootProject.rootDir, PATH_POST_COMMIT)
                    if (lintIncrementExtension.isCheckPostCommit()) {
                      FileUtils.copyResourceFile("post-commit", postCommitFile)
                    } else {
                         if (preCommitDestFile.exists()) {
                             preCommitDestFile.delete()
                            }
                    }
                    Runtime.getRuntime().exec("chmod -R +x .git/hooks/")
                }

        preBuild.finalizedBy installGitHooks
    }

Gradle插件實現發送郵件

image.png

原來打算直接用shell腳本里面的sendmail去發送郵件的,但是聽同事說如果mac上沒有登錄郵箱是沒法發送成功的,我就用了javamail,網上的方案大多數是在java里面實現javamail,在gradle里面發送郵件的方案比較少,我嘗試了多次才解決。

首先在gradle插件的build.gradle里面加入javamail的依賴,剛開始我是直接compile了,但是運行以后提示我沒找到javamail的類,原來是要ant能找到javamail的類才行

configurations {
   antClasspath
}
dependencies {
   antClasspath 'ant:ant-javamail:1.+'
   antClasspath 'javax.activation:activation:1.1.1'
   antClasspath 'javax.mail:mail:1.+'
}
ClassLoader antClassLoader = org.apache.tools.ant.Project.class.classLoader
configurations.antClasspath.each { File jar ->
   antClassLoader.addURL( jar.toURI().toURL() )
}

然后在gralde里面執行發送任務

void send(File file) {
       getProject().ant.mail(
                from: fromMail,//  發件方
                tolist: toList,//收件方
                ccList: ccList,//抄送方
                message: message,//消息內容
                subject: subject,//標題
                mailhost: mailhost,//SMTP轉發服務器
                messagemimetype: "text/html",//消息格式
                files: file.getAbsolutePath()//發送文件目錄
        )
    }

這里有幾個注意點

  1. mailhost填入不需要SSL 認證的smtp服務器,不然你就需要輸入賬號和密碼才能發送郵件
  2. message里面換行,不能用\n,因為messagemimetype是html格式,要使用<br>

發現問題回滾代碼

        if (lintClient.haveErrors() ) {
          "git reset HEAD~1".execute(null, project.getRootDir())
        }

如何調試gradle 插件

我原來看了幾篇Lint原理分析就打算去實現增量掃描,然后發現看和做還是不一樣的,中間遇到好多問題,還好gradle插件可以調試。

第一步 點擊edit configurations

image.png

第二步 創建remote,默認選項就可以
image.png

第三步 在你要運行的gradle任務里面加入
-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=5005
image.png

第四步,先點擊運行你要運行的gradle任務,gradle會等待你點擊remote,然后就可以調試了

Lint版本變動

發現android gradle最新的幾個版本對于lint做了一些優化,我順便提一下。

  1. 2.3.0以后運行./gradlew lint會更快,Google實現了LintCharSequence來完成數據的存儲和傳參,實現了內存中只有一份拷貝
  2. 2.3.0以后lint-report.html是material design,更好看、更方便查問題
  3. 2.3.0以后支持baseline增量顯示bug
  4. 3.0.0以后自定義lint規則就不用像原來美團的方法一樣麻煩了,官方支持
  5. 掃描會更快,uast語法樹替換了現在的psi和lombok語法樹

尾聲

回過頭來看,其實增量掃描也很簡單,就一行關鍵性代碼project.addfile(file)

最后講一下大家關心的開源問題吧,那要等在公司內部穩定運行以后在公司Github地址開源,畢竟我們是一款嚴肅的產品嘛。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容