【Android】FragmentでlifecycleScopeを使用している場合に警告を出す

# 前置き
Fragmentで [LifecycleCoroutineScope](https://developer.android.com/reference/kotlin/androidx/lifecycle/LifecycleCoroutineScope) を得る方法として、下記２種類が挙げられます
* 直接 `lifecycleScope` を使う
* `viewLifecycleOwner.lifecycleScope` を使う
``` Fragment.kt
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    lifecycleScope.launch { }
    viewLifecycleOwner.lifecycleScope.launch { }
}
```
これらはスコープの生存期間が異なるため、誤って使用すると意図しない動作を引き起こすことがあります。
違いについて、詳しくは下記記事をオススメします。
* [【Android】LifecycleOwner として Fragment 自身ではなく viewLifecycleOwner を使うべき場合 #ライフサイクル - Qiita](https://qiita.com/sdkei/items/100580137e7b2f705205)

私の所属しているチームでは「Activityでは `lifecycleScope` 、Fragmentでは `viewlifecycleOwner.lifecycleScope` を推奨する」というルールがあります。
しかし、ActivityとFragmentが共存しているようなリポジトリでは誤ってFragmentで `lifecycleScope` を使用してしまうリスクがあったり、誤って使用されていてもプルリクエストで気付くことが難しいという問題点がありました。

# 今回やること
前置きにある課題を解決すべく、Fragmentで `lifecycleScope` を使用している場合に警告を出すカスタムLintを作成します。

# 結論
下記で作成したIssueを `IssueRegistry` のissuesで定義する。
``` FragmentLifecycleDetector.kt
class FragmentLifecycleDetector : Detector(), Detector.UastScanner {
    override fun getApplicableUastTypes(): List<Class<out UElement>> {
        return listOf(UClass::class.java)
    }

    override fun createUastHandler(context: JavaContext): UElementHandler {
        return object : UElementHandler() {
            override fun visitClass(node: UClass) {
                val ktClass = node.javaPsi.navigationElement as KtClass
                if (ktClass.name?.endsWith("Fragment") != true) {
                    return
                }

                val functions =
                    ktClass.body?.children?.filterIsInstance<KtNamedFunction>().orEmpty()
                val blocks =
                    functions.map { it.bodyBlockExpression }.filterIsInstance<KtBlockExpression>()
                val dotQualifiedExpressions =
                    blocks.flatMap { it.children.filterIsInstance<KtDotQualifiedExpression>() }

                val lifecycleScopeReferenceExpressions = dotQualifiedExpressions
                    .flatMap { it.children.filterIsInstance<KtNameReferenceExpression>() }
                    .filter { it.firstChild.text == "lifecycleScope" }

                lifecycleScopeReferenceExpressions.forEach { expression ->
                    context.report(
                        LIFECYCLE_SCOPE_ISSUE,
                        node,
                        context.getLocation(expression),
                        "Consider using viewLifecycleOwner.lifecycleScope instead of lifecycleScope"
                    )
                }
            }
        }
    }

    companion object {
        val LIFECYCLE_SCOPE_ISSUE = Issue.create(
            id = "LIFECYCLE_SCOPE_ISSUE",
            briefDescription = "Consider using viewLifecycleOwner.lifecycleScope",
            explanation = "lifecycleScope has a longer lifecycle than viewLifecycleOwner.lifecycleScope, which can lead to unexpected behavior if used unintentionally.",
            implementation = Implementation(
                FragmentLifecycleDetector::class.java,
                Scope.JAVA_FILE_SCOPE
            )
        )
    }
}
```
※ 上記は lifecycleScopeを `lifecycleScope.launch { }` で呼び出すことを想定しており、単体での宣言や代入に使用された場合は考慮していません

## 環境
* com.android.tools.lint:lint-api:31.2.2
* Android Studio Ladybug | 2024.2.1 Patch 1

# 作成までの流れ
カスタムLint作成の流れは下記です。
1. `Detector` クラスと `Detector.UastScanner` を継承したクラスを作成する
1. getApplicableUastTypes()メソッドをオーバーライドして、フィルターしたい要素を指定する
1. createUastHandler()メソッドをオーバーライドして、 `UElementHandler` クラスをインスタンス化し、フィルターしたい要素に対応するvisit〇〇メソッドをオーバーライドする
1. オーバーライドしたvisit〇〇メソッド内で、警告を出したい要素に合致したらJavaContext#report()メソッドで警告を表示するようにする
1. 作成した `Detector` クラスを指定するIssueを作成し、`IssueRegistry` のissuesで定義する

これらの流れは下記サイトを参考にさせていただきました。
詳しく書いてあるので、ぜひこちらも参考にしていただければと思います。
* [社内Android勉強会でAndroid Lintを実装して得た知見 _ BLOG - DeNA Engineering](https://engineering.dena.com/blog/2020/12/getting-started-with-android-lint)
* [[Android] 特定のInterfaceを実装しているかどうかをチェックするCustom Lintを作る _ Wantedly Engineer Blog](https://www.wantedly.com/companies/wantedly/post_articles/194318)

私の記事では、特に理解する必要がある「オーバーライドしたvisit〇〇メソッド内で、警告を出したい要素に合致したらJavaContext#report()メソッドで警告を表示するようにする」ステップについて説明したいと思います。

## visit〇〇メソッド内で警告を出したい要素を探す
警告を出したい要素を探すには、「PsiViewer」を使いこなす必要があります。
PsiViewerはプラグインにあるので、インストールしてください。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/610553/fb0037ae-466f-5c94-f008-88b3480de655.png)

PsiViewerを表示させながら任意のFragmentを開き、今回警告を出したい `lifecycleScope` にカーソルを当ててみると、Fragmentの構成（抽象構文木）とその要素の位置を見ることができます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/610553/19000245-153a-b297-d107-65c48f7d6605.png)

今回は全Fragmentクラスを確認していきたいため、 `UElementHandler` クラスのvisitClass(node: UClass)メソッドをオーバーライドしました。
``` FragmentLifecycleDetector.kt
override fun visitClass(node: UClass) {
    // 1つ1つのクラスがここで呼ばれ、警告を出したい要素があるかを確認していく
}
```

visitClassメソッド内でまず行うことは、そのクラスがFragmentであるかどうかの確認です。
本来はそのクラスが `androidx.fragment.app.Fragment` を継承しているかどうかを確認するべきですが、それを行うためには再帰的に全クラスの継承元を確認していくことになり、私の環境では lint タスク実行時にAndroid Studioがフリーズしてしまうことが多かったため、今回はクラス名から判定することにしました。
``` FragmentLifecycleDetector.kt
override fun visitClass(node: UClass) {
    val ktClass = node.javaPsi.navigationElement as KtClass
    if (ktClass.name?.endsWith("Fragment") != true) {
        return
    }
}
```

そしてここからPsiViewerと照らし合わせながら進んでいきます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/610553/d39c9951-d749-33e4-a7af-b51e6c17951e.png)


PsiViewer上でCLASSという項目を見ると、`org.jetbrains.kotlin.psi.KtClass` でできていることが分かるので、visitClass()で取得した `UClass` からそれに変換すればいいわけです。
さらに CLASS（KtClass） > CLASS_OBODY（KtClassBody） > FUN（KtNamedFunction）と辿っていくと、 `lifecycleScope` があるところまで辿り着けます。

しかし、私が一番詰まったポイントとしてはここでした。
なぜならば、UClass#javaPsiメソッドで取得できるのは `JavaPsi` クラスですが `KtClass` にキャストすることができず、メソッドだけを抽出することはできても `KtNamedFunction` クラスにキャストする、ということができませんでした。

そしてネットを漂流し続けたところ、下記Stack overflowに辿り着くことができました。
* [intellij idea - modify a Kotlin class - Stack Overflow](https://stackoverflow.com/questions/41705925/modify-a-kotlin-class)

質問の内容はさておき、回答にはこう書いてあります。（日本語訳）
> JavaPsiFacade を使って Kotlin クラスを検索すると、ライトクラスが返される。ライトクラスは、クラスファイル内の情報に基づくだけの浅い表現である。PSI 要素を追加するためには、navigationElement を呼び出す必要がある。その後、IJ がソースを解析し、修正可能な完全な PSI ツリーを構築する。
しかし、クラスがKotlinクラスの場合、navigationElementはPsiClassから派生していないKtClassを返します。

上記回答や、PsiElement#navigationElementメソッドのJavaDocを読んでも完全に理解することはできませんでしたが、私は下記のように理解しました
* `JavaPsi` を使ってKotlinクラスを検索しても、クラスファイル内の浅い情報にしか辿り着けない
* PsiElement#navigationElementメソッドを呼び出すことで、IntelliJ(今回はAndroid Studio)がソースを解析し、PsiViewerで見られるような完全な抽象構文木を構築する

これを用い、 `lifecycleScope` まで辿ったのが下記になります。
``` FragmentLifecycleDetector.kt
override fun visitClass(node: UClass) {
    val ktClass = node.javaPsi.navigationElement as KtClass
    if (ktClass.name?.endsWith("Fragment") != true) {
        return
    }
    
    val functions =
        ktClass.body?.children?.filterIsInstance<KtNamedFunction>().orEmpty()
    val blocks =
        functions.map { it.bodyBlockExpression }.filterIsInstance<KtBlockExpression>()
    val dotQualifiedExpressions =
        blocks.flatMap { it.children.filterIsInstance<KtDotQualifiedExpression>() }
    
    val lifecycleScopeReferenceExpressions = dotQualifiedExpressions
        .flatMap { it.children.filterIsInstance<KtNameReferenceExpression>() }
        .filter { it.firstChild.text == "lifecycleScope" }
    
    lifecycleScopeReferenceExpressions.forEach { expression ->
        context.report(
            LIFECYCLE_SCOPE_ISSUE,
            node,
            context.getLocation(expression),
            "Consider using viewLifecycleOwner.lifecycleScope instead of lifecycleScope"
        )
    }
}
```
こうして警告を出すことができました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/610553/307697ea-38b9-400c-efd6-dc4b8a9cdb3c.png)

# 最後に
本当は、クラスの抽象構文木を網羅することで `lifeycleScope` 呼び出しに対して全て警告を表示する方がいいと思います。しかしFragmentの継承元問題でお話しした通り、再起的な呼び出しはAndroid Studioに負荷がかかってしまうため今回は避けました。
全て警告を表示したい場合は、抽象構文木を効率的に見るアルゴリズム的な思考が必要になりそうです。

以上、今回の調査が他の方の手助けになると幸いです。