Оптимизация анимации на Compose: крутим лоадеры, ищем неочевидные функции

от автора

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-разработчика.

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

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

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

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

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

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


ссылка на оригинал статьи https://habr.com/ru/companies/redmadrobot/articles/751976/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *