Меня зовут Евгений Подивилов, я фронтенд-разработчик в команде «Лайфстайл». Я разрабатываю раздел «Развлечения». В этом разделе можно купить билеты на мероприятия или забронировать столик в ресторане.

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

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

С чего мы начинали

Однажды при открытии нескольких SPA-приложений разом топовый Макбук завыл. Мы провели расследование и выяснили, что причиной стал обычный спиннер:

Оказалось, что на него тратилось почти 10% ресурсов CPU! Мне стало интересно разобраться и все оптимизировать, и я погрузился в код.

Все замеры производились на конфигурации: Google Chrome версии 95.0.4638.54; macOS Big Sur 11.6; MacBook Pro 15 Mid 2015, Intel Core i7 2.2Ghz, 16Gb DDR3.

Решение первое: React

Я начал с решения на React. Каждый цикл анимации вычислял значения, устанавливал их в стейт компонента, а затем использовал в render-функции. Каждые 16 мс запускались те механизмы React, за которые разработчики его так «любят». Не самое хорошее решение.

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

Цикл анимации на React
Цикл анимации на React

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

Потребление памяти на React
Потребление памяти на React

Я подумал, что где-то подтекает память, потом присмотрелся к графику и увидел, что потребление памяти всегда находится в каком-то константном диапазоне и на протяжении работы приложения не выходит за его рамки. В коде мы обнаружим такую конструкцию:

Массив colorTable хранит 10 константных значений. Его инициализация находится внутри функции анимации, поэтому мы создаем этот массив в каждом цикле, каждые 16 мс. Каждую секунду мы создаем десятки новых массивов, и это не очень хорошо.

JS — язык с автоматическим управлением памятью. Это значит, что разработчикам не надо задумываться над выделением памяти при создании чего-либо. Память выделяется автоматически, когда появляется массив, и освобождается автоматически, когда мы больше не используем его.

За процесс определения неиспользуемой памяти и ее освобождение отвечает Garbage Collector. Процесс определения неиспользуемой памяти трудозатратный для компьютера, и, чтобы минимизировать влияние на работу страницы, браузеры периодически запускают GC. Когда очень быстро создаются массивы, достигается некий предел, после которого браузер запускает GC и высвобождает всю неиспользуемую память. Потом все повторяется.

Подробнее про GC на примере Chrome можно прочитать на v8.dev.

Решение второе: SVG + JS

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

Теперь на вкладке Performance цикл анимации выглядит проще, но не значительно лучше: self time задачи уменьшилось с 1,55 до 0,17 мс, но общее время снизилось только до 4,69 мс против 5,69 мс в версии с React.

Цикл анимации на SVG + JS
Цикл анимации на SVG + JS

Проблемы с памятью уменьшились, но не исчезли. Если воспользоваться вкладкой Memory и сравнить heap snapshot до и после принудительного вызова GC, можно локализовать проблему памяти в недрах функции lerpColor, а точнее — в конкатенации строк во время формирования цвета.

Потребление памяти SVG + JS
Потребление памяти SVG + JS

Как и в предыдущем примере, значительную часть цикла анимации занимают задачи пересчета стилей и ре-лайаута. Наша анимация работает в рамках пайплайна отрисовки в браузерах.

С помощью JS меняем стили элемента path, что вызывает этап style. За ним вызывается layout, дальше paint и composite. Можно оптимизировать этот пайплайн и пропустить некоторые шаги, если использовать свойства, подходящие для анимаций. Например, пропустить этапы layout и paint, если анимация основана только на свойстве transform.

Сделать такую анимацию только на transform, кажется, не получится? "Чтобы сделать анимацию быстрой, нужно все писать на Canvas!" - подумал я.

Решение третье: Canvas

Перепишем все на Canvas. У него нет элементов и стилей. Меняя что-то, мы отрисовываем это на холсте без лишних этапов. Немного низкоуровнево, но мы же хотим лучшей производительности!

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

