Я изучаю Jetpack Compose и создал простой пример, чтобы понять управление состоянием. У меня есть кнопка, которая обновляет переменную width
, и я хочу вычислить size
на основе переменных width
и height
. Однако переменная size
не обновляется должным образом при изменении width
.
Вот упрощенный код, демонстрирующий проблему:
Пример 1 (не работает):
@Composable
@Preview
fun App() {
var width by remember { mutableStateOf(0f) }
var height by remember { mutableStateOf(0f) }
// Issue in this line. When width or height changes, size is not recalculated.
// size remains the same as initial state Size(0, 0).
// I expect that the size to be always sync with the width and height
var size = Size(width, height)
Button(
onClick = { width++ },
modifier = Modifier.pointerInput(Unit) {
detectDragGestures { change, dragAmount ->
println(size)
println(width)
}
}
) {
Text("OK")
}
}
Когда я нажимаю кнопку, width
увеличивается правильно, но size
всегда печатается Size(0.0, 0.0)
, когда я перетаскиваю кнопку. Я пробовал использовать mutableStateOf
вместо size
, но все равно не работает.
Пример 2 (использование производногоStateOf, работает):
@Composable
@Preview
fun App() {
var width by remember { mutableStateOf(0f) }
var height by remember { mutableStateOf(0f) }
val size by derivedStateOf { Size(width, height) }
Button(
onClick = { width++ },
modifier = Modifier.pointerInput(Unit) {
detectDragGestures { change, dragAmount ->
println(size)
println(width)
}
}
) {
Text("OK")
}
}
Единственное работающее решение — это использование derivedStateOf
, но в документации указано, что derivedStateOf
следует использовать, когда входные данные меняются чаще, чем это необходимо для рекомпозиции, а в данном случае это не так.
Вам следует использовать функцию
derivedStateOf
, когда вы вводите данные в Composable меняются чаще, чем вам нужно перекомпоновать. Этот часто происходит, когда что-то часто меняется, например, прокрутка положение, но компонуемому нужно реагировать на него только после того, как оно пересечет определенный порог.derivedStateOf
создает новое состояние Compose объект, вы можете заметить, что он обновляется только столько, сколько вам нужно. В этом Кстати, он действует аналогично Kotlin FlowsdistinctUntilChanged()
.Внимание:
derivedStateOf
стоит дорого, и его следует использовать только во избежание ненужной рекомпозиции, если результат не изменился.
Пример 3 (Кажется, работает, но почему?):
@Composable
@Preview
fun App() {
var width by remember { mutableStateOf(0f) }
var height by remember { mutableStateOf(0f) }
val size by mutableStateOf(Size(width, height))
println(width)
println(size)
Button(
onClick = { width++ }
) {
Text("OK")
}
}
В этом примере size
выглядит корректно, но я не понимаю, почему.
Вопрос:
Я понимаю, что derivedStateOf
часто используется для оптимизации производительности, но я пытаюсь понять, почему size
не обновляется правильно с помощью mutableStateOf
или как обычная переменная в первом примере. Кроме того, почему третий пример работает?
Попробуйте либо:
val size by remember { derivedStateOf { Size(width, height) } }
или:
val size = remember(width, height) { Size(width, height) } // don't use this in the lambda
val currentSize by rememberUpdatedState(size) // use this in the lambda
Подождите, 2-й подход даже с ключами не работает? Это очень странно. Я думаю, так и должно быть. О_о, ну, если нет, я просто отразлю детали реализации rememberUpdatedState
в редактировании.
Второй подход с ключами не работает, потому что при изменении ключей вы создаете новый экземпляр State, но лямбда-выражение pointerInput по-прежнему работает с исходным экземпляром. Это также верно и для отредактированного ответа, поэтому я предполагаю, что это тоже не сработает. Вам нужно сохранить исходный экземпляр State и изменить только значение (именно это и делает RememberUpdatedState).
@JanBína, ты абсолютно прав, он все равно фиксирует текущую лямбду. Ох, теперь я знаю, что делать, спасибо!
Отредактировал, чтобы исправить.
Документация pointerInput
объясняет это:
Когда модификатор
pointerInput
создается композицией, еслиblock
захватывает любой локальный переменных, с которыми нужно работать, для работы с изменениями этих переменных обычно используются два шаблона. в зависимости от желаемого поведения.
Указание захваченного значения в качестве параметраkey
приведет к отменеblock
и перезапуску с самого начала, если значение изменится: keyedPointerInputModifier
Ifblock
не должен перезапускаться при изменении захваченного значения, но значение все равно должно обновиться для следующего использования, используйте RememberUpdatedState для обновления держателя значения, к которому обращаетсяblock
: RememberUpdatedParameterPointerInputModifier
Для вашего варианта использования я бы порекомендовал rememberUpdatedState
:
val size = rememberUpdatedState(Size(width, height))
В первом примере вы создаете новый экземпляр Size
в каждой композиции, но блок pointerInput не создается заново и все еще ссылается на старый экземпляр. Аналогично, в третьем примере вы создаете новый экземпляр State
, потому что вы не обертываете его remember
. rememberUpdatedState
эффективно делает это:
remember {
mutableStateOf(newValue)
}.apply { value = newValue }
Таким образом, вы сохраняете тот же экземпляр State
, и когда вы вызываете State.value
внутри лямбды, вы получаете новое значение.
Это немного сложно. В дополнение к остальным ответам я постараюсь более подробно объяснить, что именно происходит в ваших трех примерах.
В первом примере вы передаете лямбду в pointerInput
(часть в фигурных скобках). Это означает, что вы передаете только функцию: код в лямбде на самом деле не выполняется сразу, вы просто предоставляете функцию, которая может быть выполнена позже. Поскольку вы получаете доступ к переменным в вашей лямбде, которые находятся за пределами лямбды, Котлин фиксирует значения переменных в замыкании . Это просто означает, что значения где-то хранятся, поэтому к ним можно будет получить доступ позже, когда лямбда-выражение действительно будет выполнено.
Двумя переменными, к которым обращается лямбда, являются pointerInput
и size
. В момент первого вызова width
pointerInput
является size
, так что объект захватывается и для Size(0f, 0f)
(по крайней мере, так кажется, читайте дальше, чтобы узнать больше) width
захватывается. Поэтому всякий раз, когда выполняется лямбда, осуществляется доступ к этим двум значениям. Поэтому вы видите только 0f
.
Теперь более интересный вопрос: почему вы видите все изменения в Size(0f, 0f)
, поскольку он фиксируется так же, как width
. Разница в том, что size
на самом деле является делегированным значением , потому что вы объявили его с помощью width
. Это всего лишь синтаксический сахар в Котлине, поэтому в вашем коде вы можете получить доступ к by
как к width
, где фактический объект — это Float
. Во время компиляции Kotlin заменяет вашу переменную MutableState<Float>
Float
на width
и везде, где вы обращаетесь к переменной, он фактически вызывает MutableState<Float>
.
Это означает, что в вашей лямбде width.value
вы фактически получаете доступ к объекту типа pointerInput
(а затем вызываете MutableState<Float>
) при доступе к value
. И в этом разница с width
: лямбда фиксирует этот size
объект, но любые изменения в MutableState<Float>
на самом деле только обновляют внутреннее свойство width
этого самого объекта, но никогда не создают новый объект. В отличие от value
, где каждый раз создается новый size
объект. Вот почему Size(...)
работает, а width
нет, так что то, что size
вообще работает, скорее совпадение.
Когда width
изменяется, запускается рекомпозиция, которая фактически выполняется width
снова, поэтому можно подумать, что все вышеперечисленное не имеет значения, потому что каждый раз, когда новая лямбда передается pointerInput
с текущими на тот момент объектами, но pointerInput только обновляет лямбду (и перезапускает его) при изменении ключей, которые вы предоставляете в качестве параметров. И поскольку вы указали pointerInput
для этого ключа, pointerInput всегда сохраняет старую лямбду, созданную при первом вызове pointerInput. Более подробно это объясняется в документации pointerInput.
Вы можете просто исправить свой код, если укажете Unit
в качестве ключа (вместо size
), тогда при каждом изменении значений в лямбда-выражении снова фиксируются, и все работает как задумано.
Теперь перейдем ко второму примеру, который, к счастью, намного проще: поскольку вы также используете Unit
с делегированием derivedStateOf
, у вас под капотом фактически есть объект State, например by
, который фиксируется в лямбде. Это работает по тем же причинам, по которым width
работал в первом примере.
За исключением одного предостережения: поскольку вы не сделали width
производное состояние, при каждой рекомпозиции создается и сохраняется новое состояние remember
. Однако это не имеет значения, поскольку лямбда использует только первый объект состояния, который был создан таким образом, и который автоматически обновляется при изменении size
(или width
, если уж на то пошло), вот как height
работает. Но вам также следует улучшить это состояние, чтобы оно не воссоздавалось без необходимости при перекомпоновке.
А в третьем примере вам даже не нужно изменяемое состояние, поскольку операторы печати не помещаются внутри ламбы:
val size = Size(width, height)
Это также просто работает так, как ожидалось.
Спасибо за подробный ответ! У меня еще есть один вопрос. Если в первом примере я использую: val size by remember{mutableStateOf(Size(width,height))}
. это должно работать аналогично тому, почему width
работает, как вы объяснили, но это не работает! Почему это? Даже если я добавлю width
и height
в качестве ключей к remember
, это все равно не сработает. Почему?
Это будет выполнено только один раз, в первой композиции. Он не будет автоматически создавать новые Size
объекты при каждой рекомпозиции. Чтобы принудительно это сделать, вам необходимо предоставить width
и height
в качестве ключей, чтобы они пересчитывались при их изменении: val size by remember(width, height) { mutableStateOf(Size(width, height)) }
. И теперь это сработает (по той же причине width
уже сработало).
Не работает даже при использовании ключей! В отладчике я вижу, что значение размера обновляется правильно, но лямбда-выражение по-прежнему печатает ноль.
Ах, извините, вы, конечно, правы: хотя сейчас это правильно обновляет size
(чего раньше не было), каждый раз создается новый объект State. Так что это больше похоже на то, что было в вашем первом примере вначале. Это помогает только предотвратить воссоздание объекта Size(...)
при рекомпозиции, где width
и height
остаются прежними (т. е. рекомпозиция была вызвана чем-то другим). Вам все равно нужно передать size
в качестве параметра key
в pointerInput
.
Спасибо за Ваш ответ! Я понимаю, что
derivedStateOf
работает, но мне интересно, почему это работает в данном случае. В документации также предполагается, чтоderivedStateOf
может не подойти для этой ситуации. Второй подход сmutableStateOf
не работает независимо от того, используются ли клавиши с запоминанием или нет. Однако использованиеrememberUpdatedState
работает. Мой вопрос: почему происходит такое поведение, помимо простого предоставления правильного решения?