This page is intended for users in Germany. Go to the page for users in United States.

[Android] 特定のInterfaceを実装しているかどうかをチェックするCustom Lintを作る

こんにちは、Wantedly PeopleでAndroidアプリエンジニアをしている、わくわく(@wakwak3125)です。
最近、CustomLintを作ってちょっとハッピーな気持ちになったのでブログを書きます。その前になぜCustomLintを作ることになったのか、ということについて説明したいと思います。

巨大な基底クラスの存在

みなさんのアプリのソースコードには、BaseFragmentBaseActivityなどは存在しますでしょうか?このこれらは、便利なケースもあるのですがこのクラスに依存していることが前提となっている実装が多くなると結合度が高まり、依存関係をうまく切り分けることが難しくなります。

これは特にマルチモジュール化をすすめる際には問題に上がりやすいと思っていて、例えばBaseFragment
みたいな基底クラスが存在していて、色々なクラスがBaseFragmentを前提としている実装になっている場合、モジュールを切り出すにしても、特定のクラスの実装に密結合しているため、引き剥がすのが大変です。

BaseFragmentの例

// BaseFragmentは他のクラスにも大量に依存している
abstract class BaseFragment {
fun doSomething()
}

class HogeFragment: BaseFragment() {
override fun doSomthing() {
//...
}
}

class HogeClass {
fun doAwsomeSomething(fragment: BaseFragment) {
fragment.doSomething()
}
}

このような状態でマルチモジュール化を進めようとすると、すべてのモジュールがこのBaseFragment
を持つモジュールに依存する必要があるため、必要以上に依存されるモジュールを生み出すことになってしまいます。

例えば、前述のHogeClassを持つモジュール(ここでは:hogeとします)を作るとします。:hoge
BaseFragmentに依存しているため、BaseFragmentを持つ別のモジュールに依存する必要があります。BaseFragment自身をうまく切り出すことができれば、それでも良いのかもしれませんが数年間メンテナンスされているアプリケーションでそこまでうまく切り出すことは難しく、いろいろなクラスを芋づる式に引き連れた巨大なモジュールに依存することになることが予想されます。

この状態を解決する方法としては、BaseFragmentを少しずつ解体していくことが必要です。その手法の一つとして、意味のあるまとまりでBaseFragmentの機能をInterfaceとして切り出していくことで、達成することが可能です。

doSomething()をInterfaceに切り出してみる例

interface IDoSomething {
fun doSomething()
}

class HogeFragment: Fragment(), IDoSomething {
override fun doSomthing() {
//...
}
}

class HogeClass {
fun doAwsomeSomething(iDoSomething: IDoSomething) {
iDoSomething.doSomething()
}
}

こうすることで、BaseFragmentへの直接的な依存がなくなり、HogeClassを持つモジュールを切り出したい際には、IDoSomething:hogeに同梱することで意味あるまとまりにすることができます。

最近切り出したモジュール

今回、トラッキングに関するコードを含む、:analyticsというモジュールを作りました。Wantedly Peopleには:appの中に、TrackingUtilsというクラスが存在しており、そのクラスが様々なトラッキング用の関数を持っています。

TrackingUtilsの例

public void recordAction(BaseFragment fragment, TrackingAction action ...) {
//...
}

また、自動的にスクリーンログを取得するために、BaseFragmentにはautoTrackというフラグがメンバ変数で用意されていました。これがtrueである場合、FragmentonResumeで自動的にスクリーンログを送るという実装になっていました。つまり、BaseFragmentへの依存を断ち切らないことにはモジュールの切り出しが難しいという状態です。

そこでTrackingUtilsが使用しているBaseFragmentの機能をまとめたTrackableScreenというInterface
を用意しました。

interface TrackableScreen {
@JvmDefault
val autoTrack: Boolean
get() = true
//...
}

そして、自動的にスクリーンログを取ることを達成するために、FragmentLifecycleCallbackを利用し以下のような雰囲気のAutoTrackerというクラスを用意しました。

class AutoTracker : Application.ActivityLifecycleCallbacks {
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
if (activity is TrackableScreen) {
if (activity.autoTrack) {
Tracker.logScreen(activity)
}
}
if (activity is FragmentActivity) {
activity.supportFragmentManager.registerFragmentLifecycleCallbacks(
object : FragmentManager.FragmentLifecycleCallbacks() {
override fun onFragmentResumed(fm: FragmentManager, f: Fragment) {
super.onFragmentResumed(fm, f)
if (f is TrackableScreen) {
if (f.autoTrack) {
Tracker.logScreen(f)
}
}
}
},
false // recursiveはお好みで。
)
}
}
//...
}

これで、BaseFragmentへの依存度を下げ、TrackableScreenさえ実装しておけば自動的にトラッキングがされる状態になったわけです。

TrackableScreenさえ実装しておけば...

これが難しいです。新しいFragmentActivityを作った際に、これの実装を忘れると、スクリーンログが自動的に取得できません。また、今回はBaseFragmentからの機能移行なので、既存の画面全てに対してこのチェックを行いたいです。Android Studioの機能を使えばある程度は達成できるかもしれませんが、それも一時的なものですし、今後新しい画面を作る際に忘れてしまっては悲しい思いをします。されに言えば、レビューでこれをもれなくチェックするのも難しいです。人間は忘れる生き物です。

アプリが取得するログはグロースに必要不可欠であり「取れていなかった」ということは時間の無駄になってしまいます。(時間は有限です)なので、できればこのInterfaceを実装しているかどうかは常にチェックしたいです。そこで、CustomLintの登場です。今回はNotImplementedTrackableScreenDetector
という名前でFragmentTrackableScreenを実装しているかどうかをチェックするLintを作りました。

NotImplementedTrackableScreenDetector

CustomLintを作る際に登場するのは以下のものです。

  • Detector
  • Detector.UastScanner
  • Issue
  • IssueRegistry

実際にコードを見たほうが早いと思うので見てみましょう。

class NotImplementedTrackableScreenDetector : Detector(), Detector.UastScanner {

override fun getApplicableUastTypes(): List<Class<out UElement>>? = listOf(UClass::class.java)

override fun applicableSuperClasses(): List<String>? = UI_CLASSES

override fun visitClass(context: JavaContext, declaration: UClass) {
super.visitClass(context, declaration)
check(declaration, context)
}
}

Lintをつくる手順としては

  • CustomLint用のモジュールを作る
    • 今回は:checksというモジュールを作りました
  • DetectorUastScannerを実装する
    • UastScannerというのは、Kotlin/Java両方に対応したASTのスキャナです
  • getApplicableUastTypesでなにに対してこのLintを反応させるかを定義する
    • UClassに対して対応したいので、UClassを指定
  • applicableSuperClassesでどのクラスを実装しているクラスに対してLintを反応させるかを定義する
    • 今回はBaseFragmentを指定
  • 目的の関数をoverrideして実装する
    • 今回はvisitClassを使用
  • Issueを作る
  • IssueIssueRegistryへと登録する
  • CustomLintのモジュールを利用する

上記をすべて適切に実装すれば、Lintが完成。利用することができます。実際のLintに関しては、check(UClass, JavaContext)という関数の中で実装しています。

check(UClass, JavaContext)

KotlinとJavaでASTが違うので、それぞれのファイルごとにチェックを行います。

private fun check(
declaration: UClass,
context: JavaContext
) {
when(FileType.from(declaration)) {
FileType.JAVA -> checkJavaFile(context, declaration)
FileType.KOTLIN -> checkKotlinFile(context, declaration)
FileType.OTHER -> {}
}
}

Kotlin/Javaそれぞれで、TrackableScreenを実装しているかチェックします。

private fun checkKotlinFile(
context: JavaContext,
declaration: UClass
) {
when (context.evaluator.getQualifiedName(declaration)) {
in UI_CLASSES -> return
else -> if (declaration.uastDeclarations
.firstOrNull()
?.context
?.toString()
?.contains("TrackableScreen") == false
) {
reportIssue(context, declaration)
}
}
}

private fun checkJavaFile(
context: JavaContext,
declaration: UClass
) {
when (context.evaluator.getQualifiedName(declaration)) {
in UI_CLASSES -> return
else -> if ((declaration as AbstractJavaUClass).uastAnchor
?.uastParent
?.toString()
?.contains("TrackableScreen") == false
) {
reportIssue(context, declaration)
}
}
}
enum class FileType(val fileExtension: String) {
JAVA(".java"),
KOTLIN(".kt"),
OTHER("");
companion object {
fun from(declaration: UClass): FileType = when {
declaration.context.toString().endsWith(JAVA.fileExtension) -> JAVA
declaration.context.toString().endsWith(KOTLIN.fileExtension) -> KOTLIN
else -> OTHER
}
}
}

発見したエラーをレポートします。

private fun reportIssue(
context: JavaContext,
declaration: UClass
) {
context.report(
ISSUE,
declaration,
context.getNameLocation(declaration),
"Subclass of Fragment should be implemented TrackableScreen"
)
}

上記を実装すると一旦はLintのコードは完成します。次に、これをIssueという形でIssueRegistry
へと登録する必要があります。

IssueとIssueRegistry

Issueは下記のような形で生成します。CategoryやPriorityなどは適宜読みかえてください。

private const val ISSUE_ID = "NotImplementedTrackableScreen"
private const val BRIEF_DESCRIPTION = "Not Implemented TrackableScreen"
private const val EXPLANATION = "Fragment should be implemented TrackableScreen"
private const val PRIORITY = 5
val ISSUE = Issue.create(
ISSUE_ID,
BRIEF_DESCRIPTION,
EXPLANATION,
Category.CORRECTNESS,
PRIORITY,
Severity.ERROR,
Implementation(
NotImplementedTrackableScreenDetector::class.java,
EnumSet.of(Scope.JAVA_FILE)
)
)

IssueRegistry

IssueRegistryを継承した、クラスを実装します。
余談ですが、YashimaというのはWantedly Peopleのプロジェクトネームです。

class YashimaIssueRegistry : IssueRegistry() {

override val api: Int
get() = CURRENT_API

override val issues: List<Issue>
get() = listOf(
NotImplementedTrackableScreenDetector.ISSUE
)
}

これをモジュールレベルのbuild.gradleで指定します。

checks/build.gradle

apply plugin: 'java-library'
apply plugin: 'kotlin'

dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
compileOnly Dependencies.kotlinStdLib
compileOnly "com.android.tools.lint:lint-api:26.5.1"
compileOnly "com.android.tools.lint:lint-checks:26.5.1"
testImplementation "com.android.tools.lint:lint:26.5.1"
testImplementation "com.android.tools.lint:lint-tests:26.5.1"
testImplementation "com.android.tools:testutils:26.5.1"
}

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)
// ここにIssueRegistryを登録する
attributes("Lint-Registry-v2": "com.wantedly.android.namecard_scanner.checks.YashimaIssueRegistry")
}
}

最後にこのLintを利用したいモジュールのbuild.gradleで下記のように指定することで、Lintが有効になります。

dependencies {
lintChecks project(':checks')
}

CustomLintのUnitTest

作ったLintに対してテストを書いておくと良いでしょう。
ここでは例として、1ケースのみを用意しました。

class NotImplementedTrackableScreenDetectorTest {

private val baseFragment = java(
"""
package com.wantedly.android.namecard_scanner.legacy.core;

import androidx.fragment.app.Fragment;

public abstract class BaseFragment extends Fragment {}
"""
).indented()

private val trackableScreen = kotlin(
"""
package com.wantedly.android.namecard_scanner.analytics

interface TrackableScreen
"""
).indented()

@Test
fun `When the Fragment class does not implement the TrackableScreen, there is an error`() {
lint().files(
baseFragment,
trackableScreen,
kotlin(
"""
package test

import com.wantedly.android.namecard_scanner.legacy.core.BaseFragment

class SampleFragment: BaseFragment()
"""
).indented()
).issues(NotImplementedTrackableScreenDetector.ISSUE)
.allowMissingSdk(true)
.run()
.expectErrorCount(1)
}
}

TestFilesjava()kotlin()を利用すると簡単にテストデータを作ることが可能です。これらのテストも、Kotlin/Javaの両方のテストケースを書いておくと良いです。

動かしてみる

プロジェクトに対して、./gradlew lintを実行すると、以下のようにちゃんとレポートされていることがわかります。

Android Studio上でも実装をしていない場合にエラーとしてレポートされます。

以上です。簡単に作ることができ、モジュール分割やクラスの解体のときなどに便利に使えるので興味のある方はぜひやってみてください。

実装の際のコツとしては、PsiViewerなどのツールを利用してASTを見ながら作ることをおすすめします。その上で、デバッガなどを使いながら目的の情報を調べていくと良いでしょう。TDD的に作っていくこともとても有効です。

Lintを作るのは初めてだったので、もっと良い方法があるよって方はぜひ教えていただけると嬉しいです。ありがとうございました〜。

Wantedly, Inc.'s job postings
18 Likes
18 Likes

Weekly ranking

Show other rankings