Android Lint增量扫描实战纪要

时间:2022-01-20 16:25:49

Android Lint增量扫描实战纪要

作者 | sunshine8

地址 | http://www.jianshu.com/p/4833a79e9396

声明 | 本文是 sunshine8 原创,已获授权发布,未经原作者允许请勿转载



前言

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


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


不能增量检查的代码扫描都是耍流氓,以前的代码一大堆问题,谁有耐心全部去解决

不能自动化的代码扫描都是欺骗我们感情,不是每个人都有良好的意识每次都去检查的

不能撤销提交的代码扫描都是自己骗自己,检查出来问题不改,这样的代码扫描要来何用

不能持续集成的代码扫描都是不专业的,问题要快上线了才发现,这样的代码扫描风险多高

开发缺的从来就不是工具,我们缺的是无缝嵌入的自动化流程、自我Code Review的意识,意识比工具重要。


这里扯了一些大道理,大家谅解,口号喊得响,大家才有兴趣看嘛。后面全是干货,大家放心,嘿嘿。


方案介绍

OkLint作为一个Gradle插件,使用起来超简单,他能在你提交时发现增量问题,撤销提交并给你发邮件。

在根目录下的build.gradle写

allprojects {
   apply plugin: 'oklint'
}


方案思考

在讲具体实现之前,先来讲讲我对于高效的代码扫描是怎么想的。


高效的代码扫描我觉得有五个方案:


  • 方案一是Android Studio自带的错误提示功能,他有个好处就是实时发现问题,缺点就是有些问题隐藏在花花绿绿的代码里,你要指定你想检查的问题为error才能暴露出来,这样就需要在每台电脑上都改动一下,太麻烦了。


  • 方案二是Android Studio的增量代码扫描功能,缺点就是不能自动化,不能在团队内很好落实,不利于统计问题和持续集成。


Android Lint增量扫描实战纪要


  • 方案三是用Sonar持续集成,但是他有个问题是不能增量,我们团队用过,最后因为以前问题太多根本推行不起来,相信好多团队都是这样吧。


  • 方案四是用Android Gradle插件2.3.0以后提供的新功能 baseline,他也是全量扫描,但是他能增量显示问题,这个方案后期和Sonar持续集成,可以作为Plan B。


  • 方案五是我现在用的方案,增量代码扫描和git hooks搭配使用,味道更好。刚开始的思路是在git commit之前扫描增量代码,结果发现lint扫描比较慢(我尝试改了,改了以后确实快了但是有些问题就扫描不到了,毕竟扫描代码还是需要整个项目的代码才能更好的找到问题)。后面我听取了同事峰哥的意见,采用另外一个思路,偷偷在git commit之后去扫描。有些人要问了为什么不在gitlab上的webhook里面执行,嗯你很机智,这样实现也有很大的优点,但是我更想及时检查每一次改动,越早发现越好解决问题。


个人觉得上面五个方案,方案四和方案五双管齐下效果更好。方案五负责及时检查每一次改动,方案四负责发现全量代码潜在的问题。


方案对比

方案做出来了,要是不对比一下,就没办法愉快地吹NB了。

Android Lint增量扫描实战纪要

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方法就是扫描方法。


而增量扫描的关键性代码是修改LintGradleClient的createLintRequest方法,往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插件实现发送邮件

Android Lint增量扫描实战纪要

原来打算直接用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

Android Lint增量扫描实战纪要


第二步 创建remote,默认选项就可以

Android Lint增量扫描实战纪要


第三步 在你要运行的gradle任务里面加入
-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=5005

Android Lint增量扫描实战纪要


第四步,先点击运行你要运行的gradle任务,gradle会等待你点击remote,然后就可以调试了


Lint B版本变动

发现android gradle最新的几个版本对于lint做了一些优化,我顺便提一下。


2.3.0以后运行./gradlew lint会更快,Google实现了LintCharSequence来完成数据的存储和传参,实现了内存中只有一份拷贝

2.3.0以后lint-report.html是material design,更好看、更方便查问题

2.3.0以后支持baseline增量显示bug

3.0.0以后自定义lint规则就不用像原来美团的方法一样麻烦了,官方支持

扫描会更快,uast语法树替换了现在的psi和lombok语法树


尾声

回过头来看,其实增量扫描也很简单,就一行关键性代码project.addfile(file)

最后讲一下大家关心的开源问题吧,那要等在公司内部稳定运行以后在公司Github地址开源,毕竟我们是一款严肃的产品嘛。

Android Lint增量扫描实战纪要


与之相关

微信 Tinker 在 Android 中集成以及使用

Android•Lottie 动画库填坑记



Android Lint增量扫描实战纪要