🕵️‍♂️

Compose UIのlintを確認する

2023/01/15に公開

みなさんJetpack Compose使ってますか。僕はあまり使っていないです。
そろそろ勉強しなければと手を付けているのですが、割とlintを触ることが多いので、その観点から
compose ui で公式に定義されているlintを紹介していきます。
lintなのでAndroid Studioで書いているときに気づけますが、全体として何が定義されているかを知ることで気をつけるポイントが予め分かるはずです。

Compose UIで定義されているlint

lintの定義自体はこちらから見ることが出来ます。
https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui-lint/

現在定義されているのは以下のISSUEになります。順番に見ていきましょう。

  • UnnecessaryComposedModifier,
  • ComposableModifierFactory,
  • ModifierFactoryExtensionFunction,
  • ModifierFactoryReturnType,
  • ModifierFactoryUnreferencedReceiver,
  • ModifierParameter,
  • ExitAwaitPointerEventScope,
  • MultipleAwaitPointerEventScopes

UnnecessaryComposedModifier

https://googlesamples.github.io/android-custom-lint-rules/checks/UnnecessaryComposedModifier.md.html

状態のあるModifierを作る時に使う composed ですが、 composable functionを内部で呼ぶときだけ使うべきです。
呼ばない場合はわざわざ composed を使う必要がないですし、 composedを使ったModifierはskippableでなくなり、パフォーマンスが悪化するためです。

// NG
fun Modifier.test(): Modifier = composed {
  this.then(Modifier)
}

// OK
fun Modifier.test(): Modifier = this.then(Modifier)

ComposableModifierFactory

https://googlesamples.github.io/android-custom-lint-rules/checks/ComposableModifierFactory.md.html

Modifier factoryには @Composable をつけません。今ポーズ可能にしたい場合は composed を使いましょう。
toplevelに置かれてcompositionの外でconstructさせるようにするため、と言っていますが、Composalbeの範囲なのでなるべく狭くしてコンポジションの回数を減らしたいということでしょうか。

// NG
@Composable
fun Modifier.fooModifier1(): Modifier {
    val number by remember { mutableStateOf(0) }
    return this.then(Modifier)
}

// OK
fun Modifier.fooModifier1() = composed {
    val number by remember { mutableStateOf(0) }
    this.then(Modifier)
}

ModifierFactoryExtensionFunction

https://googlesamples.github.io/android-custom-lint-rules/checks/ModifierFactoryExtensionFunction.md.html

Modifier fuctoryはModifierの拡張関数で定義すること。
基本的にModifierはチェーンでつなげて行って使うため、その作用にそうようにの意図だと思います。

// NG
fun fooModifier(modifier: Modifier): Modifier {
    return modifier.then(TestModifier)
}

// OK
fun Modifier.fooModifier(): Modifier {
    return this.then(TestModifier)
}

ModifierFactoryReturnType

https://googlesamples.github.io/android-custom-lint-rules/checks/ModifierFactoryReturnType.md.html

Modifier factoryを返す時は子クラスではなく Modifier 自身を返すように。
基本的にComposeには Modifier の型で渡しているので、子クラスで定義すると渡せなくなりますしね。


object TestModifier : Modifier.Element

// NG
fun Modifier.fooModifier(): Modifier.Element {
    return this.then(TestModifier)
}

// OK
fun Modifier.fooModifier(): Modifier {
    return this.then(TestModifier)
}

ModifierFactoryUnreferencedReceiver

https://googlesamples.github.io/android-custom-lint-rules/checks/ModifierFactoryUnreferencedReceiver.md.html

Modifierがチェーンで渡ってきたら、ちゃんとそのModifierを使ってModifierを作れということです。
下のOKのように、 this を直接使っていないとしても別関数( barModifier )を呼ぶならlintは通ります。thisを使っていないとしたら barModifier 側でlintに引っかかるためです。

// NG
fun Modifier.fooModifier(): Modifier {
    return HogeModifier
}

// OK
fun Modifier.fooModifier(): Modifier {
    return this.then(HogeModifier)
}

// OK
fun Modifier.barModifier(): Modifier = this.then(BarModifier)

fun Modifier.fooModifier(): Modifier {
    return barModifier()
}

