[Перевод] О зацикливании рекомпозиции в Jetpack Compose
Фотограф: Laura Cleffmann: https://www.pexels.com/ru-ru/photo/20001993/
Jetpack Compose — это мощный инструмент, который упрощает создание UI в Android, но его освоение может быть не таким уж простым. Многие разработчики сталкиваются с неожиданными результатами и ошибками, которые на первый взгляд кажутся неочевидными. Сегодня разберем один из таких примеров и посмотрим, как зациклить рекомпозицию в Compose — и самое главное, как этого избежать.
Пример кода
Допустим, у нас есть следующий код:
data class MyDataClass(
val i: Int = 0,
val block: () -> Unit = {},
)
class MyScreenViewModel : ViewModel() {
private val dataSource = MutableSharedFlow(1)
val stateValue: StateFlow
get() = dataSource
.map { number -> MyDataClass(number, { println("Hello, World!") }) }
.stateIn(viewModelScope, SharingStarted.Eagerly, MyDataClass())
}
@Composable
fun MyScreen(viewModel: MyScreenViewModel) {
Log.d("[TAG]", "Recomposition!")
val state by viewModel.stateValue.collectAsStateWithLifecycle()
val checked = remember { mutableStateOf(false) }
Column {
Checkbox(
checked = checked.value,
onCheckedChange = { isChecked -> checked.value = isChecked }
)
Text("state: ${state.i}")
}
}
На первый взгляд, код выглядит нормально. Однако если запустить его и нажать на Checkbox то посмотрев в LogCat и Layout Inspector вы увидите, что выполняется бесконечная рекомпозиция.
Бесконечная рекомпозиция. Интересно что счетчик рекомпозиций в Layout Inspector остановился на числе 80, хотя логи продолжают исправно печататься.
Почему так происходит и как это исправить? Давайте разберемся.
Причина проблемы
Давайте подробнее разберем код collectAsStateWithLifecycle()
и поймем, как именно это происходит:
@Composable
fun Flow.collectAsStateWithLifecycle(
initialValue: T,
lifecycle: Lifecycle,
minActiveState: Lifecycle.State = Lifecycle.State.STARTED,
context: CoroutineContext = EmptyCoroutineContext
): State {
return produceState(initialValue, this, lifecycle, minActiveState, context) {
lifecycle.repeatOnLifecycle(minActiveState) {
if (context == EmptyCoroutineContext) {
this@collectAsStateWithLifecycle.collect { this@produceState.value = it }
} else withContext(context) {
this@collectAsStateWithLifecycle.collect { this@produceState.value = it }
}
}
}
}
@Composable
fun produceState(
initialValue: T,
vararg keys: Any?,
producer: suspend ProduceStateScope.() -> Unit
): State {
val result = remember { mutableStateOf(initialValue) }
@Suppress("CHANGING_ARGUMENTS_EXECUTION_ORDER_FOR_NAMED_VARARGS")
LaunchedEffect(keys = keys) {
ProduceStateScopeImpl(result, coroutineContext).producer()
}
return result
}
Функция collectAsStateWithLifecycle()
создает Compose-стейт, а затем подписывается на Flow
через LaunchedEffect
. Это позволяет получать новые данные из потока.
Теперь нам становятся более очевидны проблемы нашего кода, а именно:
Каждый раз, когда мы обращаемся к
stateValue
, выполняется блокget()
, создающий новый экземплярStateFlow
. Это приводит к новому запускуLaunchedEffect
внутри функцииcollectAsStateWithLifecycle()
и в результате создается еще один подписчик нашего стейта.При каждом вызове
stateIn
происходит новая подписка наdataSource
и не смотря на то чтоdataSource
никак не меняется, новый подписчик заново выполняет всю цепочку и создает объектMyDataClass
, который, хотя и являетсяdata
-классом, содержит лямбдуblock
, что не позволяет корректно сравнить объектыMyDataClass
.
Почему лямбды не равны друг другу
Простое выражение { println("Hello, World!") } != { println("Hello, World!") }
может показаться странным, но оно иллюстрирует ключевую проблему. Лямбды создают экземпляры интерфейса FunctionX
(где X — количество аргументов). Это значит, что два объекта FunctionX
будут сравниваться по ссылкам.
// Пример одной из FunctionX: Function3 c 3мя входными параметрами
public interface Function3 : kotlin.Function {
public abstract operator fun invoke(p1: P1, p2: P2, p3: P3): R
}
Решение проблемы
Для решения этой проблемы можно использовать два подхода:
Изменить методы
equals
иhashCode
вMyDataClass
, чтобы исключить переменнуюblock
из расчетов. Тогда объектыMyDataClass(1, {})
будут считаться равными, иCompose
не будет триггерить рекомпозицию.Удалить функцию
get()
вstateValue
, чтобы не создавать новый объектStateFlow
каждый раз, а использовать один экземпляр.
Оптимально будет объединить оба подхода, и тогда мы получим следующий код:
data class MyDataClass(
val i: Int = 1,
val block: () -> Unit = {},
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as MyDataClass
return i == other.i
}
override fun hashCode(): Int {
return i
}
}
class MyScreenViewModel : ViewModel() {
private val dataSource = MutableSharedFlow(1)
val stateValue: StateFlow = dataSource
.map { number -> MyDataClass(number, { println("Hello, World!") }) }
.stateIn(viewModelScope, SharingStarted.Eagerly, MyDataClass())
}
Теперь рекомпозиция перестанет зацикливаться, так как Compose
сможет корректно сравнивать объекты MyDataClass
и не будет создавать новых подписчиков StateFlow
.
Спасибо, что прочитали статью! Если она была полезной, не забудьте поставить лайк и поделиться своими мыслями в комментариях. Ваш фидбек поможет мне делать материалы еще лучше.