こんにちは。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)、(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#getApplicableUastTypes
と UastScanner#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
についてですが、こちらはUElementHandler
でoverride
するvisit
メソッド対象のUElement
のクラスをリストにして返却します。
今回は UElementHandler#visitQualifiedReferenceExpression
のみを override
する、UQualifiedReferenceExpression
のクラスオブジェクトのみを返却しています。
例えばUElementHandler#visitClass
を override
しているが、Detector#getApplicableUastTypes
でUClass
を返却してない場合、visitClass
を実装していてもまったく呼ばれないので忘れないようにしましょう。
次にvisitQualifiedReferenceExpression
の中身を実装していきましょう。
これを実装するには、対象のNGなコードの時にPSI(Project Structure Interface)がどういう構造になっているかを知る必要があります。
PSIの構造がどうなるかイメージできる人はそのまま実装に入りましょう。
イメージが難しい人には、これを知るためにいくつかツールがあります。自分は psiviewerを使用しております。
早速、kotlinで書いた場合のNGコードの構造を見てみましょう。
この時の該当のPSIは DOT_QUALIFIED_EXPRESSION
となっており、親(Context)である PSIは BLOCK
となっております。
このPSIにとってのセレクターは subscribe()
で、その返り値は io.reactivex.disposables.Disposable
です。
正常時も見ておきましょう。dispose
されたかどうかまで見るのは複雑になるので、今回は変数に落としておけば良しとします
(RxKotlinを使った場合も考えると条件が増えてきますが、趣旨と外れてくるので今回はそこは考慮しません)。
正常時はこのような具合で、 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
だけなので、これのみを返却しています。
最後に CustomIssueRegistry
を Lint-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作成時のメソッドkotlin
をjava
に変更するだけです。
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:jar
(checks
はモジュール名) を実行すれば、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上でもしっかり検出されていることが確認できました。
Custom LintはCIやDangerと組み合わせることでレビュー時の負担を下げることができます。
説明に使ったコードは kgmyshin/custom-lint-rules にあります。 もしCustom Lintを作る場合はこの記事と一緒に参考にしてみてください。
採用情報
現在、DMM.com Groupでは、アプリ開発のエンジニアメンバーを募集しております! 興味のある方はぜひ下記募集ページをご確認下さい! dmm-corp.com