DMM.comの、一番深くておもしろいトコロ。

kotlinでも検出できるCustom Lintを作成してみた

kotlinでも検出できるCustom Lintを作成してみた

f:id:shimamura-taka:20180330150907p:plain

こんにちは。CTO室のAndroidエンジニア @kgmyshin です。

本日は、Android開発においてJavaだけでなくkotlinでも検出できるCustom Lintを作成してみたいと思います。

動機

RxJavaを用いていると、下記のようなコードを書くことが多いと思います。

observable.subscribe {
  // viewの更新
}

ただ、このコードはこのままでは問題があります。 ライフサイクル上、すでにViewが死んでしまった後にObservable#onNextが呼ばれる可能性があるからです。 そうなると、死んだViewにアクセスしようとしてアプリはクラッシュしてしまいます。 この問題は適切に Disposable を扱うことで解消されます。

val disposable = observable.subscribe {
  // viewの更新
}
:
// アプリのライフサイクル上でViewをさわってはいけなくなる手前で
disposable.dispose()

Disposableを扱えば良いということは知っているものの、それがしっかりできているかどうかを人間の目ではなく自動でできるようにしたい。 そう思ったのが、Custom Lintを作ろうと思ったきっかけでした。

調査

以前にCustom Lintを作ったことはあるものの、それはJavaのみを対象にしたものでした。 今回調べたところ、Kotlinを対象にしたCustom Lintを作るにはいくつかの方法があります。

  1. ktlintを用いる
  2. detektを用いる
  3. androidの提供しているlintを用いる

(1)、(2)の方法ではAST Nodeから型を判別できず(例えばsubscribeメソッドの返り値の型がDisposableなのかどうかわからない)やりたいことが実現できないため、今回の対象からは除外しました(detektではまだ対応されてませんが、話には上がっています。 https://github.com/arturbosch/detekt/pull/485 )。 (3)の方法でも以前まではできなかったのですが、タイミング良く2018年3月にandroid-gradle-pluginの3.1.0がリリースされたことにより可能になりました。 lint-api自体は以前からkotlinに対応していたのですが、android-gradle-pluginのほうは一つ前のバージョンの3.0.0までは ./gradlew lint をしても .kt ファイルをスキップしてしまっていました。3.1.0からは.ktファイルでもしっかり検出されます。

作り方

googlesamplesにサンプルがあるのでそちらを参考にしながら実装していきます。

準備

googlesamplesのプロジェクトにしたがって作っていきましょう。

rootのbuild.gradleは下記のようになります。

buildscript {
    ext {
        lintVersion = '26.2.0-alpha06'
    }
    repositories {
        google()
        jcenter()
    }
    dependencies {
        classpath "com.android.tools.build:gradle:3.1.0"
        ;
    }
}
:

googlesamplesと比べて lintVersionとandroid-gradle-pluginのバージョンを変えております。 lintVersionを上げないとLintのテストをkotlinでできなかったのと、 繰り返しになりますがandroid-gradle-pluginのバージョンを上げないと ./gradlew lint をしても .ktファイルがスキップされてしまい検出できなかったからです。

Custom Lintを作っていくプロジェクトのbuild.gradleは下記です。

apply plugin: 'java-library'

dependencies {
    compileOnly "com.android.tools.lint:lint-api:$lintVersion"
    compileOnly "com.android.tools.lint:lint-checks:$lintVersion"
    testCompile "junit:junit:4.12"
    testCompile "com.android.tools.lint:lint:$lintVersion"
    testCompile "com.android.tools.lint:lint-tests:$lintVersion"
    testCompile "com.android.tools:testutils:$lintVersion"
}

sourceCompatibility = "1.8"
targetCompatibility = "1.8"

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.kgmyshin.lint.CustomIssueRegistry")
    }
}

Lint-Registry-v2-v2を忘れないようにしましょう。CustomIssueRegistryについては後述します。

Detectorを作成

適切な名前をつけて Detector を継承し UastScanner を実装します。

public class NotHandledDisposableDetector extends Detector implements UastScanner {
}

以前にCustom Lintを作ったことがある方は Detector.JavaScanner を使ったことがあるかもしれません。 これはすでに Deprecatedとなっており、今では UastScannerを使用します。UastとはUnified ASTの略です。

次に Detector#getApplicableUastTypesUastScanner#createUastHandler を実装していきます。下記が実装例です。

public class NotHandledDisposableDetector extends Detector implements UastScanner {

    @Override
    public List<Class<? extends UElement>> getApplicableUastTypes() {
        return Collections.singletonList(UQualifiedReferenceExpression.class);
    }

