Android-разработчик red_mad_robot Серёжа Чумиков рассказывает о том, как сделать классную анимацию, не перегрузив смартфон, почему ей не нужна рекомпозиция и как её избежать.
Некоторые особенности анимации
Создать анимацию — стандартная задача для разработчика, но подойти к ней можно по-разному. Сделать анимацию энергоэффективной и не перегрузить батарею смартфона — в этих деталях вся суть.
Рекомпозиция UI-дерева может быть затратной операцией и приводить к увеличению потребления и уменьшению заряда батареи.
Composable и Suspend: две группы функций
Есть две группы анимации: более универсальные и более специфичные.
Нужно иметь в виду, что Composable-функции реализованы с помощью Suspend-функций. Чтобы разобраться, в чём разница между этими двумя группами, нужно понять, за что отвечают функции:
animate*AsState — базовая анимации (числа, цвета, рамки, и т. д.). Её используют, когда нужно анимировать переход от одного состояния к другому — например, от синего цвета к красному или от 0 до 100.
AnimatedVisibility — анимация появления/скрытия.
AnimatedContent и Crossfade — анимации перехода от одного контента к другому.
updateTransition — позволяет запускать несколько анимаций одновременно.
А вот функции группы Suspend:
Animatable — расширенная анимация перехода от одного значения к другому (содержит в себе AnimationState), обеспечивает согласованность при отмене и начале новой анимации. У этой функции более богатое API, чем у AnimationState.
AnimationState — простая анимация от одного значения к другому (использует animate) с сохранением промежуточного состояния.
animate — базовая Suspend-функция анимации, выдающая поток готовых анимированных чисел через колбэк без хранения промежуточного состояния. Использует TargetBasedAnimation, DecayAnimation.
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
Чем меньше рекомпозиции, тем энергоэффективнее будет анимация. А стационарной анимации рекомпозиция не нужна вообще. Но что такое стационарная анимация?
Рекомпозиция в стационарной анимации
Если взглянуть на анимации конкретных элементов, не связанных с перемещением (прогресс-бар, анимированная картинка и т. п.), можно заметить, что они обладают общими свойствами:
Анимация показывается всегда или почти всегда, входящие переменные стабильны (State<T>), композиция остаётся без изменений.
Область, в которой происходит прорисовка, находится на одном и том же месте. Размер и местоположение не меняются. Анимация не влияет на «родителей».
Такая анимация не имеет «детей». Её состояние (композиция, размер, местоположение) не влияет ни на кого.
У неё нестандартная прорисовка.
Такую анимацию можно назвать стационарной, или независимой.
В результате оказывается, что если все изменения происходят только на стадии прорисовки, то рекомпозиция должна быть равна нулю. Если такую анимацию делать только с помощью 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 просто отсутствовали. Сейчас они существуют:
Счётчик рекомпозиций в LayoutInspector из AndroidStudio.
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-разработчика.
Над материалом работали:
текст — Серёжа Чумиков, Алина Ладыгина,
редактура — Виталик Балашов,
иллюстрации и анимации — Марина Черникова.
Чтобы ничего не пропустить, следи за развитием цифры вместе с нами:
Да пребудет с тобой сила роботов! ????