Site icon AppTractor

Устранение задержек в Composable: 3 практических метода для создания плавного UI

Мы часто совершаем ошибку, выполняя сложные операции внутри Composable-объектов.

Однако Composable-объект — это всего лишь функция, которая конструирует пользовательский интерфейс и работает в основном потоке.

Это означает, что во время выполнения Composable-объекта весь процесс конструирования и отрисовки дерева пользовательского интерфейса происходит в основном потоке.

Но что произойдёт, если в этот момент параллельно будут выполняться сложные операции?

Обновление пользовательского интерфейса не успеет обработаться вовремя, что приведёт к кратковременному зависанию или подтормаживанию экрана.

Это называется «задержками».

FrameTime

FPS (кадры в секунду) варьируется в зависимости от устройства, но, как правило, Android работает со скоростью 60 кадров в секунду.

Это означает, что 60 кадров должны быть отрисованы в течение 1 секунды (1000 мс), поэтому время, доступное для обработки одного кадра, следующее:

1000 / 60 = 16.63 мс

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

Превышение этого времени приводит к накоплению кадров, что приводит к заметным подтормаживаниям.

Пример задержки

Давайте рассмотрим следующий код:

@Composable
fun Test() {
    LazyColumn(
        modifier = Modifier
            .statusBarsPadding()
            .height(300.dp)
            .fillMaxWidth()
    ) {
        item {
            Text(
                text = "Janky Ui",
                fontSize = 32.sp
            )
        }

        items(100) {
            Text(computeHeavy(it))
        }
    }
}

fun computeHeavy(value: Int): String {
    Thread.sleep(100)

    return "$value computed!"
}

При каждой отрисовке элемента LazyColumn вызывается computeHeavy(), что приводит к полной остановке обновления пользовательского интерфейса, пока эта функция выполняется в основном потоке. Сто миллисекунд значительно превышают требуемое время на кадр в 16.63 мс, что приводит к откладыванию нескольких кадров и серьёзным подтормаживаниям экрана (задержкам).

Другими словами, пользовательский интерфейс всё ещё пытается отрисовываться с частотой 60 кадров в секунду, но основной поток слишком занят вычислениями, чтобы выделить время на отрисовку.

Итак, как следует обрабатывать тяжёлые задачи, чтобы избежать задержек?

Вот 3 способа решения этой проблемы в среде Compose.

Обработка во ViewModel и отображение в UI

Лично я бы выбрал этот метод в первую очередь.

Тяжёлые задачи обрабатываются асинхронно во ViewModel, а пользовательский интерфейс просто отображает результаты, что приводит к чистому коду и чёткому распределению обязанностей.

class TestViewModel : ViewModel() {
    private val _computed = MutableStateFlow<List<String>>(emptyList())
    val computed = _computed.asStateFlow()

    init {
        viewModelScope.launch {
            println("launch: ${Thread.currentThread().name}")
            withContext(Dispatchers.IO) {
                val results = (0 until 100).map {
                    async {
                        println("Async: ${Thread.currentThread().name}")
                        computeHeavy(it)
                    }
                }.awaitAll()

                _computed.update { results }
            }
        }
    }
}

@Composable
fun Test(viewModel: TestViewModel) {
    val computed by viewModel.computed.collectAsState()

    LazyColumn(
        modifier = Modifier
            .statusBarsPadding()
            .height(300.dp)
            .fillMaxWidth()
    ) {
        item {
            Text(
                text = "viewModel",
                fontSize = 32.sp
            )
        }

        items(computed) {
            Text(it)
        }
    }
}

Важно отметить, что viewModelScope по умолчанию использует Dispatchers.Main.immediate. Это означает, что если вы явно не укажете другой диспетчер, все корутины, запущенные в нём, будут выполняться в основном потоке.

Поэтому необходимо явно переключить тяжёлые задачи на Dispatchers.IO или Dispatchers.Default, чтобы избежать их запуска в MainThread и предотвратить задержки обновления.

Когда переключение контекста не выполняется с помощью WithContext

Когда переключение контекста выполняется с помощью WithContext

Асинхронная обработка с помощью ProduceState

ProduceState, который асинхронно извлекает состояние, может быть эффективным решением.

@Composable
fun Test() {
    val computed by produceState(emptyList()) {
        value = withContext(Dispatchers.IO) {
            (0 until 100).map {
                async { computeHeavy(it) }
            }.awaitAll()
        }
    }

    LazyColumn(
        modifier = Modifier
            .statusBarsPadding()
            .height(300.dp)
            .fillMaxWidth()
    ) {
        item {
            Text(
                text = "produceState",
                fontSize = 32.sp
            )
        }

        items(computed) {
            Text(it)
        }
    }
}

В produceState можно использовать suspend для асинхронной инициализации, что делает этот подход эффективным.

В этом случае также обязательно переключите контекст с помощью Dispatchers.IO или Dispatchers.Default.

Асинхронная обработка через LaunchedEffect

Использование LaunchedEffect для обработки внутри EffectHandler также является допустимым подходом.

@Composable
fun Test() {
    val computed = remember { mutableStateListOf<String>() }

    LaunchedEffect(Unit) {
        val results = withContext(Dispatchers.IO) {
            (0 until 100).map {
                async { computeHeavy(it) }
            }.awaitAll()
        }

        computed.addAll(results)
    }

    LazyColumn(
        modifier = Modifier
            .statusBarsPadding()
            .height(300.dp)
            .fillMaxWidth()
    ) {
        item {
            Text(
                text = "LaunchedEffect",
                fontSize = 32.sp
            )
        }

        items(computed) {
            Text(it)
        }
    }
}

LaunchedEffect также должен переключать контексты с помощью Dispatchers.IO или Dispatchers.Default.

ProduceState или LaunchedEffect: какой из них использовать?

produceState внутри использует LaunchedEffect:

Реализация produceState

Но, как следует из названия, она ориентирована на создание состояния.

Подумайте об этом. Разве не странно было бы представить себе какой-то эффект, возникающий при каждой инициализации состояния?

Поэтому, если вам просто нужно асинхронно инициализировать состояние, использование produceState вполне уместно.

С другой стороны, если вам нужны дополнительные эффекты наряду с инициализацией, использование LaunchedEffect предпочтительнее.

В заключение

Редко возникает необходимость выполнять сложные задачи непосредственно в пользовательском интерфейсе, и большая часть обработки будет выполняться в ViewModel.

Однако, если у вас много работы или вам не хватает опыта с Compose, вам может потребоваться выполнять значительный объём кода внутри Composable-объектов для отображения состояния в пользовательском интерфейсе.

Надеюсь, когда придёт время, вы вспомните представленные здесь решения и сочтёте их полезными.

Источник

Exit mobile version