Устранение задержек для повышения производительности рендеринга

Tom Wiltzius
Tom Wiltzius

Введение

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

Это первая из серии статей, посвященных оптимизации производительности рендеринга в браузере. Для начала мы расскажем, почему плавную анимацию сложно реализовать и что нужно сделать, чтобы добиться этого, а также приведем несколько простых рекомендаций. Многие из этих идей были впервые представлены в докладе Jank Busters, который мы с Натом Дукой (Nat Duca) подготовили для конференции Google I/O в этом году (видео).

Представляем вертикальную синхронизацию

Этот термин может быть знаком геймерам, играющим на ПК, но в интернете он встречается редко. Что такое вертикальная синхронизация?

Рассмотрим экран телефона: он обновляется через регулярные интервалы, обычно (но не всегда!) около 60 раз в секунду. Вертикальная синхронизация – это создание новых кадров только между обновлениями экрана. Это можно сравнить с состоянием гонки между процессом, который записывает данные в буфер экрана, и операционной системой, которая считывает эти данные, чтобы вывести их на дисплей. Содержимое буфера должно меняться между обновлениями, а не во время них, иначе на экране будет отображаться половина одного кадра и половина другого, что приведет к разрыву изображения.

Чтобы анимация была плавной, новый кадр должен быть готов к каждому обновлению экрана. Это имеет два важных последствия: время формирования кадра (то есть, когда кадр должен быть готов) и бюджет кадра (то есть, сколько времени у браузера есть на создание кадра). У вас есть время только между обновлениями экрана, чтобы завершить кадр (около 16 мс на экране с частотой 60 Гц), и вы хотите начать создавать следующий кадр, как только последний будет выведен на экран.

Время решает все: requestAnimationFrame

Многие веб-разработчики используют setInterval или setTimeout каждые 16 миллисекунд для создания анимации. Это может привести к различным проблемам (о которых мы поговорим чуть позже), но в первую очередь:

  • Разрешение таймера в JavaScript составляет всего несколько миллисекунд.
  • Частота обновления зависит от устройства.

Вспомните проблему со временем формирования кадра, упомянутую выше: вам нужен завершенный кадр анимации, завершенный с любым JavaScript, манипуляциями с DOM, макетом, отрисовкой и т. д., чтобы быть готовым до следующего обновления экрана. Низкое разрешение таймера может затруднить завершение кадров анимации до следующего обновления экрана, но изменение частоты обновления экрана делает это невозможным при фиксированном таймере. Независимо от интервала таймера, вы будете медленно выходить за пределы временного окна для кадра и в конечном итоге потеряете один. Это произойдет, даже если таймер сработает с точностью до миллисекунды, чего не случится (как выяснили разработчики). Разрешение таймера зависит от того, работает ли компьютер от батареи или подключен к сети, на него могут влиять фоновые вкладки, потребляющие ресурсы, и т. д. Даже если это происходит редко (скажем, каждые 16 кадров, потому что вы ошиблись на миллисекунду), вы заметите, что теряете несколько кадров в секунду. Кроме того, вы будете тратить ресурсы на создание кадров, которые никогда не будут показаны, что приведет к потере энергии и процессорного времени, которые можно было бы потратить на другие задачи в приложении.

У разных экранов разная частота обновления: 60 Гц – это обычное значение, но у некоторых телефонов она составляет 59 Гц, у некоторых ноутбуков падает до 50 Гц в режиме экономии энергии, а у некоторых настольных мониторов – 70 Гц.

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

Чтобы кадры анимации воспроизводились в нужный момент, используйте requestAnimationFrame. При использовании этого API вы запрашиваете у браузера кадр анимации. Обратный вызов вызывается, когда браузер собирается создать новый фрейм. Это происходит независимо от частоты обновления.

У requestAnimationFrame есть и другие преимущества:

  • Анимация на фоновых вкладках приостанавливается, что позволяет экономить системные ресурсы и заряд батареи.
  • Если система не справляется с отрисовкой на частоте обновления экрана, она может замедлить анимацию и реже вызывать обратный вызов (например, 30 раз в секунду на экране с частотой 60 Гц). Хотя это снижает частоту кадров вдвое, анимация остается стабильной, а, как говорилось выше, наши глаза гораздо более чувствительны к изменениям, чем к частоте кадров. Стабильная частота 30 Гц выглядит лучше, чем 60 Гц с пропуском нескольких кадров в секунду.

requestAnimationFrame уже обсуждался во многих источниках, поэтому за дополнительной информацией обратитесь к статьям, например этой статье из Creative JS. Это важный первый шаг к плавной анимации.

Бюджет кадра

Поскольку мы хотим, чтобы новый кадр был готов при каждом обновлении экрана, у нас есть только время между обновлениями, чтобы выполнить всю работу по созданию нового кадра. На дисплее с частотой 60 Гц это означает, что у нас есть около 16 мс на выполнение всего кода JavaScript, макета, отрисовки и всего остального, что должен сделать браузер, чтобы вывести кадр. Это означает, что если код JavaScript в обратном вызове requestAnimationFrame выполняется дольше 16 мс, то кадр не будет создан вовремя для вертикальной синхронизации.

16 мс – это очень мало. К счастью, инструменты разработчика Chrome могут помочь вам отследить, не превышаете ли вы бюджет кадра во время обратного вызова requestAnimationFrame.

Открыв временную шкалу DevTools и записав эту анимацию в действии, мы быстро увидим, что при анимации мы значительно превышаем бюджет. Переключитесь на вкладку Frames (Кадры) на временной шкале и посмотрите на результат.

