前置き
Fragmentで LifecycleCoroutineScope を得る方法として、下記2種類が挙げられます
- 直接
lifecycleScope
を使う -
viewLifecycleOwner.lifecycleScope
を使う
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で定義する。
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作成の流れは下記です。
-
Detector
クラスとDetector.UastScanner
を継承したクラスを作成する - getApplicableUastTypes()メソッドをオーバーライドして、フィルターしたい要素を指定する
- createUastHandler()メソッドをオーバーライドして、
UElementHandler
クラスをインスタンス化し、フィルターしたい要素に対応するvisit〇〇メソッドをオーバーライドする - オーバーライドしたvisit〇〇メソッド内で、警告を出したい要素に合致したらJavaContext#report()メソッドで警告を表示するようにする
- 作成した
Detector
クラスを指定するIssueを作成し、IssueRegistry
のissuesで定義する
これらの流れは下記サイトを参考にさせていただきました。
詳しく書いてあるので、ぜひこちらも参考にしていただければと思います。
- 社内Android勉強会でAndroid Lintを実装して得た知見 _ BLOG - DeNA Engineering
- [Android] 特定のInterfaceを実装しているかどうかをチェックするCustom Lintを作る _ Wantedly Engineer Blog
私の記事では、特に理解する必要がある「オーバーライドしたvisit〇〇メソッド内で、警告を出したい要素に合致したらJavaContext#report()メソッドで警告を表示するようにする」ステップについて説明したいと思います。
visit〇〇メソッド内で警告を出したい要素を探す
警告を出したい要素を探すには、「PsiViewer」を使いこなす必要があります。
PsiViewerはプラグインにあるので、インストールしてください。
PsiViewerを表示させながら任意のFragmentを開き、今回警告を出したい lifecycleScope
にカーソルを当ててみると、Fragmentの構成(抽象構文木)とその要素の位置を見ることができます。
今回は全Fragmentクラスを確認していきたいため、 UElementHandler
クラスのvisitClass(node: UClass)メソッドをオーバーライドしました。
override fun visitClass(node: UClass) {
// 1つ1つのクラスがここで呼ばれ、警告を出したい要素があるかを確認していく
}
visitClassメソッド内でまず行うことは、そのクラスがFragmentであるかどうかの確認です。
本来はそのクラスが androidx.fragment.app.Fragment
を継承しているかどうかを確認するべきですが、それを行うためには再帰的に全クラスの継承元を確認していくことになり、私の環境では lint タスク実行時にAndroid Studioがフリーズしてしまうことが多かったため、今回はクラス名から判定することにしました。
override fun visitClass(node: UClass) {
val ktClass = node.javaPsi.navigationElement as KtClass
if (ktClass.name?.endsWith("Fragment") != true) {
return
}
}
そしてここからPsiViewerと照らし合わせながら進んでいきます。
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
まで辿ったのが下記になります。
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"
)
}
}
最後に
本当は、クラスの抽象構文木を網羅することで lifeycleScope
呼び出しに対して全て警告を表示する方がいいと思います。しかしFragmentの継承元問題でお話しした通り、再起的な呼び出しはAndroid Studioに負荷がかかってしまうため今回は避けました。
全て警告を表示したい場合は、抽象構文木を効率的に見るアルゴリズム的な思考が必要になりそうです。
以上、今回の調査が他の方の手助けになると幸いです。