1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Posted at

前置き

Fragmentで LifecycleCoroutineScope を得る方法として、下記2種類が挙げられます

  • 直接 lifecycleScope を使う
  • viewLifecycleOwner.lifecycleScope を使う
Fragment.kt
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    lifecycleScope.launch { }
    viewLifecycleOwner.lifecycleScope.launch { }
}

これらはスコープの生存期間が異なるため、誤って使用すると意図しない動作を引き起こすことがあります。
違いについて、詳しくは下記記事をオススメします。

私の所属しているチームでは「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 を継承したクラスを作成する
  2. getApplicableUastTypes()メソッドをオーバーライドして、フィルターしたい要素を指定する
  3. createUastHandler()メソッドをオーバーライドして、 UElementHandler クラスをインスタンス化し、フィルターしたい要素に対応するvisit〇〇メソッドをオーバーライドする
  4. オーバーライドしたvisit〇〇メソッド内で、警告を出したい要素に合致したらJavaContext#report()メソッドで警告を表示するようにする
  5. 作成した Detector クラスを指定するIssueを作成し、IssueRegistry のissuesで定義する

これらの流れは下記サイトを参考にさせていただきました。
詳しく書いてあるので、ぜひこちらも参考にしていただければと思います。

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

visit〇〇メソッド内で警告を出したい要素を探す

警告を出したい要素を探すには、「PsiViewer」を使いこなす必要があります。
PsiViewerはプラグインにあるので、インストールしてください。
image.png

PsiViewerを表示させながら任意のFragmentを開き、今回警告を出したい lifecycleScope にカーソルを当ててみると、Fragmentの構成(抽象構文木)とその要素の位置を見ることができます。
image.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

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

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

そしてネットを漂流し続けたところ、下記Stack overflowに辿り着くことができました。

質問の内容はさておき、回答にはこう書いてあります。(日本語訳)

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

最後に

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

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

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?