Android-разработчик red_mad_robot Серёжа Чумиков рассказывает о том, как сделать классную анимацию, не перегрузив смартфон, почему ей не нужна рекомпозиция и как её избежать.


Некоторые особенности анимации

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

Рекомпозиция UI-дерева может быть затратной операцией и приводить к увеличению потребления и уменьшению заряда батареи.

Composable и Suspend: две группы функций

Есть две группы анимации: более универсальные и более специфичные.

Нужно иметь в виду, что Composable-функции реализованы с помощью Suspend-функций. Чтобы разобраться, в чём разница между этими двумя группами, нужно понять, за что отвечают функции:

  1. animate*AsState — базовая анимации (числа, цвета, рамки, и т. д.). Её используют, когда нужно анимировать переход от одного состояния к другому — например, от синего цвета к красному или от 0 до 100.

  2. AnimatedVisibility — анимация появления/скрытия.

  3. AnimatedContent и Crossfade — анимации перехода от одного контента к другому.

  4. updateTransition — позволяет запускать несколько анимаций одновременно.

А вот функции группы Suspend:

  1. Animatable — расширенная анимация перехода от одного значения к другому (содержит в себе AnimationState), обеспечивает согласованность при отмене и начале новой анимации. У этой функции более богатое API, чем у AnimationState.

  2. AnimationState — простая анимация от одного значения к другому (использует animate) с сохранением промежуточного состояния.

  3. animate — базовая Suspend-функция анимации, выдающая поток готовых анимированных чисел через колбэк без хранения промежуточного состояния. Использует TargetBasedAnimation, DecayAnimation.

  4. TargetBasedAnimation, DecayAnimation — низкоуровневые классы, позволяющие контролировать время выполнения анимации.

Чаще разработчики используют для анимации Composable-функции, которые нельзя назвать универсальными. Их использование без учёта специфики увеличивает число рекомпозиций и повышает энергопотребление.

Composable-функции подходят для простых случаев, когда опорное значение (то, на основе которого строится анимация) меняется достаточно редко. Например, задано первоначальное значение (число, цвет, прозрачность и т. д.) и конечное, до которого идёт анимация. Анимация прошла — все дальнейшие изменения, например нажатие кнопки, будут нескоро или не будут вообще.

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

Дальше в статье мы будем говорить только о первых двух функциях — Animatable и AnimationState, потому что они проще в использовании и уже включают в себя следующие три более низкоуровневые функции.

Composable-анимация. animateAsState и аналоги

Базовая проблема с Composable-функциями

Функция animateFloatAsState выдаёт последовательный список чисел, а точнее возвращает объект State<Float>.

@Composable
fun Header(show: Boolean) {
    val myAlpha by animateFloatAsState(target = if (show) 1f else 0f)
    Item(modifier = Modifier.alpha(myAlpha)) // До 100 рекомпозиций. Стадия композиции.
}

Здесь меняется прозрачность (alpha) элемента Item от 0 до 1 (по умолчанию шаг 0,01). 0 — абсолютная прозрачность, 1 — полная видимость.

animateFloatAsState возвращает State<Float>. При чтении из делегата, который находится внутри Compose-функции, происходит рекомпозиция Compose-функции (Header) с новым вещественным числом, а уже затем число передаётся модификатору элемента Item. То есть передача значения модификатору происходит на стадии рекомпозиции.

В качестве альтернативы можно не использовать модификатор alpha. Он нужен для инициализации прозрачности элемента и его редкого изменения на стадии композиции. Вместо этого можно изменять прозрачность на стадии прорисовки:

@Composable
fun Header(show: Boolean) {
    val myAlpha = animateFloatAsState(target = if (show) 1f else 0f)
    Item(                                                   // 1 композиция.
        modifier = Modifier.graphicsLayer {
            alpha = myAlpha.value                  // Стадия рисования
        }
    )
}

Если для конечной анимации ошибка в использовании Composable-функций не так критична (она ведь всё равно закончится), то в случае с бесконечной анимацией и рекомпозиция будет идти бесконечно.

@Composable
fun Header() {
    val transition = rememberInfiniteTransition()
    val rotation = transition.animateValue(
        initialValue = 0,
        targetValue = 360,
        typeConverter = Int.VectorConverter,
        animationSpec = infiniteRepeatable(tween(DURATION))
    )

    DrawClock(rotation.value) // Другая функция, которая рисует круг с вращающейся часовой стрелкой. 360 рекомпозиций за поворот.
}

private const val DURATION = 2000 // Полный оборот за две секунды

Небольшие хитрости

Иногда можно схитрить и передать State<T> через все Composable-функции до стадии рисования и только после этого использовать результат без опасения. Если попутно нужно преобразовать значение, можно использовать derivedStateOf {}, который преобразует один State в другой.