Демонстрация с большим количеством макетов
Пример макета с избыточным количеством элементов

Обратные вызовы requestAnimationFrame (rAF) занимают более 200 мс. Это на порядок больше, чем нужно для отрисовки кадра каждые 16 мс. Открыв один из этих длинных обратных вызовов rAF, вы увидите, что происходит внутри: в данном случае много макета.

В видео Пола подробно рассказывается о конкретной причине изменения макета (чтение scrollTop) и о том, как этого избежать. Но главное, что вы можете изучить обратный вызов и выяснить, почему он занимает так много времени.

Обновленная демоверсия с сильно уменьшенным макетом
Обновленная демоверсия с уменьшенным макетом

Обратите внимание на время формирования кадра – 16 мс. Пустое пространство в кадрах – это запас, который можно использовать для выполнения дополнительных задач (или позволить браузеру выполнять фоновые задачи). Это пустое пространство – хорошая вещь.

Другой источник задержек

Самая большая проблема при попытке запустить анимацию на основе JavaScript заключается в том, что другие процессы могут помешать обратному вызову rAF и даже полностью предотвратить его запуск. Даже если обратный вызов rAF является компактным и выполняется всего за несколько миллисекунд, другие действия (например, обработка только что поступившего XHR, запуск обработчиков событий ввода или запуск запланированных обновлений по таймеру) могут внезапно появиться и выполняться в течение любого периода времени без уступки. На мобильных устройствах обработка этих событий может занимать сотни миллисекунд, в течение которых анимация будет полностью остановлена. Такие сбои анимации называются помехами.

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

  • Не выполняйте много операций в обработчиках ввода. Выполнение большого количества JS или попытка перестроить всю страницу, например, во время обработчика onscroll, является очень распространенной причиной ужасной задержки.
  • Перенесите как можно больше процессов (то есть все, что занимает много времени) в обратный вызов rAF или веб-воркеры.
  • Если вы добавляете работу в обратный вызов rAF, попробуйте разбить ее на части, чтобы обрабатывать только небольшую часть каждого кадра, или отложите ее до завершения важной анимации – таким образом вы сможете продолжать выполнять короткие обратные вызовы rAF и плавно анимировать.

Подробные инструкции о том, как перенести обработку в обратные вызовы requestAnimationFrame, а не в обработчики ввода, можно найти в статье Пола Льюиса (Paul Lewis) Leaner, Meaner, Faster Animations with requestAnimationFrame (Более простые, эффективные и быстрые анимации с помощью requestAnimationFrame).

CSS-анимация

Что может быть лучше, чем легковесный JS в обратных вызовах событий и rAF? Без JS.

Ранее мы говорили, что нет универсального способа избежать прерывания обратных вызовов rAF, но вы можете использовать анимацию CSS, чтобы полностью избежать необходимости в них. В частности, в Chrome для Android (и других браузерах, где разрабатываются подобные функции) анимация CSS обладает очень полезным свойством: браузер часто может воспроизводить ее, даже если выполняется код JavaScript.

В предыдущем разделе о задержках есть неявное утверждение: браузеры могут делать только одно дело за раз. Это не совсем так, но это хорошее рабочее предположение: в любой момент времени браузер может запускать JS, выполнять макет или рисовать, но только по одному за раз. Это можно проверить в представлении "Временная шкала" в Инструментах разработчика. Одним из исключений из этого правила являются CSS-анимации в Chrome для Android (и скоро в Chrome для компьютеров).

По возможности используйте анимацию CSS. Это упростит ваше приложение и обеспечит плавное воспроизведение анимации даже при выполнении кода JavaScript.

  // see http://paulirish.com/2011/requestanimationframe-for-smart-animating/ for info on rAF polyfills
  rAF = window.requestAnimationFrame;

  var degrees = 0;
  function update(timestamp) {
    document.querySelector('#foo').style.webkitTransform = "rotate(" + degrees + "deg)";
    console.log('updated to degrees ' + degrees);
    degrees = degrees + 1;
    rAF(update);
  }
  rAF(update);

Если вы нажмете кнопку, JavaScript будет выполняться в течение 180 мс, что приведет к задержке. Но если мы будем управлять этой анимацией с помощью CSS, задержек больше не будет.

(Помните, что на момент написания этой статьи анимация CSS работает без задержек только в Chrome для Android, но не в Chrome для компьютеров.)

  /* tools like Modernizr (http://modernizr.com/) can help with CSS polyfills */
  #foo {
    +animation-duration: 3s;
    +animation-timing-function: linear;
    +animation-animation-iteration-count: infinite;
    +animation-animation-name: rotate;
  }

  @+keyframes: rotate; {
    from {
      +transform: rotate(0deg);
    }
    to {
      +transform: rotate(360deg);
    }
  }

Дополнительную информацию об использовании CSS-анимации можно найти в статьях, подобных этой статье на MDN.

Завершение

Если коротко, то:

  1. При анимации важно создавать кадры для каждого обновления экрана. Анимация с вертикальной синхронизацией значительно улучшает восприятие приложения.
  2. Лучший способ получить анимацию с вертикальной синхронизацией в Chrome и других современных браузерах – использовать CSS-анимацию. Если вам требуется больше гибкости, чем может обеспечить анимация CSS, лучше всего использовать анимацию на основе requestAnimationFrame.
  3. Чтобы анимация rAF работала правильно, убедитесь, что другие обработчики событий не мешают выполнению обратного вызова rAF, и делайте обратные вызовы rAF короткими (<15 мс).

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

Желаем успехов!

Ссылки