×

Android Lint增量扫描实战纪要

96
sunshine8
2017.11.28 17:01* 字数 3971

前言

先来说我为什么要做增量扫描这个事情,毕竟代码扫描已经老生常谈了,业界方案一搜一大堆,有什么好讲的,大部人看到这篇文章的时候肯定这么想吧,但是注意今天我要分享的不是全量扫描,我分享的是从无到有实现增量扫描的过程,有的时候实现一个方案从来不是重点,我们对于方案的认知程度才是我们自己最重要的收获 ̄▽ ̄ 。

再来说说怎么样的代码扫描才算是高效的,我是这么理解的:

不能增量检查的代码扫描都是耍流氓,以前的代码一大堆问题,谁有耐心全部去解决
不能自动化的代码扫描都是欺骗我们感情,不是每个人都有良好的意识每次都去检查的
不能撤销提交的代码扫描都是自己骗自己,检查出来问题不改,这样的代码扫描要来何用
不能持续集成的代码扫描都是不专业的,问题要快上线了才发现,这样的代码扫描风险多高
开发缺的从来就不是工具,我们缺的是无缝嵌入的自动化流程、自我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地址开源,毕竟我们是一款严肃的产品嘛。

Android最佳实践
Web note ad 1