実践 脱Modifier.composed

実践 脱Modifier.composed

タクシーアプリ『GO』のAndroidアプリを開発している山本です。
Android アプリの UI 開発ツールキットである Jetpack Compose ライブラリでパフォーマンス向上のために既存の Modifier.composed を使った実装を Modifier.Node に置き換えた実例を紹介します。

Modifier.composedについて

Jetpack Compose で UI の装飾や動作を設定する Modifier をカスタム実装するアプローチとして Modifier.composed メソッドが用意されています。 Modifier.composed メソッドを使うことで、保持した状態をもとに複数の装飾や動作を設定した Modifier を返すような複雑なカスタム修飾子を作成できます。
ただし、この実装アプローチにはパフォーマンス上の問題が生じているため非推奨となっています。代わりに Modifier.Node を使った実装が推奨されています。

Jetpack Compose のパフォーマンス改善のために、タクシーアプリ『GO』で Modifier.compose を使っていた実装を Modifier.Node に移行した事例を紹介します。

Modifier.composedを使った既存実装

タクシーアプリ『GO』ではメイン導線として一番押してほしい青いボタン用のカスタム修飾子を Modifier.composed で実装していました。

初回導線 今すぐ呼ぶ導線
usage 1 usage 2

このカスタム修飾子では、タップ時にスケールアニメーションと触覚フィードバックをおこない、アニメーションの状態管理のために Modifier.composed を使っています。
具体的には、以下コードのようにタップ開始から少しの間をタップ中と判定してスケールを変更します。あわせてタップ開始時に触覚フィードバックをおこないます。

/**
 * タップ直後に触感フィードバックとScaleアニメーションをおこなう[Modifier]
 */
private fun Modifier.animateScaleClickable(
    enabled: Boolean,
    hapticEnabled: Boolean = true,
): Modifier = composed {
    val interactionSource = remember { MutableInteractionSource() }
    var buttonPressed by remember { mutableStateOf(false) }
    var duringAnimateDelay by remember { mutableStateOf(false) }
    val buttonDelayScope = rememberCoroutineScope()
    val haptic = LocalHapticFeedback.current

    // タップ中もしくは少しだけタップした直後にスケールをアニメーションする
    val scale by animateFloatAsState(
        if (buttonPressed || duringAnimateDelay) BUTTON_SCALE_CLICKED else BUTTON_SCALE_DEFAULT,
    )
    scale(scale)
        .pointerInput(interactionSource, enabled) {
            if (enabled.not()) return@pointerInput
            awaitEachGesture {
                // 最初にボタンが押されたイベントを待つ
                awaitFirstDown(requireUnconsumed = false)
                buttonPressed = true
                duringAnimateDelay = true

                if (hapticEnabled) {
                    // ボタン押下直後に触感フィードバック
                    haptic.performHapticFeedback(HapticFeedbackType.LongPress)
                }

                buttonDelayScope.launch {
                    delay(100L)
                    duringAnimateDelay = false
                }

                // ボタンから指が離れたイベントを待つ
                waitForUpOrCancellation()
                buttonPressed = false
            }
        }
} 

上記のように Modifier.composed メソッドによる実装ではUIの状態更新による Recomposition が発生した場合のパフォーマンスに問題があります。
そのため代替として提供されている Modifier.Node を使った実装に移行しました。

Modifier.Nodeへの移行

Modifier.Node では Modifier の役割を Node クラスと Element クラスに分割します。
Node クラスは Recomposition が発生した場合も再利用することができ、Modifier.composed より高いパフォーマンスになるよう設計されています。

// Before
fun Modifier.animateScaleClickable() = composed { ... }

// After
fun Modifier.animateScaleClickable() = this.then(AnimateScaleClickableElement())
class AnimateScaleClickableElement: ModifierNodeElement<AnimateScaleClickableNode>()
class AnimateScaleClickableNode: Modifier.Node()

Element クラスにはカスタム修飾子を作成または更新するデータを保持します。
今回実装するカスタム修飾子は Modifier.composed の既存実装と変わらず、2つの Boolean を引数にとるため、そのまま Element クラスとして実装します。

