Jetpack Compose — это мощный инструмент, который упрощает создание UI в Android, но его освоение может быть не таким уж простым. Многие разработчики сталкиваются с неожиданными результатами и ошибками, которые на первый взгляд кажутся неочевидными. Сегодня разберем один из таких примеров и посмотрим, как зациклить рекомпозицию в Compose — и самое главное, как этого избежать.
Пример кода
Допустим, у нас есть следующий код:
data class MyDataClass(
val i: Int = 0,
val block: () -> Unit = {},
)
class MyScreenViewModel : ViewModel() {
private val dataSource = MutableSharedFlow<Int>(1)
val stateValue: StateFlow<MyDataClass>
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 вы увидите, что выполняется бесконечная рекомпозиция.
Почему так происходит и как это исправить? Давайте разберемся.
Причина проблемы
Давайте подробнее разберем код collectAsStateWithLifecycle()
и поймем, как именно это происходит:
@Composable
fun <T> Flow<T>.collectAsStateWithLifecycle(
initialValue: T,
lifecycle: Lifecycle,
minActiveState: Lifecycle.State = Lifecycle.State.STARTED,
context: CoroutineContext = EmptyCoroutineContext
): State<T> {
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 <T> produceState(
initialValue: T,
vararg keys: Any?,
producer: suspend ProduceStateScope<T>.() -> Unit
): State<T> {
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<in P1, in P2, in P3, out R> : kotlin.Function<R> {
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<Int>(1)
val stateValue: StateFlow<MyDataClass> = dataSource
.map { number -> MyDataClass(number, { println("Hello, World!") }) }
.stateIn(viewModelScope, SharingStarted.Eagerly, MyDataClass())
}
Теперь рекомпозиция перестанет зацикливаться, так как Compose
сможет корректно сравнивать объекты MyDataClass
и не будет создавать новых подписчиков StateFlow
.
Спасибо, что прочитали статью! Если она была полезной, не забудьте поставить лайк и поделиться своими мыслями в комментариях. Ваш фидбек поможет мне делать материалы еще лучше.
Комментарии (6)
brabus0202
17.11.2024 18:02Попробовал у себя запустить код и рекомпозиций не наблюдаю
theexclusive Автор
17.11.2024 18:02Интересно. Может от версии компоуза зависит? Можно тебя попросить залить код на гитхаб чтобы самому поковырять? Было бы интересно разобраться
FirsofMaxim
17.11.2024 18:02Спасибо за статью. В data-class использовать лямбды, это некомильфо.
theexclusive Автор
17.11.2024 18:02Полностью согласен про data-class. В статье показываю какие последствия могут быть из-за таких решений.
D7ILeucoH
Ну в целом код выглядит абсурдным. Естественно получать Стейт созданием нового флоу это полный бред. Для этого уже давно придумали паттерн с private mutable value "_", где такая проблема решается автоматически.
Да и в целом получим более чистый код, так как изменение стейта будет делаться через отдельный метод. На уровне этого метода ты можешь делать кучу другой логики, помимо переопределения своего метода сравнения.
Использование лямбды внутри стейта - очень спорный вариант. Из моей практики такое было лишь несколько раз, при этом это решало, например, архитектурные проблемы (навигации со шторками). Так что хочется узнать, на каком реальном примере в похожем контексте будет нужна лямбда.
Мало того что лямбда мало где нужна, твой код ещё и зачем-то постоянно её пересоздаёт. Это не имеет смысла. Да и к тому же можно было отфильтровать dataSource на предмет одинаковых значений, например через distinct. Тогда не будет дудоса с пересозданием лямбд.
theexclusive Автор
Полностью с тобой согласен. Я тоже бы никогда не написал подобный код в приложении.
Но эта статья статья вдохновлена как раз именно таким багом который я недавно фиксил в новом проекте. Меня лично больше всего удивило то что вот такими неявными контрактами можно зациклить рекомпозицию. И самое интересное что экран будучи зациклинным вполне нормально себя вел и никак не выдавал потенциальные проблемы перфоманса.
Собственно в статье я хотел поделиться своим кейсом и рассказать что бывает и такое, а хорошие/правильныеПодходы/бестПрактис это уже другая тема.