Zenn
🤖

【Android × Gradle】自作Lint を multi-moduleで上手く使う

2025/03/23に公開

🦊 はじめに

この記事では、Lintを自作する方法とそれをマルチモジュールプロジェクトで上手く使う方法について書きます。

🐝 プロジェクトの全体像

以下の構造でプロジェクトを作ります。

- custom-lint
  ├ :app         ... アプリモジュール
  ├ :features    ... 各機能用ライブラリモジュール
  │  ├ :mypage
  │  ├ :home
  │  └ :setting
  ├ :build-logic ... Composite Build用
  └ :checks      ... 自作リント用のモジュール

モジュール間の依存関係は以下のようになります。

🐿 Lintを自作する

以下の公式サンプルリポジトリに書かれているものと同じような感じで作ります。

https://github.com/googlesamples/android-custom-lint-rules

🐬 依存関係

:checksは自作リントを配置するモジュールです。まず、このモジュールのビルド設定ファイルにREADMEに書かれているとおりcompileOnlyで必要なライブラリを追加します。後ほど単体テストも実装するのでそのためのライブラリも追加しておきます。

checks/build.gradle.kts
plugins {
    //noinspection JavaPluginLanguageLevel
    id("java-library")
    id("com.android.lint")
    alias(libs.plugins.kotlin.jvm)
}

dependencies {
    compileOnly(libs.lint.api)
    compileOnly(libs.lint.checks)
    testImplementation(libs.lint.tests)
    testImplementation(libs.lint.cli)
    testImplementation(libs.junit)
}
gradle/libs.versions.toml
[versions]lintApi = "31.9.0"

[libraries]lint-api = { group = "com.android.tools.lint", name = "lint-api", version.ref = "lintApi" }
lint-checks = { group = "com.android.tools.lint", name = "lint-checks", version.ref = "lintApi" }
lint-cli = { group = "com.android.tools.lint", name = "lint", version.ref = "lintApi" }
lint-tests  = { group = "com.android.tools.lint", name = "lint-tests", version.ref = "lintApi" }

[plugins]

🪱 リントの実装

今回はプロジェクト内の全てのAndroidManifest.xmlの<activity>android:exportedがあるかを調べるリントを実装します。

【公式doc】android:exported にある通り、android:exportedを明示することが推奨されているみたいです。

※このフラグがつく場所は<activity>だけでなく<service>などもありえますが、この記事では<activity>のみを考えます。リントを自作することが主旨です。

以下が、実装したリントコードです。

CustomLint.kt
class CustomLint : Detector(), XmlScanner { // ⭐ ResourceXmlDetector() でも可

    override fun getApplicableElements() = listOf(TAG_ACTIVITY)

    override fun visitElement(
        context: XmlContext,
        element: Element,
    ) {
        val exported = element.getAttribute("android:exported")
        if (exported.isEmpty()) {
            context.report(
                issue = ISSUE,
                scope = element,
                location = context.getLocation(element),
                message = ERROR_MESSAGE,
            )
        }
    }

    companion object {
        @JvmField
        val ISSUE = Issue.create(
            id = "CustomExportedFlag",
            briefDescription = "custom lint",
            explanation = "custom exported flag check",
            moreInfo = "",
            category = Category.SECURITY,
            priority = 5,
            severity = Severity.ERROR,
            implementation = Implementation(CustomLint::class.java, Scope.MANIFEST_SCOPE),
        )

        const val ERROR_MESSAGE = "no android:exported flag"
    }
}

次に、IssueRegistryを継承したクラスを作ります。このクラスはlintに提供するIssueのリストを管理するためのものです。自作したIssueを登録しておきます。

CustomIssueRegistry.kt
class CustomIssueRegistry : IssueRegistry() {

    override val issues = listOf(CustomLint.ISSUE)

    override val api: Int
        get() = CURRENT_API

    override val vendor: Vendor = Vendor(vendorName = "", feedbackUrl = "", contact = "")
}

最後に、META-INF のサービスローダーメカニズムを使って、IssueRegistryを指定します。

:checks のディレクトリ構造

自分のIssueRegistryの完全修飾クラス名を書きます。

com.example.checks.CustomIssueRegistry

🦩 自作リントの単体テスト

依存関係で追加したテストツールを使用して先ほど実装したリントのテストを書いてみます。

CustomLintTest.kt
@RunWith(JUnit4::class)
class CustomLintTest : LintDetectorTest() {

    override fun getDetector() = CustomLint()

    override fun getIssues() = listOf(CustomLint.ISSUE)

    // ⭐ android:exported がなく、エラーとなる場合
    @Test
    fun `lint success with unset error`() {
        lint()
            .files(
                xml(
                    "AndroidManifest.xml",
                    """
                <manifest xmlns:android="http://schemas.android.com/apk/res/android">
                    <application>
                        <activity android:name=".SomeActivity"/>
                    </application>
                </manifest>
                """
                )
            )
            .run()
            .expectContains("$WITH_ERROR_MESSAGE [CustomExportedFlag]")
    }

