像做产品一样优化Lint工具

像做产品一样优化Lint工具

在 DevOps 建设中有和没有,好用与不好用是四件事情

image.png

官网对 Android Lint 的大致定义:基于代码静态扫描从分析代码结构来推断代码质量风险,并将风险按等级分类汇报给开发者进行优化。同时开发者自己可以自由定制规则与等级。

在 Lint 检测上,我们在 Android 侧建设的相对完善,所以本文主要针对 Android Lint 来展开分析。

首先,为什么需要用Lint:

在使用Lint前我们发现代码问题有两种方式分别是 Code Review 与线上 Crash 分析。

针对特例问题进行复盘总结,再进行最优解探索交流,最后沉淀为代码规范。但随着项目增多,团队人员增多,问题积累速度变快,而掌握规范的速度因人而异,制定规范与掌握规范逐渐变成了两件无法形成闭环的事情。

基于这样的背景,Lint建设被我们正式搬上了舞台,将Lint作为团队经验沉淀与辅助实践的主要工具。

在写代码与提交代码的时候就告诉开发小伙伴哪些代码需要优化,比人工1vN反复沟通快速高效许多。

本地检查实践效果:

同时我们也在 CI 上增加了 Lint 流水线,作为代码合并的卡点,作为对“坏”代码上线的最后一道防线,未通过 Lint 检测的代码将无法合并。

CI 卡点实践效果:

如果检测不通过,相关开发小伙伴将会在办公 IM 软件上收到通知:

其实在去年我们已经做了一些探索,比如AS内置的 Lint 配置参数,Lint Task 源码,Lint 的自定义规则开发,自定义扫描范围探索,git hook 的应用。这些前期的探索对今年设计完整的 Lint 解决方案打下了基础。

Lint 可围绕哪些方向进行检测

潜在 Crash

首当其冲当是检测 Crash 相关的问题,即会导致 Crash 的风险代码,他往往较为简单,99%的时间都不会错。正是因为如此,我们往往会忽略。比如:

  • parseColor,parseInt 等方法调用需要包裹 try-catch

这些方法本身并不需要捕获异常,但其内在异常时会抛出 IllegalArgumentException ,这是一个 RuntimeException 类型的异常导致程序崩溃。

  • 作为接受网络数据的 data class 的属性必须设置非空并赋默认值

在解析网络数据时如果数据中未包含 data class 的某个字段值,虽然声明为非空,但在访问时未赋默认值的字段依然会诱发NPE。(详细可分析 Gson 用到的 Java UnSafe)

性能与稳定性

  • 禁止直接使用 new Thread()

滥用的 Thread 是首要关注的,那些未被管理的野线程可以轻易让项目失去优化的空间。

为此我们为 kotlin 项目提供了公共协程域,java项目提供了公共线程池。

  • 禁止使用 Toast.makeText()

基础库提供了统一的 Toast 管理类,统一适配并杜绝未受控制的连弹,也保证重要的 Toast 优先弹出

  • 图片适配按文件夹与尺寸限制不同文件夹下的图片体积大小

代码规范

  • 禁用 android.util.Log
  • 文件命名(特定包下的文件名,资源命名,View Id 命名 等等)
  • try-catch 必须调用 e.printStackTrace()
  • 禁止使用 OnClickListener 使用 OnShakeLessClickListener

上述只是一些简单的例子,日常开发中积累的规范与经验数不胜数。只要是从静态分析上可以检测,都可以尝试用Lint来检测。

了解 Android 官方 Lint 生态

Lint 配置:

lintOptions {
disable("GradleDependency")
    checkOnly(*lintIds)
    lintConfig = file("lint.xml")
    isAbortOnError = properties["lint.isAbortOnError"] ?: "false" == "true"
    isCheckDependencies = false
    textReport = false
    xmlReport = true
    htmlOutput = file("lint-result.html")
    xmlOutput = file("lint-result.xml")
    baselineFile = file("base_line.xml")
} 
复制代码

已经支持的几个重要功能:

  • 支持配置定义规则
  • 按 base_line 忽略部分文件
  • 碰到 error 级别错误退出编译进程
  • 调整 Lint 错误等级
  • 启用或关闭指定的 Lint 检测器

这些基础功能非常重要但是实际应用中都存在一定的局限性,导致整个体验与效率大打折扣。

用 base_line 举例,默认的 lint task 在执行时都是 对当前 git 分支下的文件进行全量扫描,再过滤掉 base_line 中的错误后输出报告。(它只对错误汇报进行白名单过滤不是基于文件范围的)

我们的实际场景是这样的:

1.未改动过又运行良好的历史代码没必要全面进行清理,这对产品稳定性,对测试回归工作都是ROI很低的。所以基于某个 git 分支版本 diff 出改动或新增的代码进行扫描更为合适。

2.某一天改动到某个历史文件了,我们希望这个文件能被完整扫描,里面的错误不要被忽略,以此文件为单位进行小范围的清理。

3.某些文件需要被长期忽略,当一次提交中只包含此类文件则不执行 lint task 节省时间。

诸如此类的小细节还有很多,又恰恰是这些小细节堆积起来导致原生 Lint 不能很好的适应我们实际的开发环境。

这三点和一起其实就需要一个功能点:自定义文件范围进行扫描(也可以叫增量扫描但不是特别恰当)

于是我们决定给予原生 Lint 做一波自定义,搞一个理想型的 Lint 工具。

Lint 应用实践

自定义 Lint 的扫描范围

虽然除了 base_line 这样的方式外官方没有提供可以支持进行自定义文件的设置,但是作为静态扫描工具,IDEA 已经实现了这一步,平时在写代码的时候新增的文件或修改都能及时提醒,猜测已经是自定义范围的了。这一个路子,但是由于“下刀”缺乏经验,没有花费太多时间。

第二种是瞄准 Gradle Task,从源码上看看能不能修改扫描的文件范围。调研也发现已经有部分开源项目分享可以借鉴,这为我们省去了不少“摸石头”的时间。

分析自定义扫描范围的切入点

从 gradlew lint task 开始追溯源码能看到这样的调用逻辑:

LintPerVariantTask:lint()>
LintBaseTask:runLint()>
ReflectiveLintRunner.runLint()>
LintGradleExecution:analyze():lintAllVariants():runLint()>
LintGradleClient:run()>
LintCliClient:run()>
LintDriver:analyze():checkProject():runFileDetectors()
复制代码

在 runFileDetectors 中可以看到两段相似的代码(在 studio 内执行与非 studio 内执行都用到了同样的逻辑)。

val files = project.subset
val uastRequest = if (files != null) {
    findUastSources(project, main, emptyList(), files)
} else {
    findUastSources(project, main, emptyList())
}
prepareUast(uastRequest)
复制代码

这段代码是针对指定扫描范围进行判断的,subset 就是一个 files 集合。如果指定过文件则不会进行全局扫描。查看 files 的引用就会发现在project 有一个 addfile 就是向 files内添加文件的。到这里问题似乎就简单了,只要想办法在扫描前给 subset 这个集合添加我们需要的文件清单即可。

配置自定义文件清单进行扫描

LintCliClient 提供了configureLintRequest 方法LintGradleClient 已经做了默认实现来支持针对动态编译的模块配置,为 LintRequest 配置好了模块的project。那只需要在处理的最后为 project 添加我们的清单文件即可。

val checkFileList = ** //扫描文件
lintRequest.getProjects()?.forEach { p ->
checkFileList.forEach {
p.addFile(it)
    }
} 
复制代码

这样在运行 Lint 的时候就只会扫描指定的文件。

这里遍历 LintRequest.getProjects 感觉很奇怪,但其实通常都只有一个,就是触发 Lint 任务的那个 project

需要哪些维度的自定义扫描范围

1).在 CI 上 触发 Lint 扫描时,需要避免文章开头提到过历史代码总是被检测的情况,避免过低的 ROI,那最好是能扫描指定时间范围内的文件。

那么针对代码合并的扫描范围就是 feature(开发分支) 分支与 master 分支的差异了。接下来就是借助 Git 命令行只需两步便能获取到这部分差异的文件清单。

先读取远端 master 分支的最后一个 commitId

//伪代码
val commitId = setCommandLine("git", "log", "origin/master", "--pretty=%h", "--max-count=1")
复制代码

取出Diff文件清单

//伪代码
val checkFileList = setCommandLine(
    "git",
    "diff",
    commitId,
    "HEAD",
    "--name-only",
    "--diff-filter=ACMRTUXB"
)
复制代码

将过滤出的文件清单集合添加到 subset 中即可。

2).上一个方案是在 CI 上运行的,发现问题的流程与时间都长一些,那能不能在本地提交代码的时候进行增量扫描呢,只检测目前新增的改动代码。

其实与上面是同样的逻辑,使用 Git 命令便可读取改动记录。将这些记录中的文件清单添加到 subset即可。

//伪代码
val fileList = setCommandLine("git", "diff", "--cached", "--name-only", "--diff-filter=ACMRTUXB")
复制代码

检查时机

检查时机的思考主要是平衡应用的执行效果与体验。我们期望 Lint 的执行尽量越无感越好,避免打断开发的工作节奏。

我们选定了两个节点,分别针对本地与远端:

