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/
Добавить комментарий