Android Lint系列(三):应用方案

Lint规则

Lint规则有Fatal、Error、Warning、Information等级别,自定义Lint主要使用Error和Warnning。考虑到实际实施,侧重于检查明确需要解决的Error级别问题,通过技术手段强制要求所有开发人员遵守;对于优先级较低、技术上难以明确判断是否需要解决的Warnning级别问题,则适当放松要求。

  • Error级别:包括Crash、严重性能问题、不符合代码规范等,必须修复。
  • Warnning级别:用于代码编写建议、潜在BUG提醒、无关紧要的优化等,适当放松要求。

Crash预防

Lint检查最重要的应用是Crash预防。一是因为Crash率是App最重要的指标之一,二是因为Lint很适合防治一些Crash问题。

例如:

  • 原生的NewApi,用于检查代码中是否调用了Android高版本才提供的API。

  • 自定义的SerializableCheck。实现了Serializable接口的类,成员变量没有实现Serializable,会导致序列化时Crash。我们制定一条代码规范,要求实现了Serializable的类,其声明的成员变量(包括从父类继承的)都要实现Serializable。

    可以设置一些白名单类型,例如数据Model中允许成员变量申明成Collection和Map(或其子类)。主要是考虑到用的场景比较多,实际上赋值一般也都是实现了Serializable的ArrayList和HashMap,所以做折中处理。

Bug预防

有些Bug可以通过Lint检查来预防。不过Bug往往涉及业务逻辑,其定义比较模糊,检查比较困难,更多时候只能起到提醒的作用。

例如:

  • SpUsage:要求所有SharedPrefrence读写操作都要使用共通工具类,工具类中进行各种异常处理;同时定义SPConstants常量类,所有SP的Key都要在这个类定义,避免在代码中分散定义的Key之间冲突。

  • ImageViewUsage:检查ImageView有没有设置ScaleType,加载时有没有设置Placeholder。

  • TodoCheck:检查代码中是否还有TODO没完成。例如开发时可能会在代码中写一些假数据,但最终上线时要确保删除这些代码。这种检查项比较特殊,通常可以在开发完成后提测阶段才检查。

性能/安全问题

  • ThreadConstruction:禁止直接使用new Thread()创建线程,而需要使用统一的工具类,在公用的线程池执行后台操作。

  • LogUsage:禁止直接使用android.util.Log,必须使用统一工具类,控制Release包不输出Log,提高性能,也避免发生安全问题。

代码规范

除了代码风格方面的约束,代码规范更多的是用于减少或防止发生BUG、Crash、性能、安全问题等。很多问题在技术上难以直接检查,但可以通过封装统一的基础库、制定代码规范的方式间接解决,而Lint检查则用于减少组内沟通成本、新人学习成本,并确保代码规范的落实。

例如:

  • 前面提到的SpUsage、ThreadConstruction、LogUsage等。

  • ResourceNaming:资源文件命名规范,防止不同模块之间的资源文件名冲突。

Lint检查时机

Lint检查可以在多个阶段执行。有两个目标:

  • 高优先级问题希望通过技术手段进行约束,强制要求所有开发人员遵守;
  • 有些问题希望做到在第一时间发现,从而减少其带来的风险或损失。

有些Lint问题发现的越早越好。例如使用了Android高版本API,在低版本设备上可能发生Crash,通过原生的NewApi可以检查出来。如果在开发期间发现,当时就可以考虑其他技术方案,实现困难时可以及时和产品、设计人员沟通;而如果到提代码、提测,甚至发版、上线时才发现,可能为时已晚。

因此,除了确定要检查哪些问题,何时、通过什么样的技术手段来执行代码检查也很重要。

手动执行

手动执行简单易用,但缺乏强制性,容易被开发者遗漏。

在Android Studio中,自定义Lint可以通过Inspections功能(Analyze - Inspect Code)手动运行。

在Gradle命令行环境下,可直接用./gradlew lint执行Lint检查。

编码阶段实时检查

编码时检查即在Android Studio中写代码时实时报错。这种方式的好处很明显,开发者可以第一时间发现代码问题。其缺点在于,受限于Android Studio对自定义Lint的支持不完善,不同开发人员IDE的配置,以及需要开发者主动查看报错并修复,不能确保高优先级问题都会被解决。

Android原生的Lint规则已经通过Inspections功能被集成到Android Studio中,因此可以直接实时检查并报错,开发过程中经常看到的一些Android API相关的Warning和Error就是这样实现的。

对于自定义的Lint规则,官方似乎没有给出明确的说明,但实际研究发现,在Android Studio 2.2+版本和基于JavaPsiScanner开发的条件下,IDE也会尝试加载并实时执行自定义Lint规则。

技术实现相关:

  1. 在Android Studio 2.x版本中,菜单Preferences - Editor - Inspections - Android - Lint - Correctness - Error from Custom Lint Check(avaliable for Analyze|Inspect Code)中指出,自定义Lint只支持命令行或手动运行,不支持实时检查。

    Error from Custom Rule When custom (third-party) lint rules are integrated in the IDE, they are not available as native IDE inspections, so the explanation text (which must be statically registered by a plugin) is not available. As a workaround, run the lint target in Gradle instead; the HTML report will include full explanations.

  2. 在Android Studio 3.x版本中,可以看到自定义Lint会被直接加载到设置中的Lint列表里,和原生Lint效果相同。

  3. 实际分析自定义Lint的IssueRegistry.getIssues()方法调用堆栈,可以看到Android Studio环境下,是由org.jetbrains.android.inspections.lint.AndroidLintExternalAnnotator调用LintDriver加载执行自定义Lint规则。

    参考代码: https://github.com/JetBrains/android/tree/master/android/src/org/jetbrains/android/inspections/lint

本地编译时自动检查

通过配置Gradle脚本,可以在本地编译Android工程时进行Lint检查。好处是既可以尽早发现问题,又可以强制检查高优问题;缺点是对本地编译速度有一定的影响。

在Android Studio中编译项目执行的Gradle Task是assemble,可通过下面的脚本,让assemble任务依赖lint任务,每次本地编译时检查Error级别Lint问题,发现未解决的问题则直接编译失败。

对于Android Application工程(APK):

1
2
3
4
5
6
android.applicationVariants.all { variant ->
variant.outputs.each { output ->
def lintTask = tasks["lint${variant.name.capitalize()}"]
output.assemble.dependsOn lintTask
}
}

对于Android Library工程(AAR):

1
2
3
4
5
6
android.libraryVariants.all { variant ->
variant.outputs.each { output ->
def lintTask = tasks["lint${variant.name.capitalize()}"]
output.assemble.dependsOn lintTask
}
}

本地commit时检查

利用git pre-commit hook,可以在本地commit代码前执行Lint检查,检查不通过则无法提交代码。这种方式的优势在于不影响开发时的编译速度,但发现问题相对有些滞后。

技术实现方面,可以编写Gradle脚本,在每次同步工程时自动将hook脚本从工程拷贝到.git/hooks/文件夹下。

提代码时CI检查

作为代码提交流程规范的一部分,发Pull Request提代码时用CI系统检查Lint问题是一个常见、可行、有效的思路。可配置CI检查通过后,代码才能被合并。

CI系统常用Jenkins。如果使用Stash做代码管理,可以在Stash上配置Pull Request Notifier for Stash插件,或在Jenkins上配置Stash Pull Request Builder插件,实现发Pull Request时执行Job,从而执行Gradle任务检查Lint问题。

在本地编译和CI系统中做代码检查,都可以通过执行Gradle的Lint任务实现。在CI环境下可以给Gradle传递一个StartParameter,Gradle脚本中如果读取到这个参数,则配置LintOptions检查所有Lint问题;否则在本地编译环境下只检查部分高优先级Lint问题,减少对本地编译速度的影响。

打包发布时检查

即使每次提代码时用CI系统执行Lint检查通过,仍然不能保证所有人的代码合并后一定没有问题;另外对于一些特殊的问题,例如前面提到的TodoCheck,还希望在更晚的时候检查。

于是在CI系统打包发布APK/AAR用于测试或发版时,还可以对所有代码再做一次Lint检查,确保万无一失。

方案确定

综上,可选择结合多种方式做代码检查:

  1. 编码阶段IDE实时检查,第一时间发现问题
  2. 本地编译时,及时检查高优先级问题
  3. 提代码时,CI检查所有问题
  4. 打包阶段,完整检查工程

自定义Lint代码管理

自定义Lint的源码可以直接在实际项目中新建模块实现,不需单独打包、管理方便;更常见的方式是作为一个独立的工程来开发,最终生成AAR发布到Maven仓库,AAR中包含lint.jar。被检查的Android工程依赖这个AAR,即可使用自定义Lint规则。

Lint AAR嵌套依赖问题

有时可能会有一套自定义Lint规则专用于某个Library,如果直接让Library依赖LintAAR,会导致依赖这个Library的其他工程也会引入这套Lint规则。

解决方法:依赖这个Library时,用exclude去掉其对LintAAR的依赖。

1
2
3
4
5
6
7
dependencies {
// 依赖某个带有专用Lint规则的Library
compile 'com.demo.librarywithlint:library:1.0' {
// 排除对自定义Lint的依赖
exclude group: 'com.demo.lint', module: 'customlint'
}
}

配置文件支持

自定义Lint通常虽然是一个独立工程,但往往又和被检查Android工程中的代码规范、封装的基础库等关系密切。

有时希望在不修改Lint工程的情况下,在被检查工程中对Lint规则进行一些配置(例如资源文件命名的检查,配置文件名要满足的正则表达式);另一方面,也希望同一套Lint代码能用于多个Android工程,甚至在多个团队之间共享和复用,通过配置文件定义其中不同的部分(例如LogUsage检查,同样是禁用Android自带的Log,不同工程中封装的Log工具类不同,于是报错时的提示信息也不一样)。

配置文件可以是任意格式,放在被检查Android工程目录下的特定路径,例如custom-lint-config.json

以LogUsage为例,Android工程A的配置文件可能是:

1
2
3
{
"log-usage-message": "请勿使用android.util.Log,建议使用LogUtils工具类"
}

而Android工程B的配置文件是:

1
2
3
{
"log-usage-message": "请勿使用android.util.Log,建议使用Logger工具类"
}

技术实现上,可在Lint代码的Scanner回调方法中,从传入的Context对象获取被检查工程目录,从而读取配置文件。关键代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import com.android.tools.lint.detector.api.Context;