本地检测不通过不可 commit 代码

借助 Git hooks 中的 pre-commit,可以在代码提交时运行 Lint 检查 Task

图中是一段固定的脚本代码,在我们的 CI 插件装载时会自动将一份 pre-commit 拷贝进 .git/hooks 目录。

这个任务将在后台执行,在执行结束如果检测未通过将会在编辑窗口弹出一个窗口作为提醒,提供一键打开 lint 报告。

表面上不会打断开发的节奏,但经过多人次体验。在本地跑 Lint 时间开销还是较大,尤其是部分小的 commit 其实不需要过 Lint 的。通常本地 commit 触发 pre-commit 脚本执行到 Lint 到出报告最少需要 15s左右。多大算大呢,由于是在本地等待,其实是1s都不想等呀。

一开始我们觉得这是一个刚需,实际体验并不理想。首先本地有 Lint 缓存的,写代码时已经有实时提醒了。第二个便是每次执行 Lint Task 整个 Lint 都需要重新装载(buildSrc 编译,加载插件,lint 编译等任务,最好才是扫描,扫描本身是非常快的),这样会增加较多的等待时间。

最后我们虽然完整的开发了这个功能,但是并没有使用太长时间就暂停了。

CI 卡点:提交代码合并时未通过检测不可合并

作为代码质量守卫的最后一道防线,为了保证执行效率,最大程度降低对人的依赖。能自动化100%执行是必不可缺的。在 CI 上我们单独为代码合并阶段设置卡点的流水线,保证每一次可合并的代码一都是通过 Lint 检测的。

配置流水线是简单的,在 CI 环节中加一个 Lint 容器关联到代码分支管理上,因为整个 Lint Task 的执行 都是可定制的这一步相对便捷。

更重要的是如何保证检查规则具备自动更新与容灾能力。

按照每周会新增1-2个规则的节奏,期望这些规则在测试通过后能立即被使用,而不需要开发人员手动更新版本,能达到即发即用。

前文提到在 moduel 想要应用自定义规则需要依赖 lintWrapper ,那么将此库改为远端依赖,在 CI 编译时能自动检索最新版本并下载就能自动完成规则更新。

使用 maven 的检索规则 latest.release 就能很好的达成这一效果。

project.dependencies.add("implementation", "com.mj.lint:lint-wrapper:latest.release")
复制代码

与之相悖的便是代码写法千万种,Lint 规则难免有没考虑到的测试场景。这时候检测往往长时间都过不去,会直接影响代码合并,阻塞产品发布。需要能动态控制检测规则的开关,能快速针对异常 Lint 进行下线处理。

在原生 LintOptions 中,checkOnly 与 disable 函数都支持对检测规则进行控制,这对自定义规则同样适用,只需要找找到动态更新的时机修改即可。

打通办公协作

在自定义的LintPerVariantTask 中我们能获取到 android 闭包的属性,这其中也包括 lintOptions。配置文件我们会上传到云端,在 CI 插件中保留一个加载链接,这样每次启动都会加载这个配置文件,解析后设置给 lintOptions 。

打通办公协作

到这一步,也就是到了整个 Lint 扫描工具工作的最后一环了,将扫描报告自动发送给相关开发,就像测试提 Bug 之后会给开发发邮件一样。

但使用邮件显得有点重了,如果我们集中发到一个群里并 @ 相关点开发,那这样负责 code review 的人也能看到,同时大家可以基于一个消息样本进行反馈。

这里需要处理3个问题:

1.将 Lint 扫描产生的静态报告上传至云端并得到访问链接。

2.统一了大家的 git user.name 的规则,全部使用拼音,与企业 IM 对齐。在 CI 插件中抓取 git 中的 author 获取用户名。

3.利用企业 IM 的 Webhook 发送消息并 @ 相关开发。

这样在群里就会收到通知消息:

什么时候做 Lint 比较合适

我总结了以下两点经验:

1.追求好代码的团队氛围,如果团队对好代码没有持之以恒的要求,明确清晰的好代码规范积累,那做 Lint 除了刷 KPI 没有太大价值,甚至会成为影响团队和谐的桎梏。

2.人数,项目个数,项目生命周期长短。这是三个需要考虑的维度,Lint 本身需要长期积累更新才能体现其价值。

总结

应用 Lint 已经有一段时间了,针对低级错误,风险代码,老代码检测,重复性问题尤其有效。同时我们也在尝试用数据分析来体现这一效果,我们会按季度将全部的 Lint 的报告抓出来分析,比如哪些 Issue 是高频哪些是低频等等(偷笑...)

END


原稿:LJJ 整理:ZSW

参考资料

分类:
开发工具
标签: