- バックエンド / リーダー候補
- PdM
- Webエンジニア(シニア)
- 他18件の職種
- 開発
- ビジネス
こんにちは、Wantedly PeopleでAndroidアプリエンジニアをしている、わくわく(@wakwak3125)です。
最近、CustomLintを作ってちょっとハッピーな気持ちになったのでブログを書きます。その前になぜCustomLintを作ることになったのか、ということについて説明したいと思います。
巨大な基底クラスの存在
みなさんのアプリのソースコードには、BaseFragment
やBaseActivity
などは存在しますでしょうか?このこれら
は、便利なケースもあるのですがこのクラスに依存していることが前提となっている実装が多くなると結合度が高まり、依存関係をうまく切り分けることが難しくなります。
これは特にマルチモジュール化をすすめる際には問題に上がりやすいと思っていて、例えば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
である場合、Fragment
のonResume
で自動的にスクリーンログを送るという実装になっていました。つまり、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
さえ実装しておけば...
これが難しいです。新しいFragment
やActivity
を作った際に、これの実装を忘れると、スクリーンログが自動的に取得できません。また、今回はBaseFragment
からの機能移行なので、既存の画面全てに対してこのチェックを行いたいです。Android Studioの機能を使えばある程度は達成できるかもしれませんが、それも一時的なものですし、今後新しい画面を作る際に忘れてしまっては悲しい思いをします。されに言えば、レビューでこれをもれなくチェックするのも難しいです。人間は忘れる生き物です。
アプリが取得するログはグロースに必要不可欠であり「取れていなかった」ということは時間の無駄になってしまいます。(時間は有限です)なので、できればこのInterface
を実装しているかどうかは常にチェックしたいです。そこで、CustomLintの登場です。今回はNotImplementedTrackableScreenDetector
という名前でFragment
がTrackableScreen
を実装しているかどうかをチェックする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
というモジュールを作りました
- 今回は
Detector
とUastScanner
を実装するUastScanner
というのは、Kotlin/Java両方に対応したASTのスキャナです
getApplicableUastTypes
でなにに対してこのLintを反応させるかを定義するUClass
に対して対応したいので、UClass
を指定
applicableSuperClasses
でどのクラスを実装しているクラスに対してLintを反応させるかを定義する今回はBaseFragment
を指定
- 目的の関数を
override
して実装する - 今回は
visitClass
を使用
- 今回は
Issue
を作るIssue
をIssueRegistry
へと登録する- 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)
}
}
TestFiles
のjava()
とkotlin()
を利用すると簡単にテストデータを作ることが可能です。これらのテストも、Kotlin/Javaの両方のテストケースを書いておくと良いです。
動かしてみる
プロジェクトに対して、./gradlew lint
を実行すると、以下のようにちゃんとレポートされていることがわかります。
Android Studio上でも実装をしていない場合にエラーとしてレポートされます。
以上です。簡単に作ることができ、モジュール分割やクラスの解体のときなどに便利に使えるので興味のある方はぜひやってみてください。
実装の際のコツとしては、PsiViewerなどのツールを利用してASTを見ながら作ることをおすすめします。その上で、デバッガなどを使いながら目的の情報を調べていくと良いでしょう。TDD的に作っていくこともとても有効です。
Lintを作るのは初めてだったので、もっと良い方法があるよって方はぜひ教えていただけると嬉しいです。ありがとうございました〜。