    @Override
    public UElementHandler createUastHandler(JavaContext context) {

        return new UElementHandler() {
            @Override
            public void visitQualifiedReferenceExpression(UQualifiedReferenceExpression node) {
              : 処理
            }
        };
    }
}

UElementHandlerは様々な visitメソッドを持っています。下記はその一例です。

  • visitAnnotation
  • visitCallExpression
  • visitClass
  • visitIfExpression

これらのメソッドは、スキャナがアノテーションにたどり着いた時や、メソッド呼び出しやクラス定義、if文にたどり着いた時に呼ばれるメソッドです。 Custom Lintを作成する場合、これらの中から必要なメソッドをoverrideして警告を出す出さないの判断をします。 今回は UElementHandler#visitQualifiedReferenceExpression のみを使用するため、そのメソッドのみをoverrideしました。

次にDetector#getApplicableUastTypesについてですが、こちらはUElementHandleroverrideするvisitメソッド対象のUElementのクラスをリストにして返却します。 今回は UElementHandler#visitQualifiedReferenceExpressionのみを overrideする、UQualifiedReferenceExpressionのクラスオブジェクトのみを返却しています。 例えばUElementHandler#visitClassoverrideしているが、Detector#getApplicableUastTypesUClassを返却してない場合、visitClassを実装していてもまったく呼ばれないので忘れないようにしましょう。

次にvisitQualifiedReferenceExpressionの中身を実装していきましょう。 これを実装するには、対象のNGなコードの時にPSI(Project Structure Interface)がどういう構造になっているかを知る必要があります。 PSIの構造がどうなるかイメージできる人はそのまま実装に入りましょう。 イメージが難しい人には、これを知るためにいくつかツールがあります。自分は psiviewerを使用しております。

早速、kotlinで書いた場合のNGコードの構造を見てみましょう。

f:id:kugimiya-shin:20180327182009p:plain

この時の該当のPSIは DOT_QUALIFIED_EXPRESSION となっており、親(Context)である PSIは BLOCK となっております。 このPSIにとってのセレクターは subscribe()で、その返り値は io.reactivex.disposables.Disposable です。

正常時も見ておきましょう。disposeされたかどうかまで見るのは複雑になるので、今回は変数に落としておけば良しとします (RxKotlinを使った場合も考えると条件が増えてきますが、趣旨と外れてくるので今回はそこは考慮しません)。

f:id:kugimiya-shin:20180327182127p:plain

正常時はこのような具合で、 DOT_QUALIFIED_EXPRESSION の親(Context)はNGの時と違ってPSIは PROPERTY となっております。 これで正常時と異常時の条件の住み分けがしっかりできていることを確認できました。

NGの際の条件をコードに落とし込むと下記のようになります。

@Override
public void visitQualifiedReferenceExpression(UQualifiedReferenceExpression node) {
    if (node.getPsi() != null &&
            node.getPsi().getContext() != null &&
            node.getPsi().getContext().toString().equals("BLOCK") &&
            node.getSelector().asRenderString().startsWith("subscribe(") &&
            node.getSelector().getExpressionType() != null &&
            node.getSelector().getExpressionType().getCanonicalText().equals("io.reactivex.disposables.Disposable")) {
        // エラー検出
        context.report(ISSUE, node, context.getLocation(node), "Should handle Disposable");
    }
}

次にJavaの場合も考慮しておきましょう。JavaとKotlinでは使われているPSIは別物なので、先ほどと同様の手順で条件式を作る必要があります。 結果はこのようになりました。

@Override
public void visitQualifiedReferenceExpression(UQualifiedReferenceExpression node) {
    // kotlin
    if (node.getPsi() != null &&
            node.getPsi().getContext() != null &&
            node.getPsi().getContext().toString().equals("BLOCK") &&
            node.getSelector().asRenderString().startsWith("subscribe(") &&
            node.getSelector().getExpressionType() != null &&
            node.getSelector().getExpressionType().getCanonicalText().equals("io.reactivex.disposables.Disposable")) {
        // エラー検出
        context.report(ISSUE, node, context.getLocation(node), "Should handle Disposable");
        return;
    }

    // java
    if (node.getPsi() != null &&
            node.getPsi().getContext() != null &&
            node.getPsi().getContext().getContext() != null &&
            node.getPsi().getContext().getContext().toString().equals("PsiCodeBlock") &&
            node.getSelector().asRenderString().startsWith("subscribe(") &&
            node.getSelector().getExpressionType() != null &&
            node.getSelector().getExpressionType().getCanonicalText().equals("io.reactivex.disposables.Disposable")) {
        // エラー検出
        context.report(ISSUE, node, context.getLocation(node), "Should handle Disposable");
    }
}

