タクシーアプリ『GO』のAndroidアプリを開発している山本です。 Android アプリの UI 開発ツールキットである Jetpack Compose ライブラリでパフォーマンス向上のために既存の Modifier.composed を使った実装を Modifier.Node に置き換えた実例を紹介します。
Jetpack ComposeでUIの装飾や動作を設定するModifierをカスタム実装するアプローチとして、Modifier.composed メソッドが用意されています。Modifier.composed メソッドを使うことで、保持した状態をもとに複数の装飾や動作を設定した Modifier を返すような複雑なカスタム修飾子を作成できます。
ただし、この実装アプローチにはパフォーマンス上の問題が生じているため非推奨となっています。代わりに Modifier.Node を使った実装が推奨されています。
Jetpack Composeのパフォーマンス改善のために、タクシーアプリ『GO』で Modifier.compose を使っっていた実装を Modifier.Node に移行した事例を紹介します。
タクシーアプリ『GO』ではメイン導線として一番押してほしい青いボタン用のカスタム修飾子を Modifier.composed で実装していました。
[[ ここにスクショと動画を貼る ]]
このカスタム修飾子では、タップ時にスケールアニメーションと触覚フィードバックをおこない、アニメーションの状態管理のために 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クラスと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クラスにはカスタム修飾子を作成または更新するデータを保持します。
今回 Modifiler.Node として再実装するカスタム修飾子は 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インターフェースで再実装します。
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 に移行していきましょう!
興味のある方は 採用ページ も見ていただけると嬉しいです。
Twitter @goinc_techtalk のフォローもよろしくお願いします!