【Android × Gradle】自作Lint を multi-moduleで上手く使う
🦊 はじめに
この記事では、Lintを自作する方法とそれをマルチモジュールプロジェクトで上手く使う方法について書きます。
🐝 プロジェクトの全体像
以下の構造でプロジェクトを作ります。
- custom-lint
├ :app ... アプリモジュール
├ :features ... 各機能用ライブラリモジュール
│ ├ :mypage
│ ├ :home
│ └ :setting
├ :build-logic ... Composite Build用
└ :checks ... 自作リント用のモジュール
モジュール間の依存関係は以下のようになります。
🐿 Lintを自作する
以下の公式サンプルリポジトリに書かれているものと同じような感じで作ります。
🐬 依存関係
:checks
は自作リントを配置するモジュールです。まず、このモジュールのビルド設定ファイルにREADMEに書かれているとおりcompileOnly
で必要なライブラリを追加します。後ほど単体テストも実装するのでそのためのライブラリも追加しておきます。
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)
}
[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>
のみを考えます。リントを自作することが主旨です。)
以下が、実装したリントコードです。
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を登録しておきます。
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
🦩 自作リントの単体テスト
依存関係で追加したテストツールを使用して先ほど実装したリントのテストを書いてみます。
@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のモジュールで使うためには、以下のように書きます。
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
では、以下のように任意の値を設定することができます。この環境変数をビルド設定ファイルで読み込むことでビルドを柔軟に構成します。
lintFlag=true
この値を作成したプラグインの中で読み込んで:checks
を依存関係に含めるかを制御します。
val customLintFlag = project.providers.gradleProperty("lintFlag")
if (customLintFlag.orNull == "true") {
add("lintChecks", project.dependencies.project(":checks"))
}
:app
をリント対象にするかは以下のように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
関連のタスクはビルド中に何も実行されていないように見えたのでビルド速度が速くなるとかはないのかなと思います。)
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より優先度が高いです。
Android Studio で gradle.properties の lintFlag の真偽を直接編集すると毎回Syncボタンを押下してからリントを実行する必要があり、面倒なのでコマンドで上書きして実行します。
./gradlew lint -Pandroid.lint.checkOnly=CustomExportedFlag -PlintFlag=true
🐈 今回のGithubリポジトリ
記事中で省略したコードなど詳細は以下のリポジトリから見れます。
Discussion