ModifierParameterDetector

https://googlesamples.github.io/android-custom-lint-rules/checks/ModifierParameter.md.html
コンポーズ可能関数に渡されるパラメータの Modifier に関するルールになります。

  • modifier と名前をつける
  • Modifier.element などではなく、 Modifier 型で宣言
  • デフォルト値を持っていないか、 持っていたとしても Modifier
  • デフォルト引数付きのパラメータが複数あった場合、そのうち一番最初に宣言される
// NG
@Composable
fun ButtonWrongModifierName(
    buttonModifier: Modifier
) {}

fun ButtonWrongModifierType(
    modifier: Modifier.Element
) {}

fun ButtonDefaultModifier(
    modifier: Modifier = HogeModifier
) {}

fun ButtonModifierLastParameter(
    onClick: () -> Unit,
    elevation: Float = 5f,
    modifier: Modifier = HogeModifier
) {}

// OK
@Composable
fun Button(
    onClick: () -> Unit,
    content: @Composable () -> Unit,
    modifier: Modifier = Modifier,
    elevation: Float = 5f,
) {}

ExitAwaitPointerEventScope

他のlintだと存在するドキュメントが存在しないため、もとのソースコードを張っておきます。
https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui-lint/src/main/java/androidx/compose/ui/lint/MultipleAwaitPointerEventScopesDetector.kt

awaitPointerEventScope はユーザーのタップイベントを待つスコープです。
PointerInputEventはこのスコープでキューイングされて処理されていきますが、例えば続けてこのブロックを宣言した場合、1つ目のスコープを抜けたタイミングで来たイベントが落ちることがあり、これがキューされなくなってしまいます。

ReturnFromAwaitPointerEventScope

https://googlesamples.github.io/android-custom-lint-rules/checks/ReturnFromAwaitPointerEventScope.md.html

awaitPointerEvent の返り値をreturnしたり引数にとるのは禁止されています。スコープをでたことによってイベントが保証されなくなるためです。

公式ドキュメントにも awaitPointerEventScope を使っているサンプルがありますが、このlintが引っかかってしまいます。
https://developer.android.com/jetpack/compose/kotlin#coroutines

// NG
@Composable
fun MoveBoxWhereTapped() {
    // Creates an `Animatable` to animate Offset and `remember` it.
    val animatedOffset = remember {
        Animatable(Offset(0f, 0f), Offset.VectorConverter)
    }

    Box(
        // The pointerInput modifier takes a suspend block of code
        Modifier.fillMaxSize().pointerInput(Unit) {
            // Create a new CoroutineScope to be able to create new
            // coroutines inside a suspend function
            coroutineScope {
                while (true) {
                    // Wait for the user to tap on the screen
                    val offset = awaitPointerEventScope {
                        awaitFirstDown().position
                    }
                    // Launch a new coroutine to asynchronously animate to where
                    // the user tapped on the screen
                    launch {
                        // Animate to the pressed position
                        animatedOffset.animateTo(offset)
                    }
                }
            }
        }
    )
}

// OK

@Composable
fun MoveBoxWhereTapped() {
    // Creates an `Animatable` to animate Offset and `remember` it.
    val animatedOffset = remember {
        Animatable(Offset(0f, 0f), Offset.VectorConverter)
    }

    Box(
        // The pointerInput modifier takes a suspend block of code
        Modifier.fillMaxSize().pointerInput(Unit) {
            // Create a new CoroutineScope to be able to create new
            // coroutines inside a suspend function
            coroutineScope {
                while (true) {
                    awaitPointerEventScope {
                        val offset = awaitFirstDown().position
                        launch {
                            // Animate to the pressed position
                            animatedOffset.animateTo(offset)
                        }
                    }
                }
            }
        }
    )
}

おわりに

今回はcompose uiで定義されているlintについてまとめました。

パフォーマンスを悪化させる不要なComposableの宣言や、Modifierへのthisの利用し忘れを防ぐlintなどがあり、悪い書き方を制限してくれるのは特にCompose初心者の自分からありがたいですね。

Lintはquickfixを用意されてることも多いのでワンボタンで直せてしまいますが、根本原因を理解した上で行いたいですね。

Discussion