LoginSignup
38
15

derivedStateOfを使うべきところとderivedStateOfを使わなくていいところ

Last updated at Posted at 2023-06-25

Composeのリファレンスの「副作用」のページの中で「derivedStateOf」について書かれています。
https://developer.android.com/jetpack/compose/side-effects?hl=ja#derivedstateof

そんなに長くない文章なのでここで一度上記のリファレンスを軽く読んでみてほしいです。


読みましたか?

derivedStateOfの使い方、理解できたでしょうか?

2024/04/28 追記

いつの間にかリファレンスのderivedStateOfの項目が改善されていますね。
この記事を書いた当時よりかなり理解しやすくなっています。

私はComposeを始めたとき最初にこのリファレンスに目を通したのだけど、正直このdeviredStateOfの項目は全く意味がわからなかった。

いや、意味はわかるんですよ。
難しいことが書いてあるものの、ゆっくり時間をかけて読めば書いてあることの意味はわかる。

でもかなり多くの疑問が残った。

リコンポジションが発生しなくなるってどういう理屈で?
どの部分がリコンポーズされなくてどこがリコンポーズされるの?
なんでこの書き方になるの? rememberのキーいるの?
最後の文章もよくわかんねえ。結局どういうときにderivedStateOfを使えばいいのかわかんねえよ。

というかこれ今見ても若干ややこしいわ。
なんで最初の例でいきなりこれを出してきたんだよ。

というわけで今回はderivedStateOfの使い方について深堀りしていく。

リコンポジション

derivedStateOfがわからないのは、実はderivedStateOfそのものがわからないというよりはリコンポジションの正確な動作をわかってないことが原因だったりする。

なのでまずはリコンポジションについておさらいしていく。

300秒カウントダウンするComposableを作ってみる。

@Composable
fun Timer() {
   var time by remember { mutableStateOf(300) }

   LaunchedEffect(Unit) {
      while (true) {
         delay(1000L)
         time--
      }
   }

   Text("$time")
}

@Composable が付与された関数は―― 「@Composable が付与された関数」と毎回言うのは長すぎるので、我々は @Composable が付与された関数のことを "Composable" と呼んでいる。 ―― ComposableはStateの値にアクセスするときに『ワタシはこのStateを使います』という旨をComposeフレームワークに伝えている。
そしてそのStateが変更されるとき、フレームワークはComposableを再実行してUIを新しい状態で再構築する。これが リコンポジション

上記のコードで言うとComposable TimermutableStateOf(300) で生成したStateにbyを使ってアクセスしている。(もちろんbyを使わず直接 .value と書いても同じことが起こる)
LaunchedEffect内の time-- によって1秒ごとにStateが変更されるので、そのたびにこのComposable Timer はリコンポーズされる。

Restartable

実はリコンポジションは毎回UI全体を再構築しているわけではない。
変更されたStateを使っているComposableから再実行される。

  // リコンポーズされない
+ @Composable
+ fun TimerScreen() {
+    Timer()
+ }
+
  // リコンポーズされる
  @Composable
  fun Timer() {
     var time by remember { mutableStateOf(300) }

     LaunchedEffect(Unit) {
        while (true) {
           delay(1000L)
           time--
        }
     }

     Text("$time")
  }

timeが変化したときにはいちいちTimerScreenから再実行せず、timeを使っているComposable Timer からいきなり再実行される。
この例のTimerのように、Composableが再実行の開始地点になれることを Restartable という。

NonRestartableComposable

わざわざ説明したってことはそうじゃないComposableもあるワケで…

  // リコンポーズされるようになる
  @Composable
  fun TimerScreen() {
     Timer()
  }

  // リコンポーズされる
  @Composable
+ @NonRestartableComposable
  fun Timer() {
     var time by remember { mutableStateOf(300) }

     LaunchedEffect(Unit) {
        while (true) {
           delay(1000L)
           time--
        }
     }

     Text("$time")
  }

