Фотограф: 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<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 вы увидите, что выполняется бесконечная рекомпозиция.

Бесконечная рекомпозиция. Интересно что счетчик рекомпозиций в Layout Inspector остановился на числе 80, хотя логи продолжают исправно печататься.
Бесконечная рекомпозиция. Интересно что счетчик рекомпозиций в Layout Inspector остановился на числе 80, хотя логи продолжают исправно печататься.

Почему так происходит и как это исправить? Давайте разберемся.

Причина проблемы

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

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

  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<in P1, in P2, in P3, out R> : kotlin.Function<R> {  
    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<Int>(1)

    val stateValue: StateFlow<MyDataClass> = dataSource
        .map { number -> MyDataClass(number, { println("Hello, World!") }) }
        .stateIn(viewModelScope, SharingStarted.Eagerly, MyDataClass())
}

Теперь рекомпозиция перестанет зацикливаться, так как Compose сможет корректно сравнивать объекты MyDataClass и не будет создавать новых подписчиков StateFlow.


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

Комментарии (6)


  1. D7ILeucoH
    17.11.2024 18:02

    Ну в целом код выглядит абсурдным. Естественно получать Стейт созданием нового флоу это полный бред. Для этого уже давно придумали паттерн с private mutable value "_", где такая проблема решается автоматически.

    Да и в целом получим более чистый код, так как изменение стейта будет делаться через отдельный метод. На уровне этого метода ты можешь делать кучу другой логики, помимо переопределения своего метода сравнения.

    Использование лямбды внутри стейта - очень спорный вариант. Из моей практики такое было лишь несколько раз, при этом это решало, например, архитектурные проблемы (навигации со шторками). Так что хочется узнать, на каком реальном примере в похожем контексте будет нужна лямбда.

    Мало того что лямбда мало где нужна, твой код ещё и зачем-то постоянно её пересоздаёт. Это не имеет смысла. Да и к тому же можно было отфильтровать dataSource на предмет одинаковых значений, например через distinct. Тогда не будет дудоса с пересозданием лямбд.


    1. theexclusive Автор
      17.11.2024 18:02

      Полностью с тобой согласен. Я тоже бы никогда не написал подобный код в приложении.

      Но эта статья статья вдохновлена как раз именно таким багом который я недавно фиксил в новом проекте. Меня лично больше всего удивило то что вот такими неявными контрактами можно зациклить рекомпозицию. И самое интересное что экран будучи зациклинным вполне нормально себя вел и никак не выдавал потенциальные проблемы перфоманса.

      Собственно в статье я хотел поделиться своим кейсом и рассказать что бывает и такое, а хорошие/правильныеПодходы/бестПрактис это уже другая тема.


  1. brabus0202
    17.11.2024 18:02

    Попробовал у себя запустить код и рекомпозиций не наблюдаю


    1. theexclusive Автор
      17.11.2024 18:02

      Интересно. Может от версии компоуза зависит? Можно тебя попросить залить код на гитхаб чтобы самому поковырять? Было бы интересно разобраться


  1. FirsofMaxim
    17.11.2024 18:02

    Спасибо за статью. В data-class использовать лямбды, это некомильфо.


    1. theexclusive Автор
      17.11.2024 18:02

      Полностью согласен про data-class. В статье показываю какие последствия могут быть из-за таких решений.