LoginSignup
6
2

More than 5 years have passed since last update.

Linで書くAndroid Custom Lint

Last updated at Posted at 2019-03-13

Custom Lintについて

チーム開発をしていると、プルリクでの細かな指摘で同じことを何度も指摘するようなことがあると思います。
そんなときに、指摘事項をlintが見つけてくれると助かります。
Android標準のlintもありますが、Custom Lintによって、標準のlintが指摘してくれないものやそのチーム独自のルールに適用できるlintを作ることができます。

Android Custom Lintに関しては下記の資料が参考になります。
https://www.slideshare.net/cch-robo/android-lintsrppractice
https://qiita.com/hotchemi/items/9364d54a0e024a5e6275
https://inside.dmm.com/entry/2018/04/03/kotlin-custom-lint

Lin

https://github.com/Serchinastico/Lin
Kotlin DSLとAnnotation Processingを使ったコード生成によって、Custom Lintの作成をちょっと簡単にしてくれるライブラリです。
また、作成したDetectorのテストの記述もシンプルになります。

Linを用いたCustom Lintの作り方

今回は例として、
・UseCase層はすべてsuspend関数にする
というルールをチーム内で決めたとして、そのルールを強制するようなCustom Lintを作ってみます。

コードは下記githubにあげています。
https://github.com/iiinaiii/lintchecks-lin

環境

  • IntelliJ IDEA 2018.3.5
  • Gradle 5.0

ライブラリ導入

Linを使ってCustom Lintを書くために、依存を追加します。

Linの依存追加

rootのbuild.gradleにJitPackリポジトリを追加

rootのbuild.gradle
allprojects {
    repositories {
        ...
        maven { url 'https://jitpack.io' }
    }
}

モジュールのdependenciesにLinの依存として以下を追加します。

モジュールのbuild.gradle
...
apply plugin: 'kotlin-kapt'

...

dependencies {
    // DSL
    implementation "com.github.serchinastico.lin:dsl:$lin_version"
    // アノテーション
    compileOnly "com.github.serchinastico.lin:annotations:$lin_version"
    // Detectorクラスを自動生成するannotation processor
    kapt "com.github.serchinastico.lin:processor:$lin_version"
    // テスト用のクラス
    testCompile "com.github.serchinastico.lin:test:$lin_version"
}

build.gradle全体

通常のCustom Lintのdependenciesと合わせて、以下のようなbuild.gradleになります。

rootのbuild.gradle
buildscript {
    ext {
        kotlin_version = '1.3.21'
        lint_version = '26.4.0-alpha09'
        lin_version = '0.0.5'
    }

    repositories {
        google()
        jcenter()
    }
    dependencies {
        classpath "com.android.tools.build:gradle:3.2.1"
        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
        classpath 'com.github.jengelman.gradle.plugins:shadow:5.0.0'
    }
}

allprojects {
    repositories {
        google()
        jcenter()
        maven { url 'https://jitpack.io' }
    }
}
モジュールのbuild.gradle
apply plugin: 'kotlin'
apply plugin: 'kotlin-kapt'
apply plugin: 'com.github.johnrengelman.shadow'
apply plugin: 'java'

configurations {
    lintChecks
}

dependencies {
    // Custom Lint
    compileOnly "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
    compileOnly "com.android.tools.lint:lint-api:$lint_version"
    compileOnly "com.android.tools.lint:lint-checks:$lint_version"

    // Lint test
    testCompile "junit:junit:4.12"
    testCompile "com.android.tools.lint:lint:$lint_version"
    testCompile "com.android.tools.lint:lint-tests:$lint_version"
    testCompile "com.android.tools:testutils:$lint_version"

    // Lin
    implementation "com.github.serchinastico.lin:dsl:$lin_version"
    compileOnly "com.github.serchinastico.lin:annotations:$lin_version"
    kapt "com.github.serchinastico.lin:processor:$lin_version"
    testCompile "com.github.serchinastico.lin:test:$lin_version"
}

jar {
    manifest {
        // Only use the "-v2" key here if your checks have been updated to the
        // new 3.0 APIs (including UAST)
        attributes("Lint-Registry-v2": "com.iiinaiii.lint.LinIssueRegistry")
    }
}

Detectorの作成

検出したい事項を表すメソッドを作成し、アノテーション@Detectorを付けます。
メソッドは、LinDetectorを返すdetector()を呼び出すようにし、
第一引数にIssueの定義、
第二引数にラムダで検出ロジック
を書きます。

UseCaseFunctionDetector.kt