public final class LintConfigManager {
private static LintConfigManager sInstance;

public static LintConfigManager getInstance(Context context) {
// 单例...
}

private LintConfigManager(Context context) {
File projectDir = context.getProject().getDir();
File configFile = new File(projectDir, "custom-lint-config.json");
if (configFile.exists() && configFile.isFile()) {
// 读取配置文件...
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class LogUsageDetector extends Detector implements Detector.JavaPsiScanner {
// ...

@Override
public List<String> getApplicableMethodNames() {
return Arrays.asList("v", "d", "i", "w", "e", "wtf");
}

@Override
public void visitMethod(JavaContext context, JavaElementVisitor visitor, PsiMethodCallExpression call, PsiMethod method) {
if (context.getEvaluator().isMemberInClass(method, "android.util.Log")) {
// 从配置文件获取Message
String msg = LintConfigManager.getInstance(context).getConfig("log-usage-message");
context.report(ISSUE, call, context.getLocation(call.getMethodExpression()), msg);
}
}
}

模板Lint规则

对一些相似的Lint规则,可以实现一个模板,直接在被检查工程中直接通过配置文件配置,而不需要在Lint工程中开发。

如下为一个配置文件示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
{
"lint-rules": {
"deprecated-api": [{
"method-regex": "android\\.content\\.Intent\\.get(IntExtra|StringExtra|BooleanExtra|LongExtra|LongArrayExtra|StringArrayListExtra|SerializableExtra|ParcelableArrayListExtra).*",
"message": "避免直接调用Intent.getXx()方法,特殊机型可能发生Crash,建议使用IntentUtils",
"severity": "error"
},
{
"field": "java.lang.System.out",
"message": "请勿直接使用System.out,应该使用LogUtils",
"severity": "error"
},
{
"construction": "java.lang.Thread",
"message": "避免单独创建Thread执行后台任务,存在性能问题,建议使用AsyncTask",
"severity": "warning"
},
{
"super-class": "android.widget.BaseAdapter",
"message": "避免直接使用BaseAdapter,应该使用统一封装的BaseListAdapter",
"severity": "warning"
}],
"handle-exception": [{
"method": "android.graphics.Color.parseColor",
"exception": "java.lang.IllegalArgumentException",
"message": "Color.parseColor需要加try-catch处理IllegalArgumentException异常",
"severity": "error"
}]
}
}

示例配置中定义了两种类型的模板规则:

  • DeprecatedApi:禁止直接调用指定API
  • HandleException:调用指定API时,需要加try-catch处理指定类型的异常

问题API的匹配,包括方法调用(method)、成员变量引用(field)、构造函数(construction)、继承(super-class)等类型;匹配字符串支持glob语法或正则表达式(和lint.xml中ignore的配置一致)。

实现方面,主要是遍历Java语法树中特定类型的节点并转换成完整字符串(例如方法调用android.content.Intent.getIntExtra),然后检查是否有模板规则与其匹配;匹配成功后,DeprecatedApi规则直接输出message报错;HandleException规则会检查匹配到的节点是否处理了特定Exception(或Exception的父类),没有处理则报错。

按git版本检查新增文件

有时新增一些低优先级Lint规则,但已经有很多历史代码用了,且没有导致明显问题。例如新增代码规范,要求使用统一的线程工具类而不允许直接用Handler以避免内存泄露,这时如果接入禁用Handler的Lint检查,就需要修改所有历史代码,成本较高而且有一定风险。

一个折中的方案是,只检查指定git commit之后新增的文件。在配置文件中添加两个配置项,git-base指定commit ID;git-based-issues指定哪些Issue要按git版本检查。也可以更细化一些,给每个Issue配置不同的git-base

获取Git新增文件

获取新增文件有两种方法如下。推荐使用方法二,因为每次检查通常只读一次文件列表(提高性能),如果用方法一读完文件之后新增的文件检查不出来。

  1. 用diff查看当前和指定commit之间的新增文件
  2. 获取指定commit已有文件,剩下的就是新增文件了。

获取git工程根目录的路径:可在工程目录下执行如下指令(被检查的工程目录例如app包含在git工程根目录下)。

1
2
$ git rev-parse --show-toplevel
/Users/jzj/AndroidStudio/AndroidLint

获取指定commit后新增文件:可执行如下命令。

1
2
3
4
5
6
7
8
9
# 新增且从没被git追踪的文件(untracked files),diff命令默认不会显示,
# diff前要先让git记录可被add的文件(record only that path will be added later)
$ git add --intent-to-add .

# `--diff-filter=A`指定仅输出新增文件,`--name-only`指定只输出文件名,
# 输出的文件名均为相对git工程根目录的路径
$ git diff 6f00672 --diff-filter=A --name-only
app/src/main/java/com/paincker/lint/demo/AddedFile1.java
app/src/main/java/com/paincker/lint/demo/AddedFile2.java

获取指定commit已有文件列表:可在工程目录下执行如下命令。

1
2
3
4
5
6
7
8
9
10
11
12
13
# `--full-tree`指定输出git工程所有文件而不是当前目录下的文件,
# `--full-name`指定输出路径是相对git工程根目录而不是当前目录,
# `--name-only`指定只输出文件名,`-r`指定递归子目录。
$ git ls-tree --full-tree --full-name --name-only -r 6f00672
.gitignore
app/.gitignore
app/build.gradle
app/proguard-rules.pro
app/src/main/AndroidManifest.xml
app/src/main/java/com/paincker/lint/demo/MainActivity.java
app/src/main/res/layout/activity_main.xml
app/src/main/res/mipmap-hdpi/ic_launcher.png
……

获取到文件列表的字符串后,将其按行分割并保存到HashSet中。如果只考虑Java和XML文件,也可以用grep过滤掉其他文件。

判断是否要检查

按git版本检查主要是处理Java文件和XML文件。在JavaPsiScanner.visitMethod()/XmlScanner.visitElement()等方法中调用LintConfig.shouldCheckFile(xxx)方法,通过Context.getLocation(node).getFile()获取PsiElement/Node所在文件,判断是否在指定commit的已有文件列表中,从而决定要不要检查。

关键代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
import com.android.tools.lint.detector.api.Context;
import com.android.tools.lint.detector.api.Issue;
import com.android.tools.lint.detector.api.JavaContext;
import com.android.tools.lint.detector.api.Location;
import com.android.tools.lint.detector.api.XmlContext;
import com.intellij.psi.PsiElement;
import org.w3c.dom.Node;

// ...

public final class LintConfig {

/**
* git工程文件夹,末尾包含separator,例如"/usr/project/"
*/
private String mGitDir;
/**
* git历史文件
*/
private HashSet<String> mOldFiles;
/**
* 需要区分git版本的Issue
*/
private HashSet<String> mGitBasedIssueSet;

// ...

public boolean shouldCheckFile(XmlContext context, Issue issue, Node node) {
if (context == null || issue == null || node == null || mOldFiles == null || mOldFiles.isEmpty()
|| mGitDir == null || mGitBasedIssueSet == null || !mGitBasedIssueSet.contains(issue.getId())) {
return true;
}
Location location = context.getLocation(node);
return shouldCheckLocation(location, mGitDir, mOldFiles);
}

public boolean shouldCheckFile(JavaContext context, Issue issue, PsiElement node) {
if (context == null || issue == null || node == null || mOldFiles == null || mOldFiles.isEmpty()
|| mGitDir == null || mGitBasedIssueSet == null || !mGitBasedIssueSet.contains(issue.getId())) {
return true;
}
Location location = context.getLocation(node);
return shouldCheckLocation(location, mGitDir, mOldFiles);
}

private boolean shouldCheckLocation(Location location, @NonNull String gitDir, @NonNull HashSet<String> oldFiles) {
// path = "/usr/project/Test.java"
// gitDir = "/usr/project/"
// relative = "Test.java"
String path = location.getFile().getAbsolutePath();
int len = gitDir.length();
if (!path.startsWith(mGitDir) || path.length() <= len) {
return true;
}
String relative = path.substring(len);
return !oldFiles.contains(relative);
}
}

参考资料与扩展阅读

  • 使用 Lint 改进您的代码

https://developer.android.com/studio/write/lint.html

  • AndAndroid Plugin DSL Reference:LintOptions

http://google.github.io/android-gradle-dsl/current/com.android.build.gradle.internal.dsl.LintOptions.html

  • How Do We Configure Android Studio to Run Its Lint on Every Build?

http://stackoverflow.com/questions/32631131/how-do-we-configure-android-studio-to-run-its-lint-on-every-build

  • Writing custom lint rules and integrating them with Android Studio inspections

https://android.jlelse.eu/writing-custom-lint-rules-and-integrating-them-with-android-studio-inspections-or-carefulnow-c54d72f00d30

  • 你可能不知道的Android Studio/IDEA使用技巧

http://www.xiaoming.io/android-studio-skill

  • Android自定义Lint实践

http://tech.meituan.com/android_custom_lint.html

  • Android Gradle配置快速入门

http://www.xiaoming.io/android-gradle-basics

  • Gradle开发快速入门——DSL语法原理与常用API介绍

http://www.xiaoming.io/gradle-develop-basics

  • Viewing PSI Structure

https://www.jetbrains.com/help/idea/viewing-psi-structure.html

  • Git - Documentation

https://git-scm.com/documentation

如果觉得文章有帮助,欢迎分享转发,也欢迎关注我的公众号“搬砖的小明”,及时获取更新

公众号:搬砖的小明