[Перевод] О зацикливании рекомпозиции в Jetpack Compose

Фотограф: Laura Cleffmann: https://www.pexels.com/ru-ru/photo/20001993/

Фотограф: 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, хотя логи продолжают исправно печататься.

Бесконечная рекомпозиция. Интересно что счетчик рекомпозиций в 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. Это позволяет получать новые данные из потока.

Теперь нам становятся более очевидны проблемы нашего кода, а именно:

  1. Каждый раз, когда мы обращаемся к stateValue, выполняется блок get(), создающий новый экземпляр StateFlow. Это приводит к новому запуску LaunchedEffect внутри функции collectAsStateWithLifecycle() и в результате создается еще один подписчик нашего стейта.

  2. При каждом вызове 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  
}

Решение проблемы

Для решения этой проблемы можно использовать два подхода:

  1. Изменить методы equals и hashCode в MyDataClass, чтобы исключить переменную block из расчетов. Тогда объекты MyDataClass(1, {}) будут считаться равными, и Compose не будет триггерить рекомпозицию.

  2. Удалить функцию 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.

Спасибо, что прочитали статью! Если она была полезной, не забудьте поставить лайк и поделиться своими мыслями в комментариях. Ваш фидбек поможет мне делать материалы еще лучше.

© Habrahabr.ru