Цикл анимации на Canvas
Цикл анимации на Canvas

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

Есть возможность распараллелить выполнение задач через web workers. Но в них нет доступа к DOM, а значит, мы не сможем менять состояния элементов. И я решил избавиться от JS. 

Решение четвертое: SVG + CSS

Я вернулся к SVG, но переписал все на чистом CSS и попытался сделать анимацию менее зависимой от основного потока. Результат кажется более простым для понимания: вместо сложных вычислений сдвига и цвета мы задаем лишь опорные точки, а все расчеты по интерполяции значений между этими точками берет на себя браузер. Разработчики браузеров наверняка сделали эти вещи оптимальнее, чем мы со своими велосипедами.

Результаты улучшились, но проблема с фризами анимации осталась. Не все свойства CSS анимируются в отдельном потоке. В CSS можно анимировать с высокой производительностью только два свойства — transform и opacity. Зато с памятью теперь проблем нет.

Цикл анимации на SVG + CSS
Цикл анимации на SVG + CSS
Потребление памяти на SVG + CSS
Потребление памяти на SVG + CSS

Решение пятое: SVG

Я отказался от JS, получится ли отказаться и от CSS?

SMIL — это Synchronized Multimedia Integration Language, такой HTML для анимаций. Можно запрограммировать анимации в виде разметки с помощью набора тегов и атрибутов. Можно изменить код так, что вся анимация будет только в SVG-файле.

Без JS. Без CSS. Работает практически везде, кроме IE. Такую анимацию можно подключить с помощью тега <img> или свойства background-image. Но блокировка основного треда по-прежнему блокирует нашу анимацию.

Решение шестое: Video

Видео кажется отличной идеей и имеет ряд преимуществ:

  • расчеты всех этапов анимации выполняются заранее и «зашиваются» в видео,

  • поддерживается всеми браузерами,

  • существуют аппаратные оптимизации для некоторых видео кодеков.

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

<video width="100%" height="100%" autoplay loop muted playsinline>
  <source src="spinner.mov" type='video/mp4; codecs="hvc1"' />
  <source src="spinner.webm" type="video/webm" />
</video>

Но есть и несколько минусов:

  • нужен прозрачный фон. Даже если не надо поддерживать IE 11, все равно нужно минимум два варианта видео: с кодеком VP9 — для Chrome и HEVC — для Safari;

  • менять размеры видео без потери в качестве не получится. Значит, нужно несколько видеофайлов под каждый размер спиннера;

  • перезапуск видео выполняется в основном потоке, хотя само видео выполняется в отдельном. Спиннер будет крутиться, пока не проиграет один цикл анимации.

Решение седьмое: CSS

Но если подумать, что такое видео? Набор кадров, которые очень быстро переключаются. Что, если использовать именно это качество в CSS?

Звучит как бред, но раз я решил пробовать все варианты, то почему бы и нет. С помощью черной магии ffmpeg разложил видео покадрово, а с image magic собрал атлас из полученных кадров. Главное, чтобы фон был прозрачным. С помощью простых и быстрых CSS-трансформаций я менял кадры:

Сработало! Даже при условии, что основной поток занят JS. Да, по-прежнему есть проблемы с ресайзом, но их можно решить с помощью нескольких атласов. Мы даже можем подгружать атлас на сайт с использованием lazy-атрибута. <sarcasm>Кажется, я нашел идеальное решение.</sarcasm> 

Выводы

Кроме разных способов анимации я хотел показать, что в большинстве случаев нам достаточно простых, но быстрых решений. Даже создатели популярной библиотеки компонентов material-ui пошли по пути сокращения потребления ресурсов и в пользу UX.

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

Решения рабочие, но использовать их в продакшене крайне сомнительно. Для обычного спиннера слишком много затрат. В итоге нам удалось убедить дизайнеров и бизнес, что будет лучше заменить наш вычурный индикатор на что-то попроще. Например, такое:

Результаты профилирования решений

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


  1. AndreyMyagkov
    21.01.2022 16:32
    +9

    Сначала создали себе мегапролему на ровном месте, затем героически ее решили. Анимация на Реакте ????‍♂️ Куда руководство смотрит?)) Ну ладно вам интересно, но вашей компании то это зачем?) Добавлю еще решение - анимированный GIF.


    1. PaulZi
      21.01.2022 17:06

      Webp вроде ещё умеет анимацию, но не пробовал никогда


    1. epodivilov Автор
      21.01.2022 22:39
      +2

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

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

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

      Да, возможно это было бы можно решить с помощью современных форматов, типа Webp, но на момент поиска решения (~2 года назад) Safary не умел в Webp, а тем более анимированный


  1. asakasinsky
    21.01.2022 16:39
    +2

    <sarcasm>Кажется, я нашел идеальное решение.</sarcasm> 

    Сарказм тут и не нужен, кмк. Это решение отлично работало, работает и будет работать. Году в 2015-м, если память не подводит, ко мне пришёл заказ на «3D» просмотр товаров (простое вращение по оси X, на самом деле) без использования flash. Я таким образом и делал: брал видео прокрутки товара, делал ленту спрайтов и с помощью JS обрабатывал тач-движения. Плюс добавил ускорения, инерцию.

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


    1. epodivilov Автор
      21.01.2022 22:42

      Полностью согласен, что решение отличное. Но согласитесь, одно дело использовать его в каком-то специфическом случае, когда нам надо отобразить 3D задёшево, другое - использовать подобную технику для простого индикатора загрузки.


  1. fransua
    22.01.2022 01:19

    Есть возможность распараллелить выполнение задач через web workers. Но в них нет доступа к DOM, а значит, мы не сможем менять состояния элементов

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

    А для варианта с svg не помогло свойство contain? Должно бы layout уменьшить.


    1. epodivilov Автор
      22.01.2022 08:20

      Вы имеете в виду SMIL? Да, наверное это свойство могло бы ещё улучшить это и другие решения, но главная проблема в том, что все решения, кроме CSS работают в том же потоке.

      Тут либо спиннер оставлять в основном потоке, а всю работу с данными переводить в воркеры, либо упростить спиннер ????


  1. Goodzonchik
    22.01.2022 08:16

    Помню, как решал подобную проблему, была таблица, и в каждой строке было выпадающее меню, которое подписывалось на событие resize и scroll для закрытия (писали на Angular без использования onPush). При 10-20 записях все было хорошо, но при 100 записях все начинало тормозить, любое событие resize или scroll вызывало фриз на 1,7-1,8 секунды. Договорился с аналитиками, что можно заменить все на css, принеся в жертву открытие на клик мыши. Стало работать за 2-3мс. (Сейчас я бы там все переписал по нормальному, но тогда это было тоже крутое ускорение)


  1. black_list_man
    22.01.2022 12:20

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


    1. epodivilov Автор
      22.01.2022 12:22
      +1

      Да, но есть проблема с тем, чтобы засунуть в webgl кривые. По крайней мере у меня не вышло. А как и у решение на canvas проблема с выполнением в основном потоке остаётся.


  1. freedbrt
    22.01.2022 15:10

    А вы не пробовали lottie? Интересно сравнить производительность с ним


    1. epodivilov Автор
      22.01.2022 16:20

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

      И опять же, любая анимация на JS имеет недостаток, что выполняется в том же потоке что и ваш код. Будете блокировать поток - будет тормозить анимация.


  1. LtSerge
    23.01.2022 09:29

    This is the most detailed article on animations I've ever seen good job.


  1. kacetal
    23.01.2022 13:21

    Интересная статья, спасибо.

    Можете посоветовать что почитать новичку о профилировании фронта?


    1. epodivilov Автор
      23.01.2022 14:02

      Могу посоветовать начать с советов от самого Гугла

      К сожалению не видел статьи, где бы разбирались все панели devTools, но по частям можно найти именно в блоге Гугла