2024/1/26 追記
Strong Skipping Modeによるラムダ式のメモ化が実装されました
https://android.googlesource.com/platform/frameworks/support/+/androidx-main/compose/compiler/design/strong-skipping.md#lambda-memoization
この記事に書かれている内容は今後ほとんど気にしなくてよくなる可能性が高いです
こちらの記事の中に、ラムダ式がStableになるときとならないときがあると書かれている。
これを読んだとき、よくわからないがそういうことがあるらしいというようなもやもやした理解で終わってしまっていたが、あれからおよそ10ヶ月間、いろいろ触っているうちにこういうことなんじゃないかという納得のいく仮説がひとつ生まれたのでメモしておく。
この内容に確信があるわけではなく、こうだとしたらすべてスッキリ納得がいく、というくらいのものなので、それくらいの信憑性のつもりで読んでいただきたい。
結論
Composeのルール上は「関数型がStableとしてみなされる」以外に特殊なことは発生していない。外部の値をキャプチャしない関数のインスタンスはシングルトンとしてコンパイルされるというKotlinコンパイラの最適化の結果、外部の値をキャプチャしていないときだけStableかのように振る舞っている
(おそらく)Composeのルール上は「関数型がStableとしてみなされる」以外に特殊なことは発生していない
Composeは基本的にはComposeコンパイラを通っていないライブラリの型はすべてStableではないとみなす。
が、一部例外はあって、Composeコンパイラが特別にStableとして扱う型がいくつかある。
例
Int
, String
, Pair
(Kotlin標準ライブラリ)
ImmutableList
(kotlinx.collections.immutable)
これらと同様に、すべての関数型(つまり() -> Unit
, (A) -> R
, (A, B) -> R
...。JVM上の型で言うとkotlin.jvm.functions.Function0
, Function1
, Function2
...)もStableとして扱われているのではないだろうか。
Composeの仕様としてはそれだけのシンプルなルールだが、次項のKotlinコンパイラの仕様と組み合わさることでややこしくなっているのではないか、というのが今回の仮説。
ちなみに引数や返り値の型にStableでないものが混じっていても関数型自体はStableとみなされる模様。
Kotlinコンパイラは外部の値をキャプチャしない関数のインスタンスはシングルトンとしてコンパイルする
専門用語が多いのでひとつずつ説明していく。
まずKotlinのラムダ式やローカル関数はクロージャであり、その関数が定義されているスコープのローカル変数へアクセスできる。
fun outerFunction() {
val outerVariable = 42
val lambda = { outerVariable + 1 }
fun innerFunction(): Int {
return outerVariable * 2
}
}
このとき、クロージャが outerVariable
をキャプチャすると表現する。
キャプチャは下記のような形にコンパイルされる。(関数型はジェネリクスであるためここからさらにイレイジャが発生してクラスファイル内ではもう少し違う形になる)
fun outerFunction() {
val outerVariable = 42
val lambda = `OuterFunction$1`(outerVariable)
val innerFunction = `OuterFunction$2`(outerVariable)
}
class `OuterFunction$1`(private val outerVariable: Int) : () -> Int {
override fun invoke(): Int {
return outerVariable + 1
}
}
class `OuterFunction$2`(private val outerVariable: Int) : () -> Int {
override fun invoke(): Int {
return outerVariable * 2
}
}
ちなみに
ローカルclassでも同様のことができる。この場合コンストラクタの引数にキャプチャする値が追加される。
fun outerFunction() {
val outerVariable = 42
class InnerClass {
fun innerFunction(): Int {
return outerVariable + 1
}
}
}
fun outerFunction() {
val outerVariable = 42
}
class `OuterFunction$InnerClass`(private val outerVariable: Int) {
fun innerFunction(): Int {
return outerVariable + 1
}
}
ちなみに
キャプチャ対象がvarだとこうなる。
fun outerFunction() {
var outerVariable = 42
val lambda = {
outerVariable++
}
}
fun outerFunction() {
val outerVariable = IntRef()
outerVariable.element = 42
val lambda = `OuterFunction$1`(outerVariable)
}
class `OuterFunction$1`(private val outerVariable: IntRef) : () -> Int {
override fun invoke(): Int {
return outerVariable.element++
}
}
これを知っておくとComposeで remember { mutableStateOf() }
にしないといけないところや rememberUpdatedState
にしないといけないところの理解が深まる。
閑話休題。
つまりクロージャは outerFunction
を実行するたびにインスタンス化されている。Composableでもそれは変わらず、リコンポジションのたびにクロージャはインスタンス化されている。したがって、そのクロージャを他のComposableに渡した場合、スキップの判定時、クロージャのインスタンスの equals
は false
を返すため、 Composableに記述したクロージャを他Composableに渡すとスキップの条件を満たさない。
そもそもComposeでラムダ式を書くとスキップされないのが普通だったのだ。
ではなぜonClick等を引数に含むComposableがスキップされるときがあるのか?
この疑問の答えが、先程の専門用語の多い文章。「Kotlinコンパイラは外部の値をキャプチャしない関数のインスタンスはシングルトンとしてコンパイルする」からだ。
我々が記述するラムダ式やローカル関数はすべて外部の値をキャプチャしているのかというと当然そんなことはなく、受け取った引数にのみ作用するものもある。
fun outerFunction() {
val lambda = { param: Int -> param + 1 }
}
こういったラムダ式はコンパイル時シングルトンになる。
つまりこうなる。
fun outerFunction() {
val lambda = `OuterFunction$1`
}
object `OuterFunction$1` : (Int) -> Int {
override fun invoke(param: Int): Int {
return param + 1
}
}
したがって、この場合のラムダ式はリコンポジション後も同一インスタンスとなり、 スキップの条件を満たす。
これがラムダ式がStableになったりならなかったりするように見える理由なのではないだろうか?
解決策
ではComposableに関数を渡すとき、どうすればリコンポジションをスキップさせられるのか?
1. 関数参照(Function Reference)にする
可能な場合は関数参照を使う。
@Composable
fun Hoge(viewModel: HogeViewModel) {
Hoge(
- onClickPiyo = { viewModel.piyo() }
+ onClickPiyo = viewModel::piyo
)
}
関数参照は kotlin.jvm.internal.FunctionReference
というクラスを継承したインスタンスにコンパイルされる。FunctionReferenceの equals
は同じレシーバーの同じ関数には true
を返す実装になっており、上記の例で言うと viewModel
が変化していなければリコンポジションがスキップされるようになる。
ちなみにラムダ式は kotlin.jvm.internal.Lambda
を継承するが、こちらはequalsは実装されていない。
2. rememberする
1.が不可能なときはこちら。
@Composable
fun Hoge(viewModel: HogeViewModel) {
Hoge(
onClickPiyo = remember(viewModel) {
{ viewModel.piyo() }
}
)
}
viewModelが変化していないとき、直前のコンポジションと同一インスタンスを使うようになるため、リコンポジションがスキップできる。
3. equalsを実装する
これは基本的に選ばない選択肢だと思うけど、一応可能ではある。
@Composable
fun Hoge(viewModel: HogeViewModel) {
class OnClickPiyo(private val viewModel: HogeViewModel) : () -> Unit {
override fun invoke() {
viewModel.piyo()
}
override fun equals(other: Any?): Boolean
= other is OnClickPiyo && viewModel == other.viewModel
}
Hoge(
onClick = OnClickPiyo(viewModel)
)
}
一応可能ではある。基本的に選ばない選択肢だと思うけど。
まとめ
- 関数を受け取るComposableはスキップされないことがあるが、おそらくComposeのルール上は「関数型がStableとしてみなされる」以外に特殊なことは発生していない。リコンポジションのたびに毎回ラムダ式がインスタンス化されていることが原因。
- 外部の値をキャプチャしないラムダ式はKotlinコンパイラの最適化の結果スキップ可能になる。
- 関数参照はequalsが実装されているためスキップ可能になる。
- rememberで同一インスタンスを使うことでスキップ可能にできる。