Внутри лямбды derivedStateOf {} мы задаём алгоритм преобразования значения State<T> в другое значение, после чего результат оборачивается в новый стейт типа State<R>. По смыслу это похоже на функцию map {}.

В примере ниже — из State<Int> в State<Float>.

Допустим, мы хотим, чтобы плавно появлялся индикатор загрузки при значении прогресса, отличного от нуля, и исчезал при 0.

@Composable
fun AnimatedProgress(progress: State<Int>) { // 2 рекомпозиции.
    // Вместо 100 значений Int только два состояния Float!
    val targetAlpha = remember { 
        derivedStateOf {
            if (progress.value > 0) 1f else 0f)
        } 
    }

    // Две рекомпозии.
    val alpha = animateFloatAsState(target = targetAlpha.value)

    // А вот так было бы 100 рекомпозий.
    // val alpha = animateFloatAsState(target = if (progress.value > 0))

    Canvas { 
        // Стадия рисования. Получаем множество значений alpha (Float) от 0f до 1f.
        doSomething(alpha.value, progress.value)
    }
}

Иногда ничего не помогает

Например, у нас есть линейный индикатор, отображающий прогресс. Поскольку прогресс может увеличиваться случайно и скачкообразно (допустим, 1, 13, 15, 22, 60, 90, 100), то, чтобы сгладить его, мы принимаем решение — значения прогресса будут пропускаться через функции анимации, которые сами заполнят промежуточные значения между скачками прогресса.

// Рекомпозируется столько раз, сколько изменилось значение progress: вплоть до 100 для Int, или до 10000 для Float, Double (если точность до сотых — от 0.00 до 100.00)
fun AnimatedProgress(progress: Int) {
    val animatedValue = animateFloatAsState(target = progress)
    Canvas {
        drawProgress(animatedValue.value)
    }
}

В результате мы получим рекомпозиции на каждое изменения прогресса, а их может быть очень много. И если индикаторов несколько, это перерастает в серьёзную проблему. Замена progress на State<Int> не поможет — проблема просто перейдёт на строку ниже.

fun AnimatedProgress(progress: State<Int>) { 
     // Читаем progress.value внутри Compose-функции. Очень много рекомпозиций.
    val animatedValue = animateFloatAsState(target = progress.value)
    Canvas {
        drawProgress(animatedValue.value)
    }
}

В этом случае выручит Suspend-анимация.

Suspend-анимации. Animatable и AnimationState

Чем меньше рекомпозиции, тем энергоэффективнее будет анимация. А стационарной анимации рекомпозиция не нужна вообще. Но что такое стационарная анимация?

Рекомпозиция в стационарной анимации

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

  1. Анимация показывается всегда или почти всегда, входящие переменные стабильны (State<T>), композиция остаётся без изменений.

  2. Область, в которой происходит прорисовка, находится на одном и том же месте. Размер и местоположение не меняются. Анимация не влияет на «родителей».

  3. Такая анимация не имеет «детей». Её состояние (композиция, размер, местоположение) не влияет ни на кого.

  4. У неё нестандартная прорисовка.

Такую анимацию можно назвать стационарной, или независимой.

В результате оказывается, что если все изменения происходят только на стадии прорисовки, то рекомпозиция должна быть равна нулю. Если такую анимацию делать только с помощью Composable-функций, мы неизбежно получим рекомпозиции, за исключением совсем простых случаев. Благодаря Suspend-функции этого можно избежать.

Как вообще избежать рекомпозиций

Возвращаемся к анимации линейного индикатора прогресса:

@Composable
fun AnimatedProgress(progress: State<Float>) {
    // При создании Animatable мы нигде не читаем progress.value, поэтому рекомпозиции нет.
    val animatedProgress = remember {
        Animatable(
                initialValue = 0f,
                typeConverter = Float.VectorConverter,
                visibilityThreshold = 0.01f,
                label = "MyAnimation"
        )
    }

    LaunchedEffect(Unit) {                    // Вне рекомпозиции
        snapshotFlow { progress.value } // Чтение (подписка) — вне рекомпозиции
            .collect { newValue ->
                animatedProgress.animateTo(newValue, tween(DURATION)) // suspend
            }
    }

    Canvas {
        // Здесь уже стадия отрисовки, поэтому можно безопасно читать animatedProgress.value
        drawProgress(animatedProgress.value)     }
}

private const val DURATION = 100

При том же результате — анимации изменения прогресса Composable-функции нигде не использованы, а подписка на progress.value происходит за пределами стадии рекомпозиции. И если в примере с использованием Composable-функций в зависимости от реализации может быть до 100 или даже 10 000 рекомпозиций, то для примера выше — 0.

Почему об этом почти никто не задумывается? Дело в том, что до определённого момента ясные метрики Compose просто отсутствовали. Сейчас они существуют:

  1. Счётчик рекомпозиций в LayoutInspector из AndroidStudio.

  2. Compose-метрики стабильности, выдаваемые компилятором.

