[Android]ConstLayntLayout内でmatchParentを使用した場合に警告するカスタムLintを作成する

はじめに

DroidKaigi 2021にて行われた下記の発表に触発されて

DroidKaigi 2021 – 25分で作るAndroid Lint / Nozomi Takuma [JA] – YouTube
25分で作るAndroid Lint / Android Lint made in 25 minutes – Speaker Deck

PRレビューでよく指摘していたことをLintにできたらいいなと思い、上記を参考に自分もAndroidのカスタムLintを作成してみました。
Lint化しておけばローカルの開発環境や、CI上でDangerを使用してPR作成後に自動でLintチェックを行い、レビュアーが指摘せずとも間違いに気づける環境が構築できるという狙いがあります。

カスタムLint作成の準備

まずは発表内容を参考にカスタムLint作成時に必要なものが揃っているので下記のリポジトリをベースに作成していきます。

GitHub – googlesamples/android-custom-lint-rules: This sample demonstrates how to create a custom lint checks and corresponding lint tests

今回はConstraintLayout内の子Viewに
android:layout_width="match_parent"もしくは android:layout_height="match_parent"
が設定されている場合に警告するLintを作成してみます。

ConstraintLayout内の子Viewにmatch_parentを使っては行けない理由は公式ドキュメントにかかれているのでそちらを参考にしてください。
ConstraintLayout  |  Android Developers

layout.xmlに対するLintの作成方法は発表内容やドキュメントだけだとわかりにくいので
標準のConstraintLayoutのLintのコードを参考にしながら作成しました。

ConstraintLayoutDetector.kt – Android Code Search

layout.xmlに対するカスタムLintの作成

まずはカスタムLintの本体となるLayoutDetectorを実装していきます。

appliesToやgetApplicableElementsでLintを適用する対象を絞っています。
visitElementで対象を実際にLintチェックするコードを実装します。
最後にLintチェックに引っかかったらcontext.reportでIssueを報告するようにします。

package com.example.lint.checks.detector

import com.android.SdkConstants.*
import com.android.resources.ResourceFolderType
import com.android.tools.lint.detector.api.*
import com.android.utils.forEach
import org.w3c.dom.Element
import org.w3c.dom.Node

class ConstraintLayoutDetector : LayoutDetector() {

    // layoutファイルだけ検出するように設定
    override fun appliesTo(folderType: ResourceFolderType) =
        (folderType == ResourceFolderType.LAYOUT)

    // ConstraintLayoutの要素だけ検出するように設定
    override fun getApplicableElements() = setOf(
        CONSTRAINT_LAYOUT.oldName(),
        CONSTRAINT_LAYOUT.newName()
    )

    // 検出したConstraintLayoutに対してLintCheckを実行
    override fun visitElement(context: XmlContext, element: Element) {
        var child = element.firstChild
        while (child != null) {
            if (child.nodeType != Node.ELEMENT_NODE) {
                child = child.nextSibling
                continue
            }
            // ConstraintLayout内の子Viewの設定をチェックしていく
            child.attributes.forEach { attribute ->
                val name = attribute.localName ?: return@forEach
                val value = attribute.nodeValue
                // android:layout_width or android:layout_heightにmatch_parentが含まれているかチェック
                if (name != ATTR_LAYOUT_WIDTH && name != ATTR_LAYOUT_HEIGHT) return@forEach
                if (value != VALUE_MATCH_PARENT) return@forEach
                // Lintチェックに引っかかったのでreportする
                context.report(
                    issue = ISSUE_USE_MATCH_PARENT_IN_CONSTRAINT_LAYOUT,
                    location = context.getLocation(element),
                    message = ISSUE_USE_MATCH_PARENT_IN_CONSTRAINT_LAYOUT.getExplanation(TextFormat.TEXT)
                )
                return
            }
            child = child.nextSibling
        }
    }

    companion object {
        // Lintで検知した際に表示する内容などを設定する
        @JvmStatic
        internal val ISSUE_USE_MATCH_PARENT_IN_CONSTRAINT_LAYOUT = Issue.create(
            id = "UseMatchParentInConstraintLayout",
            briefDescription = "Similar behavior can be defined by using MATCH_CONSTRAINT with the corresponding left/right or top/bottom constraints being set to \"parent\"",
            explanation = "MATCH_PARENT is not recommended for widgets contained in a ConstraintLayout.",
            category = Category.CORRECTNESS,
            priority = 6,
            severity = Severity.ERROR,
            implementation = Implementation(
                ConstraintLayoutDetector::class.java,
                Scope.RESOURCE_FILE_SCOPE
            ),
            androidSpecific = true
        ).addMoreInfo("https://developer.android.com/reference/androidx/constraintlayout/widget/ConstraintLayout")
    }
}

また、カスタムLint開発時はテストコードでデバッグしながらやるとわかりやすいです。

package com.example.lint.checks

import com.android.tools.lint.checks.infrastructure.TestFiles.xml
import com.android.tools.lint.checks.infrastructure.TestLintTask.lint
import com.example.lint.checks.detector.ConstraintLayoutDetector
import org.junit.Test

@Suppress("UnstableApiUsage")
class XmlDetectorTest {
    @Test
    fun testXML() {
        lint()
            .files(
                xml(
                    "res/layout/sample.xml",
                    """
                <?xml version="1.0" encoding="utf-8"?>
                <layout xmlns:android="http://schemas.android.com/apk/res/android"
                    xmlns:app="http://schemas.android.com/apk/res-auto"
                    xmlns:tools="http://schemas.android.com/tools">

                    <data>

                    </data>

                    <androidx.constraintlayout.widget.ConstraintLayout
                        android:id="@+id/main"
                        android:layout_width="match_parent"
                        android:layout_height="match_parent"
                        tools:context=".ui.main.MainFragment">

                        <EditText
                            android:id="@+id/message_1"
                            android:layout_width="wrap_content"
                            android:layout_height="match_parent"
                            android:text="MainFragment"
                            app:layout_constraintBottom_toBottomOf="parent"
                            app:layout_constraintEnd_toEndOf="parent"
                            app:layout_constraintStart_toStartOf="parent"
                            app:layout_constraintTop_toTopOf="parent" />

                        <EditText
                            android:id="@+id/message_2"
                            android:layout_width="wrap_content"
                            android:layout_height="wrap_content"
                            android:text="MainFragment"
                            app:layout_constraintBottom_toBottomOf="parent"
                            app:layout_constraintEnd_toEndOf="parent"
                            app:layout_constraintStart_toStartOf="parent"
                            app:layout_constraintTop_toTopOf="parent" />

                    </androidx.constraintlayout.widget.ConstraintLayout>
                </layout>
            """.trimIndent()
                ).indented()
            )
            .issues(ConstraintLayoutDetector.ISSUE_USE_MATCH_PARENT_IN_CONSTRAINT_LAYOUT)
            .allowMissingSdk()
            .run()
            .expectErrorCount(1)
    }
}

あとは発表内容にもあるように
作成したカスタムLintようのIssueRegistryの作成や
src/main/resources/META-INF/services/com.android.tools.lint.client.api.IssueRegistry
リソースの追加を行えばカスタムLintモジュールが完成します。

作成したLintを導入する

作成したカスタムLintモジュールを対象のプロジェクトに導入してappのbuild.gradleにlintChecksで追加したLintCheckモジュールを参照します。

// build.gradle(app)
dependencies {

    // Custom Lint Check
    lintChecks project(&#039;:lint-checks&#039;)

}

これで対象のプロジェクトで作成したカスタムLintが機能するようになります。

まとめ

コードレビューで何度も指摘することを検出するカスタムLintを作成することでレビューの負荷が減らすことができます。

また、特定のクラスを参照しないように検知するといったプロジェクト固有のルールを適用するLintも作成できます。

これにより複数人での開発でもプロジェクトがカオスになっていくのを防ぐことができます。

カスタムLintの書き方はドキュメントも少なく分かりづらいですが、
既存Lintのコードを参考にすればなんとか作成していけると思います。