@NonRestartableComposable を付与するとRestartableではなくなる。この場合最も近いRestartableな親Composableからリコンポーズされる。
他のComposableをひとつ呼ぶだけなどの完全に小さなComposableはいちいちRestartableにするコストの方が上回るので @NonRestartableComposable になっていたりする。

inline fun

あとはComposableがinline funの場合にもRestartableではなくなる。
まあこれはComposeの仕様というよりはインライン展開された結果そうなってるだけなんだろうけど。

  // リコンポーズされる
  @Composable
  fun TimerScreen() {
     Timer()
  }

  // リコンポーズされる
  @Composable
- fun Timer() {
+ inline fun Timer() {
     var time by remember { mutableStateOf(300) }

     LaunchedEffect(Unit) {
        while (true) {
           delay(1000L)
           time--
        }
     }

     Text("$time")
  }

クロージャ

Kotlinのラムダ式はクロージャなので、こういうパターンもありうる。
Boxなどの内側からBoxの外側にあるStateを使う場合。

  @Composable
  fun Timer() {
     // リコンポーズされない
     var time by remember { mutableStateOf(300) }

     LaunchedEffect(Unit) {
        while (true) {
           delay(1000L)
           time--
        }
     }

+    Box {
        // リコンポーズされる
        Text("$time")
+    }
  }

この場合もBoxの中身の部分がRestartableとなり、ここだけがリコンポーズされる。
まあ、外側(この例では Timer )の方でも time を使ってたら当然そっちもリコンポーズされるので、この現象が起こるのは『Stateを外側に置いているが使うのはBoxの内側だけ』というような場合だ。

もちろん仮にBoxがinline funだとTimerからのリコンポジションになる。

inline funは引数で受け取った関数もインライン展開する。

// inline funじゃない場合
fun <R> run(block: () -> R): R {
   return block()
}

val i = run { 1 * 2 * 3 }
    
val i = run(fun () { return 1 * 2 * 3 })
// inline funの場合
inline fun <R> run(block: () -> R): R {
   return block()
}

val i = run { 1 * 2 * 3 }
    
val i = 1 * 2 * 3

inline funの場合だけラムダ式内にreturnを書けるのはこのため。

fun foo(): Int {
   run { return 42 }           // inline funじゃない場合
       
   run(fun () { return 42 })   // ❌

   run { return 42 }           // inline funの場合
       
   return 42                   // ⭕
}

スキップ

Composableは、リコンポーズされたときの引数が直前のコンポーズの引数と同じであれば、前回と同じ結果になるとみなして処理を スキップ する。

  @Composable
  fun Timer() {
     var time by remember { mutableStateOf(300) }

     LaunchedEffect(Unit) {
        while (true) {
           delay(1000L)
           time--
        }
     }

-    Text("$time")
+    Row {
+       Minutes(time / 60)
+       Seconds(time % 60)
+    }
  }

  // 60秒に1回リコンポーズされる
+ @Composable
+ fun Minutes(m: Int) {
+    Text("$m")
+ }

  // 毎秒リコンポーズされる
+ @Composable
+ fun Seconds(s: Int) {
+    Text("$s")
+ }

time / 60 は60秒に1回しか変化しないので変化していないときの Minutes のリコンポジションはスキップされるのですね。
サンプルコードのコメントに60秒に1回リコンポーズされると書いたけど、厳密に言うとリコンポジションは毎秒発生していて、その60回のうち59回がスキップされていると言うほうが正しいのだと思う。そのあたりの細かい話はよしなに読み取ってください。

ちなみに変化している引数があってもその引数を使っていなければリコンポジションはスキップできる。

  @Composable
  fun TimerScreen() {
     Timer()
  }

  @Composable
  fun Timer() {
     var time by remember { mutableStateOf(300) }

     LaunchedEffect(Unit) {
        while (true) {
           delay(1000L)
           time--
        }
     }

     Row {
        Minutes(time / 60)
+       Separator(time)
        Seconds(time % 60)
     }
  }

  // 毎秒リコンポーズされる
+ @Composable
+ fun Separator(time: Int) {
+    Text(
+       if (time % 2 == 0) { ":" } else { " " }
+    )
+ }
  // リコンポジションは発生しない(引数は毎秒変化しているのに)
  @Composable
  fun Separator(time: Int) {
-    Text(
-       if (time % 2 == 0) { ":" } else { " " }
-    )
+    Text(":")
  }

だからなんやねんと思うかもしれないけど、この仕様はComposableを引数として受け取るComposable(スロットAPIなどと呼ばれる)のときに意味がある。

  @Composable
  fun TimerScreen() {
     Timer(
+       separator = { time ->
+          // 毎秒リコンポーズされる。timeを使わなければスキップされる
+          Text(
+             if (time % 2 == 0) { ":" } else { " " }
+          )
+       }
     )
  }

  @Composable
  fun Timer(
+    separator: @Composable (Int) -> Unit
  ) {
     var time by remember { mutableStateOf(300) }

     LaunchedEffect(Unit) {
        while (true) {
           delay(1000L)
           time--
        }
     }

     Row {
        Minutes(time / 60)
+       separator(time)
        Seconds(time % 60)
     }
  }

もっと複雑なパターン、例えばScope系になっても同様。 this という見えない引数にアクセスしなければ this が変化していてもスキップ可能。

  @Composable
  fun TimerScreen() {
     Timer(
        separator = {
           // 毎秒リコンポーズされる(timeを使わなければされない)
+          Text(
+             if (time % 2 == 0) { ":" } else { " " }
+          )
        }
     )
  }

+ class SeparatorScope(val time: Int)

  @Composable
  fun Timer(
-    separator: @Composable (Int) -> Unit
+    separator: @Composable SeparatorScope.() -> Unit
  ) {
     var time by remember { mutableStateOf(300) }

     LaunchedEffect(Unit) {
        while (true) {
           delay(1000L)
           time--
        }
     }

     Row {
        Minutes(time / 60)
-       separator(time)
+       val scope = SeparatorScope(time)
+       scope.separator()
        Seconds(time % 60)
     }
  }

Stable

ただしスキップされるのはComposableの引数がすべて Stable の場合だけ。
Stableでない引数が混じっているとスキップされず毎回リコンポーズされる。

「Stableである」とは、大きく分けると次の2つ。

  1. Composableからアクセスできるプロパティの型がすべてStableであり、プロパティが変化しない
  2. Composableからアクセスできるプロパティの型がすべてStableであり、プロパティが変化したときComposeフレームワークに通知する

Stableの中でも1の方を特に Immutable と呼んだりする。まあそのまんまなんだけど。

Stableでないインスタンスの有名な例として List がある。
ListはサブタイプとしてMutableListがあって変更可能なリストが渡される可能性があるというのはKotlinerならよく知っている問題ですね。

  @Composable
  fun Timer(
     separator: @Composable SeparatorScope.() -> Unit
  ) {
     var time by remember { mutableStateOf(300) }
+    var lapTimes by remember { mutableStateOf(emptyList<Int>()) }

     LaunchedEffect(Unit) {
        while (true) {
           delay(1000L)
           time--
        }
     }

+    Column {
        Row {
           Minutes(time / 60)
           val scope = SeparatorScope(time)
           scope.separator()
           Seconds(time % 60)
        }
+
+       LapButton(
+          onClick = {
+             lapTimes += time
+          }
+       )
+    }
+
+    LapTimes(lapTimes)
  }

+ @Composable
+ fun LapTimes(lapTimes: List<Int>) {
+    // 毎秒リコンポーズされる
+    Column {
+       for (t in lapTimes) {
+          Text("$t")
+       }
+    }
+ }

kotlinx.collections.immutableの ImmutableList がImmutableとして扱われるのでComposeでは積極的に使っていこう。

  @Composable
  fun Timer(
     separator: @Composable SeparatorScope.() -> Unit
  ) {
     var time by remember { mutableStateOf(300) }
-    var lapTimes by remember { mutableStateOf(emptyList<Int>()) }
+    var lapTimes by remember { mutableStateOf(persistentListOf<Int>()) }

     LaunchedEffect(Unit) {
        while (true) {
           delay(1000L)
           time--
        }
     }

     Column {
        Row {
           Minutes(time / 60)
           val scope = SeparatorScope(time)
           scope.separator()
           Seconds(time % 60)
        }

        LapButton(
           onClick = {
              lapTimes += time
           }
        )
     }

     LapTimes(lapTimes)
  }

  @Composable
- fun LapTimes(lapTimes: List<Int>) {
+ fun LapTimes(lapTimes: ImmutableList<Int>) {
     // lapTimesが変化したときのみリコンポーズされる
     Column {
        for (t in lapTimes) {
           Text("$t")
        }
     }
  }

これが、引数がStableとみなされる条件その1「Composeからアクセスできるプロパティが変化しない」。

続いて、条件その2「プロパティが変化したときComposeフレームワークに通知する」ってなんなのかというと、まあ早い話Stateを使ってるってことです。StateはComposeに変更を通知してくれますからね。

例えば引数の型を State にしてもスキップが可能。

  @Composable
- fun LapTimes(lapTimes: ImmutableList<Int>) {
+ fun LapTimes(lapTimesState: State<ImmutableList<Int>>) {
     // lapTimesStateが変化したときのみリコンポーズされる
     Column {
-       for (t in lapTimes) {
+       for (t in lapTimesState.value) {
           Text("$t")
        }
     }
  }

直接Stateを使う場合だけじゃなく間接的であってもStateで管理していればStableになる。
例えばTimerでrememberしていた2つのStateを別のclassに持たせてみる

+ class TimerState {
+    var time by mutableStateOf(300)
+       internal set
+    var lapTimes by mutableStateOf(persistentListOf<Int>())
+       internal set
+ }

  @Composable
  fun Timer(
     separator: @Composable SeparatorScope.() -> Unit
  ) {
-    var time by remember { mutableStateOf(300) }
-    val lapTimes by remember { mutableStateOf(persistentListOf<Int>()) }
+    val state = remember { TimerState() }

     LaunchedEffect(Unit) {
        while (true) {
           delay(1000L)
-          time--
+          state.time--
        }
     }

     Column {
        Row {
-          Minutes(time / 60)
+          Minutes(state.time / 60)
-          val scope = SeparatorScope(time)
+          val scope = SeparatorScope(state.time)
           scope.separator()
-          Seconds(time % 60)
+          Seconds(state.time % 60)
        }

        LapButton(
           onClick = {
-             lapTimes += time
+             state.lapTimes += state.time
           }
        )
     }

-    LapTimes(lapTimes)
+    LapTimes(state)
  }

  @Composable
- fun LapTimes(lapTimesState: State<ImmutableList<Int>>) {
+ fun LapTimes(state: TimerState) {
     // state.lapTimesが変化したときのみリコンポーズされる
     Column {
-       for (t in lapTimesState.value) {
+       for (t in state.lapTimes) {
           Text("$t")
        }
     }
  }

新しく TimerState というclassを作ったが、この型もStableとなる。

もっとプロパティや関数を経由したってOKだ。

  class TimerState {
     var time by mutableStateOf(300)
        internal set
+    val minute: Int
+       get() = time / 60
+    fun second(): Int = time % 60

     var lapTimes by mutableStateOf(persistentListOf<Int>())
        internal set
  }

  @Composable
  fun Timer(
     separator: @Composable SeparatorScope.() -> Unit
  ) {
     val state = remember { TimerState() }

     LaunchedEffect(Unit) {
        while (true) {
           delay(1000L)
           state.time--
        }
     }

     Column {
        Row {
-          Minutes(state.time / 60)
+          Minutes(state.minute)
           val scope = SeparatorScope(state.time)
           scope.separator()
-          Seconds(state.time % 60)
+          Seconds(state.second())
        }

        LapButton(
           onClick = {
              state.lapTimes += state.time
           }
        )
     }

     LapTimes(state)
  }

Composeコンパイラは型がStableかどうかある程度推論してくれるが、やはり限界はある。
Stableになるように作っているのにComposeコンパイラがStableではないと推論してしまった場合は、 @Stable を付与してStableであることをComposeコンパイラに伝えることができる。

+ @Stable
  class TimerState {
     var time by mutableStateOf(300)
        internal set
     val minute: Int
        get() = time / 60
     fun second(): Int = time % 60

     var lapTimes by mutableStateOf(persistentListOf<Int>())
        internal set
  }

なのだけど、現状、ComposeコンパイラがStableと推論したかどうかはIDE等で手軽に確認できず、少々面倒な手順を踏まないといけない。
なので私はわりとComposeコンパイラがどう考えたかは気にせずStableなら @Stable と書いている。

状態ホルダ

上記の TimerState みたいに複数のStateをまとめるインスタンスのことを 状態ホルダ と言う。

Composableの状態をそのComposableの外部と共有するためにとても役に立つ。
Composableに何が表示されているのか、そのComposable以外も知ることができるし、Composableの状態をそのComposable以外が変更することもできる。

例えばComposable Timer の外に一時停止ボタンを置くとしたらこんな感じにできそうですね。

  @Stable
  class TimerState {
     var time by mutableStateOf(300)
        internal set
     val minute: Int
        get() = time / 60
     fun second(): Int = time % 60

+   var isPausing by mutableStateOf(false)
+      private set

     var lapTimes by mutableStateOf(persistentListOf<Int>())
        internal set

+    fun pause() {
+       isPausing = true
+    }
+
+    fun resume() {
+       isPausing = false
+    }
  }

  @Composable
  fun TimerScreen() {
+    val timerState = remember { TimerStae() }

+    Column {
        Timer(
+          state = timerState,
           separator = {
              Text(
                 if (time % 2 == 0) { ":" } else { " " }
              )
           }
        )

+       if (timerState.isPausing) {
+          ResumeButton(
+             onClick = {
+                timerState.resume()
+             }
+          )
+       } else {
+          PauseButton(
+             onClick = {
+                timerState.pause()
+             }
+          )
+       }
+    }
  }

  @Composable
  fun Timer(
+    state: TimerState,
     separator: @Composable SeparatorScope.() -> Unit
  ) {
-    val state = remember { TimerState() }

-    LaunchedEffect(Unit) {
+    LaunchedEffect(state.isPausing) {
-       while (true) {
+       while (!state.isPausing) {
           delay(1000L)
           state.time--
        }
     }

     Column {
        Row {
           Minutes(state.minute)
           val scope = SeparatorScope(state.time)
           scope.separator()
           Seconds(state.second())
        }

        LapButton(
           onClick = {
              state.lapTimes += state.time
           }
        )
     }

     LapTimes(state)
  }

derivedStateOf

ようやく本題。
でもここまでわかっていればderivedStateOf自体は難しくないんですよ。

derivedStateOfは、なにか他のStateを使った計算を行い、その結果を保持するStateである

derivedStateOfというのは、なにか他のStateを使った計算を行って、その結果を保持しているStateです。
ちなみに derived(ディライヴド)なので注意。デリバード(delivered)でもドライヴド(drived)でもないです。

極端な例えだとこれだけのことです。

var a by remember { mutbaleStateOf(0) }
val b by remember { derivedStateOf { a * 2 } }

State a を2倍するという計算を行ってその結果を b というStateに保持させています。

そしてderivedStateOfは、計算に使ったStateが変更されると自動的に再計算されます。
a を4に変更すると自動的に b が8になるんですね。

  var a by remember { mutbaleStateOf(0) }
  val b by remember { derivedStateOf { a * 2 } }

+ LaunchedEffect(Unit) {
+    a = 4
+    assertEquals(8, b)
+ }

え、これって別にderivedStateOfを使わなくても a が変更されたらリコンポジションが発火されて b も再計算されるから同じ結果になるのでは?

そのとおりです。

しかしderivedStateOfを使う場合、再計算されるのはderivedStateOfに渡した部分だけです。

val b by remember { derivedStateOf { a * 2 } }
//                                  ^^^^^^^ 再実行されるのはここだけ

再計算した結果 b の値が変化していなければリコンポーズが発火されないし、再計算した結果 b の値が変化していればリコンポジションが発火されます。
なぜならderivedStateOfはなにか他のStateを使った計算を行い、その結果を保持する State なので。ComposableはStateが変化したときにリコンポーズされます。

なるほど、ということはderivedStateOfを使って効果があるのは、 b の変更頻度が a の変更頻度より低くなるとき。
Stateを使った計算結果の変更頻度が、元のStateの変更頻度より低いとき
ということになりそうですね。

val b by remember { derivedStateOf { a * 2 } }

と書いても結局 a が変化するたびに b も変化するのでリコンポジションは全く減らないわけです。

例えば derivedStateOf { a / 60 } とかだと60回に1回しか計算結果が変わらないので効果がありそうですね。
ん、なんかこの計算見たことがありますね

derivedStateOfを使うべきところ

それではderivedStateOfを使うべきところとderivedStateOfを使わなくていいところについて考えていきましょう。

いくつかderivedStateOfを使ったコードを置いておきます。効果があるのかどうかの答えは折りたたんでおくので、一度考えてみてから開いてもおもしろいかもしれません。

Q1

  @Composable
  fun Minutes(timerState: TimerState) {
-    val m = timerState.time / 60
+    val m by remember(timerState) { derivedStateOf { timerState.time / 60 } }
     Text("$m")
  }

まずはわかりやすい例です

このderivedStateOfは…

Good!

timerState.time は毎秒変化しますが、 timerState.time / 60 は1分に1回しか変化しませんね。

Composable内に直接 timerState.time / 60 と書くと毎秒リコンポーズされますが、derivedStateOfで包むことによって derivedStateOfの再計算は毎秒行われますが、その結果が変化してMinuteがリコンポーズされるのは1分に1回となります。

Q2

  @Composable
  fun Minutes(time: Int) {
     val m by remember { derivedStateOf { time / 60 } }
     Text("$m")
  }

引数をInt型に変えてみました。
毎秒異なるtimeが渡されてくるので、derivedStateOfを使ってリコンポジションを減らそうとしたパターンですね。

このderivedStateOfは…

Incident!!!

このコードは意図通りに動作しません。
m の値は再計算されず、何分待っても同じテキストが表示されたままになります。

これはderivedStateOfに特殊な仕様があるわけではなく、rememberによるものです。

たとえば

val m = remember { time / 60 }

とすれば m がリコンポジション後も同じ値になるのがわかるでしょう。

たとえば

val m: () -> Int = remember {
   { time / 60 }
}

m()

としても m() はリコンポジション後も同じ値を返します。
これについては普段あまり気にしていない人もいるかと思いますが、
{ time / 60 } というラムダ式はコンパイル後下記のようになります。

class `Minutes$1`(private val time: Int) : () -> Int {
   override fun invoke(): Int {
      return time / 60
   }
}

それを remember { `Minutes$1`(time) } しているわけですから、リコンポジション後もこのラムダ式が返す値は古い値のままです。

derivedStateOfでも同じです。

Q3

  @Composable
  fun Minutes(time: Int) {
     val m by remember(time) { derivedStateOf { time / 60 } }
     Text("$m")
  }

Q2がダメだったのでrememberのキーでtimeを指定したパターンです。

このderivedStateOfは…

Ineffective...

rememberのキーにtimeを指定してtimeが変化するたびにderivedStateOfを再生成するようにしたわけですが、それ結局リコンポジション減ってねえじゃねえか! ってことでderivedStateOfの恩恵は受けられないです。

Q4

  @Composable
  fun Minutes(time: Int) {
     val timeState = rememberUpdatedState(time)
     val m by remember { derivedStateOf { timeState.value / 60 } }
     Text("$m")
  }

Q2がダメだったので引数で受け取ったtimeを一旦Stateに入れるようにしたパターンです。

このderivedStateOfは…

Ineffective...

timeをStateに入れるようにしたことで、derivedStateOfは問題なくtimeの変化を検知できるようになりました。
m も1分に1回しか変化しないのでderivedStateOfはしっかり働いてくれます。

でもtimeが毎秒違う値になるならリコンポジションはスキップされないので、せっかくderivedStateOfが意図通りに動いてもリコンポジションは減りません。

Q5

  @Composable
  fun Minutes(time: Int) {
     val timeState = rememberUpdatedState(time)
     val minuteState = remember { derivedStateOf { timeState.value / 60 } }
     RealMinutes(minuteState)
  }

  @Composable
  private fun RealMinutes(minuteState: State<Int>) {
     Text("${minuteState.value}")
  }

スキップされないことがわかったので、せめて大部分を別のComposable RealMinutes に逃がしてスキップできるようにしたパターン。
サンプルではTextしかないけど、ここがすげーデカかったとして、Minutes自体はスキップできないかもしれないけど、RealMinutesに逃した大部分はスキップできるよねという考えです。

このderivedStateOfは…

Ineffective...

derivedStateOfを使いたすぎて迷走していますね。

スキップしたいところを別のComposableに逃がすアイデアはたしかに有効なのですが、その場合derivedStateOfを使わなくてもスキップできるので…

  @Composable
  fun Minutes(time: Int) {
     RealMinutes(time / 60)
  }

  @Composable
  private fun RealMinutes(minute: Int) {
     Text("$minute")
  }

これでよいです。

Q6

  @Composable
  fun TodoList(highPriorityKeywords: List<String> = listOf("Review", "Unblock", "Compose")) {
     val todoTasks = remember { mutableStateListOf<String>() }

     val highPriorityTasks by remember(highPriorityKeywords) {
        derivedStateOf {
           todoTasks.filter { task ->
              highPriorityKeywords.any { keyword ->
                 task.contains(keyword)
              }
           }
        }
     }

     Box(Modifier.fillMaxSize()) {
        LazyColumn {
           items(highPriorityTasks) { /* ... */ }
           items(todoTasks) { /* ... */ }
        }
    }
}

最後にComposeの公式リファレンスを見ましょう。

このderivedStateOfは…

Good!

まず最初に気づいてほしいポイントとして 引数の型がStableじゃない ということがあります。
ImmutableListではなくListで宣言しているので、このComposable TodoList はリコンポーズされたとき引数が前回と同じであってもスキップされません。

Q5までは「使っているStateが変化したとき」もしくは「引数が変化したとき」だけを考えればよかったですが、このサンプルではなにかの理由で親ComposableがリコンポーズされればTodoListは引数もStateも変化していなくてもリコンポーズされます。

todoTasksをhighPriorityKeywordsでフィルタリングする処理はリコンポジションのたびに毎回やるには重すぎますよね。
なのでderivedStateOfを使って todoTasks が変化したときにだけ再計算されるようにします。

ただしこのフィルタリング処理では highPriorityKeywords も使っています。これはStateではないのでderivedStateOfは変更を検知できません。なのでrememberにキーとして highPriorityKeywords を指定して highPriorityKeywords が変化したときにderivedStateOf自体を再生成する必要があります。

TodoList は残念ながらスキップされませんが、BoxやLazyListはスキップできます。
そしてLazyList内で highPriorityTaskstodoTasks を使っているので、このどちらかが変化すればRestartableであるLazyListがリコンポーズされますね。このときには TodoList はリコンポーズされないでしょう。

ただ、このderivedStateOfは highPriorityKeywords があまり変化しないときに真価を発揮します。
もし highPriorityKeywordstodoTasks と同じくらいの高頻度で変化するとしたら…
そのたびに毎回derivedStateOfを再生成するよりは毎回フィルタリング処理をする方がコストが安いということがありえます。
その場合derivedStateOfを使わず remember(todoTasks, highPriorityKeywords) { ... } と書いたほうがいいわけですね。

最後に

もう一回言わせてほしい。

なんでこんな難しいのをサンプルにしたんだよ。

38
15
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
38
15