在 DevOps 建设中有和没有,好用与不好用是四件事情
官网对 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