    // ⭐ android:exported があるが、真偽値がなくエラーとなる場合
    @Test
    fun `lint success with empty error`() {
        lint()
            .files(
                xml(
                    "AndroidManifest.xml",
                    """
                <manifest xmlns:android="http://schemas.android.com/apk/res/android">
                    <application>
                        <activity android:name=".SomeActivity" android:exported=""/>
                    </application>
                </manifest>
                """
                )
            )
            .run()
            .expectContains("$WITH_ERROR_MESSAGE [CustomExportedFlag]")
    }

    // ⭐ android:exported があり、リントでエラーが検出されない時
    @Test
    fun `lint success with no error`() {
        lint()
            .files(
                xml(
                    "AndroidManifest.xml",
                    """
                    <manifest xmlns:android="http://schemas.android.com/apk/res/android">
                        <application>
                            <activity android:name=".SomeActivity" android:exported="false"/>
                        </application>
                    </manifest>
                    """
                )
            )
            .run()
            .expectClean()
    }
}

このリントをfeaturesのモジュールで使うためには、以下のように書きます。

feature/mypage/build.gradle.kts
dependencies {
    lintChecks(project(":checks"))
}

ただ、全てのfeaturesモジュールでこの記述をするのは面倒なので簡潔にする方法を書きます。

🦒 自作したLintをマルチモジュールで使う

自作したリントをマルチモジュールで上手く使う方法について書きます。Composite Build や Binary Plugins を使ってlintChecks(project(":checks"))を共通化するだけです。

🦔 Binary Plugins でライブラリモジュールの設定を共通化

全ての解説をしていると超絶長文になるので、プラグインのコードのみを載せます。

// ⭐ ここに package 名を書くと場合により上手くプラグインを登録できません。
class LibraryPlugin : Plugin<Project> {
    override fun apply(target: Project) {
        with(target) {
            with(pluginManager) {
                apply("com.android.library")
                apply("org.jetbrains.kotlin.android")
                apply("org.jetbrains.kotlin.plugin.compose")
            }

            extensions.configure<LibraryExtension> {
                configureAndroid(this)
                defaultConfig.targetSdk = libs.version("targetSdk").toInt()
            }

            // ⭐ ここで共通化
            dependencies {
          add("lintChecks", project.dependencies.project(":checks"))
            }
        }
    }
}

// :build-logic/build.gradle.kts
gradlePlugin {
    plugins {
        register("android-library") {
            id = "android.library"
            implementationClass = "LibraryPlugin"
        }
    }
}

このプラグインをfeaturesの各モジュールで使うとリント対象のモジュールにできます。

🦎 Gradleの環境変数を利用する

gradle.propertiesでは、以下のように任意の値を設定することができます。この環境変数をビルド設定ファイルで読み込むことでビルドを柔軟に構成します。

gradle.propeties
lintFlag=true

この値を作成したプラグインの中で読み込んで:checksを依存関係に含めるかを制御します。

val customLintFlag = project.providers.gradleProperty("lintFlag")
if (customLintFlag.orNull == "true") {
    add("lintChecks", project.dependencies.project(":checks"))
}

:appをリント対象にするかは以下のようにbuild.gradle.kts内で制御します。

build.gradle.kts
val lintFlag: String by project // ⭐ StringじゃないとNG

dependencies {
    implementation(project(":features:home"))
    implementation(project(":features:mypage"))
    implementation(project(":features:setting"))

    ~中略~

    if (lintFlag == "true") {
        lintChecks(project(":checks"))
    }
}

リント対象モジュールがないとき、:checksを認識させる必要がないのでsettings.gradle.ktsでは以下のよう書きます。(でも、フラグがtrueでもBuild AnalyzerやBuild Scanの結果を見ると、:checks関連のタスクはビルド中に何も実行されていないように見えたのでビルド速度が速くなるとかはないのかなと思います。)

settings.gradle.kts
include(":app")

include(":features:mypage")
include(":features:home")
include(":features:setting")

if (extra["lintFlag"] == "true") {
    include(":checks")
}

以上のようにして、フラグを使って自作リントをマルチモジュールで上手く使うことができます。

🍮 Tips集

🐨 特定のリントのみ実行したい

./gradlewコマンドには、-Pで値を渡すことができます。これを利用して今回作った自作リントのみを対象にリントを実行できます。

./gradlew lint -Pandroid.lint.checkOnly=CustomExportedFlag

🐞 gradle.propeties の値をコマンドで上書きする

以下の記事の優先度に従って環境変数が解釈されるのでコマンドからフラグを制御してからリントを実行できます。コマンドラインの方がgradle.propertiesより優先度が高いです。

https://docs.gradle.org/current/userguide/build_environment.html#sec:project_properties

Android Studio で gradle.properties の lintFlag の真偽を直接編集すると毎回Syncボタンを押下してからリントを実行する必要があり、面倒なのでコマンドで上書きして実行します。

./gradlew lint -Pandroid.lint.checkOnly=CustomExportedFlag -PlintFlag=true

🐈 今回のGithubリポジトリ

記事中で省略したコードなど詳細は以下のリポジトリから見れます。
https://github.com/rikuyu/custom-lint

Discussion

ログインするとコメントできます