У применяемого выше класса Animatable есть три главных метода:

  • animateTo — анимация к новому значению с предварительной приостановкой предыдущей анимации;

  • snapTo — мгновенный переход к новому значению с предварительной приостановкой предыдущей анимации;

  • stop() — остановка анимации на текущем значении.

Есть также свойство value. На самом деле это (by) делегат от State<T> для текущего значения анимации. Поэтому стоит быть осторожнее и не допускать его чтения в Compose-функции, иначе теряется весь смысл использования Suspend-функций и State<T>.

Решение проблем с приостановкой бесконечной анимации

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

@Composable
fun AnimatedLoader() {
    // Цепляюсь к скоупу функции, чтобы анимация прекратилась при декомпозиции.
    val scope = rememberCoroutineScope() 

    val rotation = remember { Animatable(0, Int.VectorConverter) }
    val infiniteSpec = remember { infiniteSpec(tween(DURATION)) }
    Column {
        Button(onClick = { scope.launch { rotation.animateTo(MAX_ANGLE, infiniteSpec) } })
        Button(onClick = { scope.launch { rotation.stop() } })
    }

    Canvas { drawRoation(smoothProgress) }
}

private const val MAX_ANGLE = 360
private const val DURATION = 2000  //  один оборот за две секунды.

Анимацию можно запустить и приостановить по кнопке. Всё будет хорошо до тех пор, пока вы не запустите анимацию снова. Лоадер будет крутиться только часть оборота. Если раньше он полностью проходил путь от 0° до 360°, то теперь — только от части круга до 360°, например от 45° до 360° или от 286° до 360°. То есть от того места, где вы остановили анимацию.

Дело в том, что в момент остановки сдвигается начальная точка анимации: с 0° на тот момент, где вы её остановили.

Простое, но неполное решение — перескок в начало при повторном запуске анимации.

То есть этот вариант:

Button(onClick = { scope.launch { rotation.animateTo(MAX_ANGLE, infiniteSpec) } })

Заменить на этот:

Button(
    onClick = {
        scope.launch {
            rotation.snapTo(0) // Мгновенный переход в начало.
            rotation.animateTo(MAX_ANGLE, infiniteSpec)
        }
    }
)

Это даст полный круг, но при остановке и повторном запуске картинка резко перескочит в начало (0), что создаст неприятное ощущение от анимации.

Неприятно, не правда ли?
Неприятно, не правда ли?

Правильное решение — «смещение во времени» в настройках перезапуска анимации. Поскольку результат функции infiniteSpec иммутабельный, мы не будем создавать спецификацию анимации в начале, а перенесём её на момент запуска анимации.

@Compose
fun RotationLayout() {
    val scope = rememberCoroutineScope()
    val rotation = remember { Animatable(0, Int.VectorConverter) }
    // Здесь мы сразу не определяем настройки бесконечной анимации (infiniteSpec), они будут ниже
    val tweenSpec = remember { tween(DURATION) }
    Column {
        Button(
            onClick = {
                scope.launch {
                    // Вычисление прошедшего времени от начала и до момента остановки.
                    val millisOffset = rotation.value / MAX_ANGLE * DURATION
                    // FastForward — Мгновенное смещение во времени.
                    val initialOffset = StartOffset(millisOffset, StartOffsetType.FastForward)
                    // Мгновенный переход в начало.
                    rotation.snapTo(0)
                    // Анимация после мгновенного смещения во времени.
                    rotation.animateTo(MAX_ANGLE, infiniteSpec(tweenSpec, initialOffset))
                }
            }
        )
        Button(onClick = { scope.launch { rotation.stop() } })
    }

    Canvas { drawRoation(rotation.value) }
}

private const val MAX_ANGLE = 360
private const val DURATION = 1000

Теперь анимация запускается с того же места, на котором остановилась. Вы великолепны.

Другое дело!
Другое дело!

И как теперь с этим жить

Любой инструмент удобен и эффективен, если применять его в подходящих ситуациях. Это не призыв полностью отказываться от Composable-функций в угоду Suspend-функциям. Следует понимать природу и область применения первых — это компактные простые анимации без частого изменения входящих параметров. Но за это удобство и компактность можно заплатить большую цену, если не смотреть на метрики. Suspend-функции дают больше свободы, но требуют больше труда, поэтому они незаменимы в сложных случаях. Как говорится, выбирай мудро.


Кстати, у нас открыта вакансия старшего android-разработчика.

Над материалом работали:

  • текст — Серёжа Чумиков, Алина Ладыгина,

  • редактура — Виталик Балашов,

  • иллюстрации и анимации — Марина Черникова.

Чтобы ничего не пропустить, следи за развитием цифры вместе с нами:

Да пребудет с тобой сила роботов! ????

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