// equals, hashCode によって Modifier に変更が必要か判定する(data class だと自動で実装される)
data class AnimateScaleClickableElement(
    private val enabled: Boolean,
    private val hapticEnabled: Boolean = true,
) : ModifierNodeElement<AnimateScaleClickableNode>() {
    // Modifier.Node のインスタンスを生成する
    override fun create() = AnimateScaleClickableNode(enabled, hapticEnabled)

    // Modifier の変更がある場合、 Modifier.Node を更新する
    override fun update(node: AnimateScaleClickableNode) {
        node.enabled = enabled
        node.hapticEnabled = hapticEnabled
    }

    override fun InspectorInfo.inspectableProperties() {
        name = "animateScaleClickable"
        properties["enabled"] = enabled
        properties["hapticEnabled"] = hapticEnabled
    }
}

Node クラスにはカスタム修飾子の機能を実装します。

既存実装にある Modifier.scale(), Modifier.pointerInput(), CompositionLocal の各機能を androidx.compose.ui.node にある各 Node インターフェースで再実装します。

  • Modifier.scale()DrawModifierNode インターフェースでスケールアニメーションをおこなう
  • Modifier.pointerInput()PointerInputModifierNode インターフェースで最初にタップされたイベントと指が離れたイベントを判定する
  • CompositionLocalCompositionLocalConsumerModifierNode インターフェースで CompositionLocal の値を取得する
class AnimateScaleClickableNode(
    var enabled: Boolean,
    var hapticEnabled: Boolean = true,
) : Modifier.Node(),
    DrawModifierNode, // Modifier.scale を実装する
    PointerInputModifierNode, // Modifier.pointerInput を実装する
    CompositionLocalConsumerModifierNode { // CompositionLocal を実装する

    // タップ中とタップ直後の状態を管理して、スケールアニメーションする
    var buttonPressed = mutableStateOf(false)
    var duringAnimateDelay = mutableStateOf(false)

    /*
     [BEFORE]
     val scale by animateFloatAsState(...)
     Mosifier.scale(scale)
     */
    override fun ContentDrawScope.draw() {
        val scale = if (buttonPressed.value || duringAnimateDelay.value) {
            BUTTON_SCALE_CLICKED
        } else {
            BUTTON_SCALE_DEFAULT
        }
        scale(scale) { this@draw.drawContent() }
    }

    /*
     [BEFORE]
     Modifier.pointerInput(interactionSource, enabled) {
       // 最初にボタンが押されたイベントを待つ + ボタンから指が離れたイベントを待つ
     }
     */
    override fun onPointerEvent(pointerEvent: PointerEvent, pass: PointerEventPass, bounds: IntSize) {
        // ボタン不可の場合は何もしない
        if (!enabled) return

        when (pointerEvent.type) {
            PointerEventType.Press -> {
                if (buttonPressed.value) return

                buttonPressed.value = true
                duringAnimateDelay.value = true
                if (hapticEnabled) {
                    // ボタン押下直後に1回目の触感フィードバック
                    /*
                     [BEFORE]
                     val haptic = LocalHapticFeedback.current
                     */
                    currentValueOf(LocalHapticFeedback)
                        .performHapticFeedback(HapticFeedbackType.LongPress)
                }
                coroutineScope.launch {
                    delay(100L)
                    duringAnimateDelay.value = false
                }
            }

            PointerEventType.Release -> {
                buttonPressed.value = false
            }

            else -> Unit
        }
    }

    override fun onCancelPointerInput() {
        buttonPressed.value = false
    }
}

最後に Modifier.composed の代わりに Element クラスを使うよう修正します。これで Modifier.Node への移行は完了です。

/**
 * タップ直後に触感フィードバックとScaleアニメーションをおこなう[Modifier]
 */
fun Modifier.animateScaleClickable(
    enabled: Boolean,
    hapticEnabled: Boolean = true,
) = this.then(AnimateScaleClickableElement(enabled, hapticEnabled))

まとめ

androidx.compose.ui.node にある Modifier.Node 実装が豊富なため、既存の Modifier.composed はスムーズに移行できると感じました。
とはいえ Modifier.composed と比べると Modifier.Node は複雑な実装になるため、Modifier.Node に移行する機会に複雑なカスタム修飾子を分解できると、更にスムーズに移行できると思います(今回の例だと「スケールアニメーション」と「触覚フィードバック」それぞれのカスタム修飾子に分解できそう)。

表示機会の多いカスタム修飾子は是非 Modifier.Node に移行していきましょう!

参考