Issueを作成

次にIssueを作成しておきましょう。 IssueはこのCustom Lintがどのようなものなのかを説明するクラスです。

public class NotHandledDisposableDetector extends Detector implements UastScanner {
    public static final Issue ISSUE = Issue.create(
            "NotHandledDisposable",
            "Not Handled Disposable",
            "Disposable should be called dispose.",
            Category.CORRECTNESS,
            6,
            Severity.ERROR,
            new Implementation(
                    NotHandledDisposableDetector.class,
                    Scope.JAVA_FILE_SCOPE
                    )
            );
    :

特に大事なのが第一引数のIDです。 設定からdisableにしたり、レベルを変更する際にはこのIDを使用します。 わかりやすいIDにしておきましょう。 その他の引数にはタイトルやサマリなどがあります。 迷った場合は既存のLintを参考にしてみましょう(既存のAndroidのLint: https://android.googlesource.com/platform/tools/base/+/master/lint/libs/lint-checks )。

Registryを作成

Registryには、作成したIssueを登録します。

public class CustomIssueRegistry extends IssueRegistry {
    @Override
    public List<Issue> getIssues() {
        return Collections.singletonList(NotHandledDisposableDetector.ISSUE);
    }
}

今回は NotHandledDisposableDetector だけなので、これのみを返却しています。 最後に CustomIssueRegistryLint-Registry-v2 にセットしてあげればCustom Lintの完成です。

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.kgmyshin.lint.CustomIssueRegistry")
    }
}

作ったCustom Lintをテストする

LintDetectorTest 継承してテストを書いていきます。 基本的には下記のように、対象になるコードを文字列でつくり、lintをかけて最後にexpectメソッドで期待どおりの検出エラー文言になっているのかを確認していくだけです。

public void testXXX() {
    @Language("kotlin") String content = "(対象コード)";
    lint().files(
            kotlin(content)
    ).run().expect("エラー検出時もしくは成功時の文言")
}

Javaの場合は下記のように@Languageアノテーションの中身をJAVAに、TestFile作成時のメソッドkotlinjavaに変更するだけです。

public void testXXX() {
    @Language("JAVA") String content = "(対象コード)";
    lint().files(
            java(content)
    ).run().expect("エラー検出時もしくは成功時の文言")
}

テストの時に複数ファイルを一気にLintにかけたい場合にJavaとKotlinを混ぜると(lint().files(java(xxx), kotlin(xxx))みたいにすると)うまく動かなかったので、そこは考慮しておいたほうが良さそうです。 また対象コードを文字列でコード内に書くと見通しが大変悪くなるので、ファイルに外出しして読み込むようにすることをオススメします。 今まで説明してきた NotHandledDisposableDetector のテストを こちら に書いてますので、参考にしていただければ幸いです。

作ったCustom Lintを使う

以前は作ったCustom Lintを使う際に一手間必要だったのですが、今ではdependenciesのなかに lintChecks (対象) と書くだけです。

dependencies {
    :
    lintChecks project(":checks")
}

Custom Lintを複数のプロジェクトで使い回したい場合は、jarファイルのみを適当な場所に配置して、下記のように書くことで問題なく使えます。

dependencies {
    :
    lintChecks files("lint/custom-lint.jar")
}

ちなみに、jarファイルを作成するには ./gradlew checks:jarchecksはモジュール名) を実行すれば、checks/build/libs配下にできます。

動かしてみる

作ったCustom Lintを動かしてみましょう。

./gradlew lint

:

Errors found:

/../custom-lint-rules/library/src/main/java/test/pkg/MainJava.java:8: Error: Should handle Disposable [NotHandledDisposable]
        Single.just("test").subscribe();
        ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
/../custom-lint-rules/library/src/main/java/test/pkg/MainKt.kt:8: Error: Should handle Disposable [NotHandledDisposable]
        Single.just("aa").subscribe()
        ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
:

無事検出されていますね。またAndroid Studio上でもしっかり検出されていることが確認できました。

f:id:kugimiya-shin:20180327182157p:plain

Custom LintはCIやDangerと組み合わせることでレビュー時の負担を下げることができます。

説明に使ったコードは kgmyshin/custom-lint-rules にあります。 もしCustom Lintを作る場合はこの記事と一緒に参考にしてみてください。

採用情報

現在、DMM.com Groupでは、アプリ開発のエンジニアメンバーを募集しております! 興味のある方はぜひ下記募集ページをご確認下さい! dmm-corp.com

www.wantedly.com