本文在官方文檔的基礎(chǔ)上,詳細(xì)講解了自定義 Lint 檢查代碼的步驟,并給出了調(diào)試代碼的方法和發(fā)布流程柏锄,方便團(tuán)隊(duì)進(jìn)行代碼的管理。
本文由 “谷歌開(kāi)發(fā)者” 官方微信公眾號(hào)轉(zhuǎn)載复亏,地址:https://mp.weixin.qq.com/s/B9p4EUIaFhL-JcNAjopOKw
1. 背景
之前開(kāi)發(fā)過(guò)程中遇到過(guò)一些坑趾娃,并產(chǎn)生了大量的線(xiàn)上崩潰,遇到過(guò)的一些問(wèn)題如下:
- 有些顏色值是通過(guò)后端下發(fā)的缔御,但是在使用
Color.parseColor()
方法時(shí)抬闷,如果后端返回的不是標(biāo)準(zhǔn)的顏色格式,就會(huì) crash耕突。 - 在
AndroidManifest.xml
文件中對(duì)一個(gè) Activity 同時(shí)設(shè)置方向和透明主題時(shí)笤成,在 Android 8.0 手機(jī)上會(huì) crash。
但是這些類(lèi)似的錯(cuò)誤并不是每位開(kāi)發(fā)者都會(huì)知道眷茁,所以即使一個(gè)人遇到過(guò)炕泳,以后可能還會(huì)有人犯同類(lèi)的錯(cuò)誤。
因此上祈,為了避免后人踩相同的坑培遵,我們可以利用 Lint 檢查工具,對(duì)大家寫(xiě)的代碼進(jìn)行檢查登刺,針對(duì)可能會(huì)產(chǎn)生問(wèn)題的代碼進(jìn)行友好的提示籽腕,并在打包中的 Lint 檢查過(guò)程中禁止編譯通過(guò)。
IDE 自帶的 Lint 檢查的使用可參見(jiàn) https://developer.android.com/studio/write/lint纸俭,但是這是不能滿(mǎn)足我們需求的皇耗,因此需要我們自己實(shí)現(xiàn) Lint 檢查的代碼。
下面來(lái)看一下是如何自定義 Lint 檢查的揍很。
2. 創(chuàng)建 Lint 檢查項(xiàng)目
2.1 新建工程
使用 Android Studio 新建一個(gè)空工程郎楼,在選擇工程模板的界面,選擇第一個(gè) No Activity窒悔,然后其余的和常規(guī)項(xiàng)目沒(méi)有區(qū)別呜袁。
在項(xiàng)目根目錄的 build.gradle
文件添加依賴(lài):
buildscript {
// ...
dependencies {
classpath "com.android.tools.lint:lint:26.3.2"
}
}
2.2 新建 lint module
新建一個(gè) module,在選擇 module 類(lèi)型的界面蛉迹,選擇 Java or Kotlin Library傅寡,然后給新建的 module 命名放妈,例如 lint北救。
在新建的 module 下的 build.gradle
文件添加依賴(lài):
dependencies {
// Lint
compileOnly "com.android.tools.lint:lint-api:26.3.2"
compileOnly "com.android.tools.lint:lint-checks:26.3.2"
// Lint Testing
testImplementation "com.android.tools.lint:lint:26.3.2"
testImplementation "com.android.tools.lint:lint-tests:26.3.2"
}
2.3 在 app module 添加 lintChecks
為了方便在寫(xiě)完 Lint 檢查的代碼后進(jìn)行測(cè)試荐操,在 app module 下的 build.gradle
文件添加依賴(lài):
dependencies {
lintChecks project(':lint')
}
3. 注冊(cè)檢查列表
在 lint module 新建一個(gè)類(lèi)繼承自 IssueRegistry
,其中 getIssues()
方法先返回一個(gè)空列表珍策,并重寫(xiě)一下 getApi()
方法:
class MyIssueRegistry extends IssueRegistry {
@NotNull
@Override
public List<Issue> getIssues() {
List<Issue> issues = new ArrayList<>();
return issues;
}
@Override
public int getApi() {
return ApiKt.CURRENT_API;
}
}
然后在 lint module 下的 build.gradle
文件添加如下配置:
jar {
manifest {
attributes("Lint-Registry": "com.jimmysun.android.lint.MyIssueRegistry")
}
}
4. 自定義 Lint 檢查
下面來(lái)看看如何實(shí)現(xiàn)自定義 Lint 檢查的代碼托启。
4.1 Issues vs Detectors
首先來(lái)區(qū)分一下這兩個(gè)概念。Issue 代表你想要發(fā)現(xiàn)并提示給開(kāi)發(fā)者的一種問(wèn)題攘宙,包含描述屯耸、更全面的解釋、類(lèi)型和優(yōu)先級(jí)等等蹭劈。官方提供了一個(gè) Issue
類(lèi)疗绣,你只需要實(shí)例化一個(gè) Issue,并注冊(cè)到 IssueRegistry
里铺韧。
另外你還需要實(shí)現(xiàn)一個(gè) Detector
多矮。Detector 負(fù)責(zé)掃描代碼并找到有問(wèn)題的地方,然后把它們報(bào)告出來(lái)哈打。一個(gè) Detector 可以報(bào)告多種類(lèi)型的 Issue塔逃,你可以針對(duì)不同類(lèi)型的問(wèn)題使用不同的嚴(yán)重程度,這樣用戶(hù)可以更精確地控制他們想要看到的內(nèi)容料仗。
下面我們就以檢測(cè) AndroidManifest.xml
和資源文件來(lái)舉例湾盗。創(chuàng)建一個(gè) Detector
:
public class FixOrientationTransDetector extends Detector {
private static final Implementation IMPLEMENTATION =
new Implementation(FixOrientationTransDetector.class, EnumSet.of(Scope.MANIFEST,
Scope.ALL_RESOURCE_FILES));
public static final Issue ISSUE = Issue.create(
"FixOrientationTransError",
"不要在 AndroidManifest.xml 文件里同時(shí)設(shè)置方向和透明主題",
"Activity 同時(shí)設(shè)置方向和透明主題在 Android 8.0 手機(jī)會(huì) Crash",
Category.CORRECTNESS,
8,
Severity.ERROR,
IMPLEMENTATION);
}
Implementation
我們后面再解釋。先看 Issue.create()
方法立轧,其參數(shù)定義如下:
- id:唯一的 id格粪,簡(jiǎn)要表達(dá)當(dāng)前問(wèn)題。
- briefDescription:簡(jiǎn)單描述當(dāng)前問(wèn)題氛改。
- explanation:詳細(xì)解釋當(dāng)前問(wèn)題和修復(fù)建議匀借。
-
category:?jiǎn)栴}類(lèi)別,在 Android 中主要有如下六大類(lèi):
-
SECURITY
:安全性平窘。例如在 AndroidManifest.xml 中沒(méi)有配置相關(guān)權(quán)限等吓肋。 -
USABILITY
:易用性。例如重復(fù)圖標(biāo)瑰艘,一些黃色警告等是鬼。 -
PERFORMANCE
:性能。例如內(nèi)存泄漏紫新,xml 結(jié)構(gòu)冗余等均蜜。 -
CORRECTNESS
:正確性。例如超版本調(diào)用 API芒率,設(shè)置不正確的屬性值等囤耳。 -
A11Y
:無(wú)障礙(Accessibility)。例如單詞拼寫(xiě)錯(cuò)誤等。 -
I18N
:國(guó)際化(Internationalization)充择。例如字符串缺少翻譯等德玫。
-
- priority:優(yōu)先級(jí),從 1 到 10椎麦,10 最重要宰僧。
-
severity:嚴(yán)重程度,包括
FATAL
观挎、ERROR
琴儿、WARNING
、INFORMATIONAL
和IGNORE
嘁捷。 - implementation:Issue 和哪個(gè) Detector 綁定造成,以及聲明檢查的范圍。
之后將 FixOrientationTransDetector
注冊(cè)到上面的 MyIssueRegistry
里:
public List<Issue> getIssues() {
List<Issue> issues = new ArrayList<>();
issues.add(FixOrientationTransDetector.ISSUE);
return issues;
}
4.2 Scopes
再來(lái)說(shuō)說(shuō)上面創(chuàng)建的 Implementation
對(duì)象雄嚣,它的構(gòu)造方法的第二個(gè)參數(shù)傳入一個(gè) Scope
枚舉類(lèi)的集合谜疤,包括:
- 資源文件
- Java 源文件
- Class 文件
- Proguard 配置文件
- Manifest 文件
- 等等
Issue 需要指定分析代碼所需的范圍,例如上面代碼我們要檢查的是 Manifest 文件和資源文件现诀。
4.3 Scanner
自定義 Detector 還需要實(shí)現(xiàn)一個(gè)或多個(gè)以下接口:
-
UastScanner
:掃描 Java 文件和 Kotlin 文件 -
ClassScanner
:掃描 Class 文件 -
XmlScanner
:掃描 XML 文件 -
ResourceFolderScanner
:掃描資源文件夾 -
BinaryResourceScanner
:掃描二進(jìn)制資源文件 -
OtherFileScanner
:掃描其他文件 -
GradleScanner
:掃描 Gradle 腳本
因?yàn)槲覀冃枰獟呙璧?AndroidManifest.xml
和 styles.xml
都是 XML 文件夷磕,那么需要實(shí)現(xiàn) XMLScanner
接口:
public class FixOrientationTransDetector extends Detector implements XmlScanner
4.4 掃描 XML 文件
要分析一個(gè) XML 文件,你可以重寫(xiě) visitDocument()
方法仔沿。這個(gè)方法每個(gè) XML 文件都會(huì)調(diào)用一次坐桩,然后傳入 XML DOM 模型,之后你就可以自己遍歷并做分析封锉。
但是呢绵跷,我們通常只關(guān)注一些特定的標(biāo)簽和一些特定的屬性,為了讓掃描更快成福,Detector 可以指定我們關(guān)注的元素和屬性碾局。
要篩選我們關(guān)注的元素或?qū)傩裕恍鑼?shí)現(xiàn) getApplicableElements()
或 getApplicableAttributes()
方法奴艾,并返回一個(gè)標(biāo)簽或?qū)傩悦Q(chēng)的字符串列表净当。然后再實(shí)現(xiàn) visitElement()
或 visitAttribute()
方法,這兩個(gè)方法針對(duì)每個(gè)指定的元素和屬性都會(huì)調(diào)用一次蕴潦。
接上例像啼,我們需要分析的是 activity
和 style
標(biāo)簽,那么需要實(shí)現(xiàn) getApplicableElements()
方法:
@Override
public Collection<String> getApplicableElements() {
return Arrays.asList(SdkConstants.TAG_ACTIVITY, SdkConstants.TAG_STYLE);
}
你也可以從 getApplicableElements()
和 getApplicableAttributes()
方法返回一個(gè) ALL
常量潭苞,這樣對(duì)于所有的元素或?qū)傩远紩?huì)調(diào)用一次忽冻。
另外 SdkConstants.java
類(lèi)內(nèi)置了很多常量可以直接使用,包括 TAG_MANIFEST
此疹、TAG_RESOURCES
等等僧诚,如果沒(méi)有也可以自己手寫(xiě)遮婶。
之后我們要實(shí)現(xiàn) visitElement()
方法來(lái)進(jìn)行分析。我們需要判斷 activity
標(biāo)簽中配置的 android:screenOrientation
的某些屬性與透明主題是否同時(shí)設(shè)置的湖笨,如果出現(xiàn)這種情況則報(bào)告出來(lái)旗扑,代碼如下:
private final Map<ElementEntity, String> mThemeMap = new HashMap<>();
@Override
public void visitElement(@NotNull XmlContext context, @NotNull Element element) {
switch (element.getTagName()) {
case SdkConstants.TAG_ACTIVITY:
if (isFixedOrientation(element)) {
String theme = element.getAttributeNS(SdkConstants.ANDROID_URI,
SdkConstants.ATTR_THEME);
if ("@style/Theme.AppTheme.Transparent".equals(theme)) {
reportError(context, element);
} else {
// 將主題設(shè)置暫存起來(lái)
mThemeMap.put(new ElementEntity(context, element),
theme.substring(theme.indexOf('/') + 1));
}
}
break;
case SdkConstants.TAG_STYLE:
String styleName = element.getAttribute(SdkConstants.ATTR_NAME);
mThemeMap.forEach((elementEntity, theme) -> {
if (theme.equals(styleName)) {
if (isTranslucentOrFloating(element)) {
reportError(elementEntity.getContext(), elementEntity.getElement());
} else if (element.hasAttribute(SdkConstants.ATTR_PARENT)) {
// 替換成父主題
mThemeMap.put(elementEntity,
element.getAttribute(SdkConstants.ATTR_PARENT));
}
}
});
break;
default:
}
}
private boolean isFixedOrientation(Element element) {
switch (element.getAttributeNS(SdkConstants.ANDROID_URI, "screenOrientation")) {
case "landscape":
case "sensorLandscape":
case "reverseLandscape":
case "userLandscape":
case "portrait":
case "sensorPortrait":
case "reversePortrait":
case "userPortrait":
case "locked":
return true;
default:
return false;
}
}
private boolean isTranslucentOrFloating(Element element) {
for (Node child = element.getFirstChild(); child != null; child = child.getNextSibling()) {
if (child instanceof Element
&& SdkConstants.TAG_ITEM.equals(((Element) child).getTagName())
&& child.getFirstChild() != null
&& SdkConstants.VALUE_TRUE.equals(child.getFirstChild().getNodeValue())) {
switch (((Element) child).getAttribute(SdkConstants.ATTR_NAME)) {
case "android:windowIsTranslucent":
case "android:windowSwipeToDismiss":
case "android:windowIsFloating":
return true;
default:
}
}
}
return "Theme.AppTheme.Transparent".equals(element.getAttribute(SdkConstants.ATTR_PARENT));
}
private void reportError(XmlContext context, Element element) {
context.report(
ISSUE,
element,
context.getLocation(element),
"請(qǐng)不要在 AndroidManifest.xml 文件里同時(shí)設(shè)置方向和透明主題"
);
}
private static class ElementEntity {
private final XmlContext mContext;
private final Element mElement;
public ElementEntity(XmlContext context, Element element) {
mContext = context;
mElement = element;
}
public XmlContext getContext() {
return mContext;
}
public Element getElement() {
return mElement;
}
}
這里先提一下 Lint 處理文件的順序:
- Manifest 文件
- 資源文件,按字母順序處理(先是按資源文件夾的字母順序赶么,然后在每個(gè)文件夾里的字母順序)
- Java 源文件
- Java class 文件肩豁,按字母順序處理(如果有內(nèi)部類(lèi)脊串,外部類(lèi)在內(nèi)部類(lèi)之前)
- Proguard 文件
那么上面代碼大體邏輯是這樣的:因?yàn)?Lint 分析會(huì)先檢查 AndroidManifest 文件辫呻,后檢查資源文件,那么在檢查 AndroidManifest 文件時(shí)如果遇到 Activity 同時(shí)設(shè)置了方向和主題琼锋,將相應(yīng)節(jié)點(diǎn)和主題名先暫存下來(lái)放闺。在檢查資源文件時(shí),判斷暫存的主題里是否存在透明設(shè)置缕坎,如果存在則上報(bào)出來(lái)怖侦,否則將暫存的主題名改成父主題(如果有的話(huà))。這里會(huì)有個(gè)缺陷谜叹,就是如果主題的繼承關(guān)系比較復(fù)雜匾寝,可能會(huì)有漏報(bào)的情況。
另外荷腊,上面代碼中有個(gè)上報(bào)錯(cuò)誤的方法 reportError()
艳悔,這個(gè)后面再詳細(xì)說(shuō)明。
4.5 分析 Java/Kotlin 源文件
此外我們?cè)賮?lái)講講如何分析 Java 和 Kotlin 文件女仰,我們以分析 Color.parseColor()
方法為例進(jìn)行說(shuō)明猜年。舊版本的 Detector 需要實(shí)現(xiàn) JavaScanner
接口,新的已經(jīng)被 UastScanner
替代疾忍。示例代碼:
public class ParseColorDetector extends Detector implements Detector.UastScanner {
private static final Implementation IMPLEMENTATION =
new Implementation(ParseColorDetector.class, Scope.JAVA_FILE_SCOPE);
public static final Issue ISSUE = Issue.create(
"ParseColorError",
"Color.parseColor 解析可能 crash",
"后端下發(fā)的色值可能無(wú)法解析乔外,導(dǎo)致 crash",
Category.CORRECTNESS,
8,
Severity.ERROR, IMPLEMENTATION)
.setAndroidSpecific(true);
@Override
public List<String> getApplicableMethodNames() {
return Collections.singletonList("parseColor");
}
@Override
public void visitMethodCall(@NotNull JavaContext context, @NotNull UCallExpression node,
@NotNull PsiMethod method) {
// 不是 android.graphics.Color 類(lèi)的方法,直接返回
if (!context.getEvaluator().isMemberInClass(method, "android.graphics.Color")) {
return;
}
// 參數(shù)寫(xiě)死的比如 "#FFFFFF" 這種一罩,簡(jiǎn)單判斷如果是 # 號(hào)開(kāi)頭杨幼,直接返回
if (isConstColor(node)) {
return;
}
// 已經(jīng)做了 try catch 處理,直接返回
if (isWrappedByTryCatch(node, context)) {
return;
}
reportError(context, node);
}
private boolean isConstColor(UCallExpression node) {
return node.getValueArguments().get(0).evaluate().toString().startsWith("#");
}
private boolean isWrappedByTryCatch(UCallExpression node, JavaContext context) {
if (context.getUastFile() instanceof KotlinUFile) {
return UastUtils.getParentOfType(node.getUastParent(), UTryExpression.class) != null;
}
for (PsiElement parent = node.getSourcePsi().getParent(); parent != null && !(parent instanceof MethodElement); parent = parent.getParent()) {
if (parent instanceof PsiTryStatement) {
return true;
}
}
return false;
}
private void reportError(JavaContext context, UCallExpression node) {
context.report(ISSUE, node, context.getCallLocation(node, false, false)
, "Color.parseColor 解析后端下發(fā)的值可能導(dǎo)致 crash聂渊,請(qǐng) try catch");
}
}
同分析 XML 文件一樣推汽,你需要實(shí)現(xiàn) getApplicableXXX()
和 visitXXX()
方法,例如我們需要分析 parseColor()
方法歧沪,那么就要重寫(xiě) getApplicableMethodNames()
和 visitMethodCall()
方法歹撒。
4.6 報(bào)告錯(cuò)誤
如果你的 Detector 定位到一個(gè)問(wèn)題,需要使用 Context
對(duì)象(Detector 的每個(gè)方法都會(huì)傳入進(jìn)來(lái))調(diào)用 report()
方法來(lái)報(bào)告錯(cuò)誤诊胞,例如 4.4 節(jié)中的代碼如下:
private void reportError(XmlContext context, Element element) {
context.report(
ISSUE,
element,
context.getLocation(element),
"請(qǐng)不要在 AndroidManifest.xml 文件里同時(shí)設(shè)置方向和透明主題"
);
}
除了列出要報(bào)告的問(wèn)題外暖夭,還需要提供位置锹杈、作用域節(jié)點(diǎn)和錯(cuò)誤提示消息:
-
作用域節(jié)點(diǎn):對(duì)于 XML 和 Java 源文件,是指發(fā)生的錯(cuò)誤周?chē)罱?XML DOM 或 Parse AST 樹(shù)節(jié)點(diǎn)迈着,例如上面?zhèn)魅氲?
element
對(duì)象竭望。 -
位置:是指錯(cuò)誤發(fā)生的位置。一般只需將 AST/XML 節(jié)點(diǎn)傳遞給
context.getLocation()
方法裕菠,該方法將創(chuàng)建一個(gè)具有正確文件名和與給定節(jié)點(diǎn)相對(duì)應(yīng)的偏移量的Location
咬清。如果你的錯(cuò)誤與某個(gè)屬性有關(guān),則傳遞該屬性奴潘,以使該問(wèn)題更好地指出錯(cuò)誤發(fā)生的位置旧烧。
好了,這樣一個(gè)完整的自定義 Lint 檢查的代碼就算完成了画髓。
更多關(guān)于狀態(tài)保存掘剪、多階段操作、分析 class 文件和增量 Lint 等高級(jí)用法可以參見(jiàn):http://tools.android.com/tips/lint/writing-a-lint-check
5. 執(zhí)行 Lint 檢查
在編寫(xiě)完 Lint 檢查的代碼之后就可以使用 ./gradlew :app:lintDebug
命令執(zhí)行 Lint 檢查了奈虾,我在 app module 下故意寫(xiě)了兩個(gè)出問(wèn)題的代碼夺谁,對(duì)應(yīng)輸出結(jié)果如下:
上面兩個(gè)鏈接是分析報(bào)告,下面是錯(cuò)誤的提示肉微。
5.1 分析報(bào)告
一般 HTML 版的報(bào)告更清晰一些匾鸥,我們復(fù)制鏈接到瀏覽器里查看一下,可以看到與我們代碼對(duì)應(yīng)的關(guān)系:
點(diǎn)擊 FixOrientationTransError
可以看到 report()
方法輸出的信息和定義的問(wèn)題類(lèi)別碉纳、嚴(yán)重程度和優(yōu)先級(jí)等勿负,如下:
截圖中間那部分是我后加的,讀者不用在意村象。
5.2 錯(cuò)誤提示
剛才終端輸出的錯(cuò)誤提示也是 report()
方法輸出的信息笆环,因?yàn)槲覀儌鬟f了 Location
,所以輸出了問(wèn)題出現(xiàn)在哪個(gè)文件的哪一行并可以直接點(diǎn)擊跳轉(zhuǎn)源碼對(duì)應(yīng)的位置厚者。
6. 調(diào)試代碼
有的時(shí)候我們寫(xiě)完代碼可能并不會(huì)完美地按照我們的想法去分析躁劣,那么我們還可以通過(guò)調(diào)試代碼來(lái)查找問(wèn)題,方法如下库菲。(該方法也適用于自定義 gradle plugin 的調(diào)試账忘。)
6.1 新建 Remote 配置
找到「Edit Configurations...」,如圖:
然后點(diǎn)擊左上角的加號(hào)選擇 Remote熙宇,如圖:
然后在右側(cè)輸入一個(gè)名字鳖擒,例如 LintCheckDebug,其它的使用默認(rèn)值就好烫止,最后點(diǎn)擊 OK蒋荚,如圖:
6.2 開(kāi)啟調(diào)試
在命令行啟動(dòng)遠(yuǎn)程調(diào)試器來(lái)調(diào)試對(duì)應(yīng)的任務(wù),例如我們要調(diào)試的任務(wù)是 lintDebug馆蠕,那么就輸入如下命令:
./gradlew --no-daemon -Dorg.gradle.debug=true :app:lintDebug
最后期升,我們?cè)诖a中打好相應(yīng)的斷點(diǎn)惊奇,選中我們上一步創(chuàng)建的 Remote 配置,點(diǎn)擊 Debug 按鈕即可開(kāi)始調(diào)試我們的自定義 Lint 檢查的代碼了播赁。
7. 發(fā)布
我們可以發(fā)布 aar 到遠(yuǎn)程倉(cāng)庫(kù)颂郎,步驟可以參見(jiàn) https://juejin.cn/post/6844904135314128903#heading-28
但是我這里走的公司內(nèi)部發(fā)布流程,上面方法并沒(méi)有驗(yàn)證過(guò)容为。
最后各個(gè)組件可以在 build.gradle 文件添加 lint 檢查:
dependencies {
lintChecks "com.xxx.lint:lint-checks:x.x.x"
}