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
.
Спасибо, что прочитали статью! Если она была полезной, не забудьте поставить лайк и поделиться своими мыслями в комментариях. Ваш фидбек поможет мне делать материалы еще лучше.
ссылка на оригинал статьи https://habr.com/ru/articles/859084/
Добавить комментарий