@Detector
fun checkUseCaseSuspendFunction() = detector(
    issue(
        scope = Scope.JAVA_FILE_SCOPE,
        description = "UseCase function must be suspend function.",
        explanation = """This project adopts a layered architecture,
            and the function of the UseCase layer must be a suspend function
        """.trimIndent()
    )
) {
    type {
        suchThat { uClass ->
            if (uClass.name?.contains("UseCase") == false) {
                return@suchThat false
            }

            return@suchThat uClass.methods.filter {
                it.isConstructor.not()
            }.all {
                it.modifierList.text.contains("suspend")
            }.not()
        }
    }
}

Issueの定義

Linに定義されているissue()を使うと、
scope,description,explanation以外のものはLinデフォルトのものになります。

  • category = Category(null, "Lin", 100)
  • priority = 5
  • severity = Severity.ERROR
dsl.kt
fun issue(
    scope: EnumSet<Scope>,
    description: String,
    explanation: String
): IssueBuilder = IssueBuilder(scope, description, explanation)

data class IssueBuilder(
    val scope: EnumSet<Scope>,
    val description: String,
    val explanation: String,
    val category: Category = LinCategory,
    var priority: Int = 5,
    var severity: Severity = Severity.ERROR
) {
...

カスタマイズしたい場合はIssueBuilder()を直接呼び出します。

@Detector
fun checkUseCaseSuspendFunction() = detector(
    IssueBuilder(
        scope = Scope.JAVA_FILE_SCOPE,
        description = "UseCase function must be suspend function.",
        explanation = """This project adopts a layered architecture,
            and the function of the UseCase layer must be a suspend function
        """.trimIndent(),
        category = Category.PERFORMANCE,
        priority = 8,
        severity = Severity.WARNING
    )

検出ロジックの作成

検出ロジックの作成は以下のような流れになります

  • 検出したい構文要素に対応するメソッドを呼び出す
  • その中でsuchThatメソッドを呼び出す
  • その中で検出ロジックを書き、指摘事項を検出したらtrueを、検出しなければfalseを返す

今回の例では、
・UseCase層はすべてsuspend関数にする
というルールに当てはまるロジックを作るので、
・クラス名に"UseCase"が含まれること
・該当クラスのすべてのメソッドのmodifierに"suspend"が付与されていること
が検出ロジックとなります。

@Detector
fun checkUseCaseSuspendFunction() = detector(
    issue(
        ...
    )
) {
    // クラス名が「〜UseCase」のものを検出するため, type{}を使う
    type {
        suchThat { uClass ->
            // クラス名の検査 : "UseCase"が含まれていること
            if (uClass.name?.contains("UseCase") == false) {
                return@suchThat false
            }

            // メソッドの検査 : "suspend" modifierが付いていること
            return@suchThat uClass.methods.filter {
                // コンストラクタは除外
                it.isConstructor.not()
            }.all {
                // すべてのメソッドのmodifierに"suspend"が含まれること
                it.modifierList.text.contains("suspend")
            }.not()
        }
    }
}

PsiViewerプラグイン

検出したい部分の構文要素が何かを知るにはPsiViewerプラグインが便利です。
PsiViewerを使うと、上記の例の"suspend"がmethod.modifierList.textに含まれることがわかります。
スクリーンショット 2019-03-13 21.23.07.png

Detectorクラスの自動生成

ここまでできたら、gradleのassembleタスクを実行します。
{@Detectorを付けたメソッド名+Detector}という名前の、Detectorクラスが自動生成されます。

CheckUseCaseSuspendFunctionDetector.kt
class CheckUseCaseSuspendFunctionDetector : Detector(), UastScanner {
    val projectVisitor: LinVisitor = LinVisitor(detector)

    lateinit var javaContext: JavaContext
...

IssueRegistryの作成

先程自動生成されたクラスからissueが取得できるようになっています
IssueRegistryを継承したIssueクラスを作成し、issuesのリスト返却の内容として、上記自動生成クラスのissueを含めます。

LinIssueRegistry.kt
class LinIssueRegistry : IssueRegistry() {
    override val api: Int
        get() = CURRENT_API

    override val minApi: Int
        get() = -1

    override val issues: List<Issue> = listOf(
        CheckUseCaseSuspendFunctionDetector.issue
    )
}

通常のCustom Lint同様、 作成したIssueRegistryを下記のようにモジュールのbuild.gradleに記載します。

モジュールのbuild.gradle
...
...
jar {
    manifest {
        // Only use the "-v2" key here if your checks have been updated to the
        // new 3.0 APIs (including UAST)
        attributes("Lint-Registry-v2": "com.iiinaiii.lint.LinIssueRegistry")
    }
}

テストを書く

Linを使うとDetectorのテストもシンプルに書けます。

テストクラスの作成

LinTestを実装したテストクラスを作成し、issueとして自動生成Detectorクラスのissueを返します。

UseCaseFunctionDetectorTest.kt
class UseCaseFunctionDetectorTest : LintTest {
    override val issue: Issue
        get() = CheckUseCaseSuspendFunctionDetector.issue
...

テストメソッドの書き方

書き方は以下のようになります

エラーがある場合
expect(
   """
   // 検出したいコードの文字列
   """.inKotlin //(or inJava)
) toHave SomeError("{fileName}")
エラーがない場合
expect(
   """
   // 検出したいコードの文字列
   """.inKotlin //(or inJava)
) toHave NoErrors

今回作成した、Detectorのテストコードは以下のようになりました。

    @Test
    fun whenContainsNotSuspendFunction_detectsErrors() {
        expect(
            """
                package foo

                class SampleUseCase @Inject constructor(
                    private val sampleRepository: SampleRepository
                ) {

                    operator fun invoke(id: Int): Result<SampleData> {
                        val result = sampleRepository.getSampleData(id)
                        return when (result) {
                            is Result.Success -> Result.Success(result.data.toSampleData())
                            is Result.Error -> result
                        }.exhaustive
                    }
                }
            """.inKotlin
        ) toHave SomeError("src/foo/SampleUseCase.kt")
    }

KotlinでLintのテストコードを書くと、トリプルクォートが使えるので、テスト対象のクラスの表現がしやすいのですが、それに加えてLinを使うとアサーションが書きやすくなりました。

ちなみに以前Kotlinで書いた、Custom LintのDetectorのテストは以下のようなアサーションをしていました。

val kotlinResult = lintProject(LintDetectorTest.kotlin(SOURCE_PATH + "Foo.kt", kotlinCode))
assertThat(kotlinResult).isEqualTo(
            """
src/com/iiinaiii/lintchecks/Foo.kt:7: Error: Don't write http:// code direct!!! [WriteHttpDirect]
                    private val fieldUrl = "http://www.foo.jp/field"
                                            ~~~~~~~~~~~~~~~~~~~~~~~
src/com/iiinaiii/lintchecks/Foo.kt:17: Error: Don't write http:// code direct!!! [WriteHttpDirect]
                        val localUrl = "http://www.foo.jp/local"
                                        ~~~~~~~~~~~~~~~~~~~~~~~
src/com/iiinaiii/lintchecks/Foo.kt:22: Error: Don't write http:// code direct!!! [WriteHttpDirect]
                        request("http://www.foo.jp/direct")
                                 ~~~~~~~~~~~~~~~~~~~~~~~~
3 errors, 0 warnings
        """.trimIndent()
)

jarを作成してプロジェクトに適用する

Fat Jarの作成

以上で、Linを使ったCustom Lintの作成ができました。
Androidのプロジェクトに適用するため、jarを作成します。
Linのクラスをjarに含めるようにするため、Gradle Shadow Pluginを使って、Fat Jarを作成するようにしました。
https://imperceptiblethoughts.com/shadow/

以下のコマンドで、モジュール/build/libs配下にjarが生成されます。

 ./gradlew {モジュール名}:shadowJar

Androidプロジェクトへ配置

Androidプロジェクトの適当な場所にjarを配置し、
gradleのdependenciesに以下のように記載すると、Custom Lintが動作します。

dependencies {
  ...
  lintChecks files("lint/checks-all.jar")
}

注 : ハマったポイント

annotation processingによる自動生成を含むからなのか
1つjavaのファイルを配置しておかないと、"ソース・ファイルがありません"
というエラーが出てうまくいきませんでした。
image.png

Linのリポジトリでもそのようにしていたため、現状では回避策としてそのようにする必要があるかもしれません。
https://github.com/Serchinastico/Lin/blob/master/detectors/src/main/kotlin/com/serchinastico/lin/detectors/JavaFileToAvoidCompilationFailure.java

完成

image.png

Linのその他機能

Linには以下のような機能もあるようです。
機会があれば使ってみたいと思います。

まとめ

  • Linを使うとDSLによって、issueの定義、検出ロジックに集中できるので、Custom Lintが書きやすくなりました。
  • Linを使うとDSLによって、自作したDetectorのテストコードが書きやすくなりました。

参考

6
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
2