Приветствую, уважаемые читатели Хабра. Эта статья про фронтенд, JavaScript и FPS. И сегодня мне хотелось бы поделиться своими мыслями о «слепом» коде, который фактически никак не учитывает производительности среды исполнения. Ну и, конечно, напишем очередной велосипедокостыль — куда без них.
Предисловие
Была недавно статья на Geektimes — «Mozilla выпустила статистику «железа» на клиентских ПК». И прекрасный комментарий от schetilin выражающий основную проблему —
Вот бы эту статистику посмотрели наши сайтописатели. А то сейчас создают сайты, для которых i7 32Gb маловато будет.
И в догонку была еще статья «Правда о традиционных JavaScript-бенчмарках». Суть которой в том, что попугаи хороши для сравнения попугаев, но не для реального применения.
Бла бла бла...
Представим ситуацию — сидит супер разработчик Вася и пишет свой крутой код, запускает его на своем не менее крутом «маке». И… сайт прекрасно работает, анимация красивая, скролл быстрый и плавный.
Но вот пользователь Петя сел за свой ноутбук и решил зайти на сайт интернет магазина для которого, кстати, пишет код Вася. И… понеслось — анимируемые галереи, баннеры, всплывающие окна и еще где-то в недрах запускается парсинг каких-то данных с сервера. Все это периодически сопровождается значком ожидания. Ноутбук Пети в шоковом состоянии, а сам Петя начинает заочно проклинать Васю. Давайте разбираться кто «крайний».
«Петя — тормоз непродвинутый пользователь, пусть обновляет железо» — скажите вы. Но пользователь не обязан подстраиваться по каждый сайт, это дело сайта предоставить качественный интерфейс.
«Вася — рукожоп нехороший разработчик!» — скажите вы. И зря, Вася изо всех сил старается оптимизировать свой код, но он не может его протестировать на всех платформах под все конфигурации железа. И тут напрашивается вопрос к разработчикам браузеров, почему бы ни предоставить web-разработчику хоть какую-нибудь информацию о производительности системы на которой исполняется JS код. Ну правда, что например дает знание какой браузер использует пользователь? Это дает разработчику знание какой синтаксис и API он может использовать. И как это поможет, например, в выборе таймаута при разбиении ресурсоемкой задачи на части. Для каждой версии браузера/движка, для каждой операционной системы и конфигурации железа оптимальный таймаут будет разный.
Представим если бы у web-разработчика, был хотя бы примерный индекс производительности, который бы учитывал тип ОС и конфигурацию железа. Тогда бы при индексе 2 из 10, можно было б вообще отключить все анимации и наоборот при 10 использовать по полной. Ну и неплохо было бы иметь статистические таблицы со временем исполнения типичных задач (например, вставка 100 строк в таблицу) под каждый индекс. Но, на сколько я знаю, ничего подобного нет.
Счетчики
Что ж, прямых данных о производительности системы на которой исполняется наш код, у нас нет. Но есть FPS ( Frames Per Second ) — отличный косвенный показатель. Высокий FPS — это то что нужно пользователю для комфортной навигации, и это то, что может измерить разработчик. А если мы можем измерить, то можем и использовать эту метрику в каких-то условиях. Прекрасно, идем дальше…
Cправка:
FPS — это, количество кадров в секунду на экране монитора. Для браузеров это количество раз в секунду, когда браузер обновляет интрефейс.
Вообще процесс отрисовки в браузере не так прост. Браузер старается не перерисовать всю страницу, разбивает страницу на слои и еще кучу всего, черт знает чего. Лезть в дебри этого процесса, сейчас нет никакого смыла, да и алгоримт отрисовки может меняться от версии к версии. В принципе достаточно запомнить, одно правило — если часто обращаешься к DOM элементу, то нужно вытащить его из основной структуры т.е. позиционировать его фиксировано или абсолютно.
Итак, нам нужно измерить FPS. Известно, что браузеры стараются обновлять интерфейс порядка 60 раз в секунду ( т.е. идеальный FPS равен 60). И есть метод requestAnimationFrame(), который позволяет запланировать исполнение нашего кода перед следующей перерисовкой интерфейса. Этот метод настоятельно рекомендуется использовать для всех анимаций, вместо setTimeout/setInterval которые могут вызывать принудительны перерисовки, что увеличивает нагрузку на систему.
Пишем счетчик FPS:
let frameCount = function _fc(timeStart){
let now = performance.now();
let duration = now - timeStart;
if(duration < 1000){
_fc.counter++;
} else {
_fc.fps = _fc.counter;
_fc.counter = 0;
timeStart = now;
console.log(_fc.fps);
}
requestAnimationFrame(() => frameCount(timeStart));
}
frameCount.counter = 0;
frameCount.fps = 0;
frameCount(performance.now())
Счетчик отлично считает, в этом можно убедиться в консоли. Но он оперирует секундами, а код исполняется гораздо быстрее — единицы и доли миллисекунд. Попробуем максимально сократить период подсчета, например, до 100 ms. Конечно это неизбежно уменьшит точность, зато прирост скорости будет аж в 10 раз.
Быстрый счетчик FPS:
let frameCount = function _fc(timeStart){
let now = performance.now();
let duration = now - timeStart;
if(duration < 100){
_fc.counter++;
} else {
_fc.fps = _fc.counter * 10;
_fc.counter = 0;
timeStart = now;
console.log(_fc.fps);
}
requestAnimationFrame(() => frameCount(timeStart));
}
frameCount.counter = 0;
frameCount.fps = 0;
frameCount(performance.now())
И тут меня осенило — почему бы ни использовать оба счетчика. Получится некая система из счетчиков — одного быстрого и одного точного. Посмотрим…
let frameCount = function _fc(fastTimeStart, preciseTimeStart){
let now = performance.now();
let fastDuration = now - (fastTimeStart || _fc.startTime);
let preciseDuration = now - (preciseTimeStart || _fc.startTime);
if(fastDuration < 100){
_fc.fastCounter++;
} else {
_fc.fastFPS = _fc.fastCounter * 10;
_fc.fastCounter = 0;
fastTimeStart = now;
console.log(_fc.fastFPS);
}
if(preciseDuration < 1000){
_fc.preciseCounter++;
} else {
_fc.preciseFPS = _fc.preciseCounter;
_fc.preciseCounter = 0;
preciseTimeStart = now;
console.log(_fc.preciseFPS);
}
requestAnimationFrame(() => frameCount(fastTimeStart, preciseTimeStart));
}
frameCount.fastCounter = 0;
frameCount.fastFPS = 0;
frameCount.preciseCounter = 0;
frameCount.preciseFPS = 0;
frameCount.startTime = performance.now();
frameCount()
Теперь мы можем хоть как-то оценить текущее состояние среды исполнения. Но, все равно, меня не покидало чувство, того что еще не все сделано. И я опять решил поэксперементировать…
На рисунке видно, что мы тратим один такт (фрейм) на обнуление счетчика, а реальный подсчет начинается со следующего такта. Сократим время подсчета, при этом не будем обнулять счетчик. Это также даст небольший выйгрыш в скорости подсчета FPS. Новое значение периода подсчета определяется как (100/6)*5 = 83 для быстрого счетчика и как (1000/60)*59 = 983 для точного.
Таймаут
Метрику, на которую мы будем ориентироваться, добыли. Теперь нужен механизм управления временем исполнения кода. А раз так нам нужны такие параметры как timeout и delta- величина изменения таймаута. Но, сразу возникает вопрос, как определить оптимальную величину этих параметров? Ответ прост — сходу, никак. Нужны данные, нужна статистика. Хотя сам механизм управления таймаутом вполне можно определить. Он будет предельно прост:
timeoutCorrection(){
if(fastFPS <= 2 && timeout < 1000){
timeout += delta;
} else if(fastFPS > 2 && timeout > 16) {
timeout -= delta;
}
}
Из кода видно, что ориентир идет на величину в 20 FPS ( fastFPS *10 ), а точнее чуть большую — "<= ". Значения 16 и 1000 это лимиты. Минимальный таймаут 16, соотвествует 60 FPS. А максимальный выбран величиной в 1000 соответствующий 1 FPS.
Предполагается, что функция корректировки таймаута будет вызываться каждый раз при установке нового значения быстрого счетчика FPS.
Исполнитель
FPS измеряем, таймаутом управляем. Теперь нужен исполнитель — функция, которая на основе этих данных, будет управлять исполнением кода, а точнее просто тормозить вызовы, когда необходимо. Пишем…
let requestAdaptiveAnimation = function _raa(cb, priority, timeout, ...args){
if( !_raa.cbsStore.has(cb.toString) || timeout){
_raa.cbsStore.add(cb);
_raa.queue = _raa.queue.then(()=>{
return new Promise((res)=>{
setTimeout(()=>{
requestAnimationFrame(()=>{
cb(...args);
res();
});
}, timeout || 0);
});
});
return;
}
if(frameCount.fastFPS >= 4 || priority){
requestAnimationFrame(()=>cb(...args));
return;
}
if( frameCount.preciseFPS < 15){
_raa.queue = _raa.queue.then(()=>{
return new Promise((res)=>{
requestAnimationFrame(()=>{
cb(...args);
res();
});
});
});
return;
}
setTimeout(()=>{
requestAnimationFrame(()=>cb(...args));
}, _raa.timeout);
}
requestAdaptiveAnimation.cbsStore = new Set();
requestAdaptiveAnimation.queue = Promise.resolve();
Эта функция намеренно названа схоже с requestAnimationFramе, так как именно этот метод лежит в её основе. Т.е. любые обновления интерфейса мы делаем через requestAnimationFramе. Но так как с помощью этого метода нельзя задать таймаут, делаем обертки с помощью setTimeout.
На основе цепочки промисов организована асинхронная очередь, которая во первых позволяет задать таймаут, а во вторых предотвращает «лавинное» исполнение кода.
Aлгоритм requestAdaptiveAnimation в следующем:
- Если переданный коллбэк используется впервые или задан таймаут, то исполнение колбэка ставиться в общую очередь.
- Если это анимация и один и тот же коллбэк вызывается несколько раз, тогда при высоком значении быстрого счетчика FPS производиться прямой вызов requestAnimationFramе.
- Если значение точного счетчика FPS упало ниже критического порога в 15, то колбэк ставится в очередь. Визуально это выглядит как временная заморозка анимации. Число 15 выбрано субъективно, на мой взгяд при этом значение скролл страницы еще можно назвать приемлемым.
- По умолчанию, вызываем requestAnimationFramе, через откорректированный таймаут.
Тесты
Настало время собрать все вместе и затестить. И кроме того осталась еще нерешенная задача с таймаутом и дельтой. Для тестирования я написал небольшой стенд , и стал смотреть различные соотношения таймаута и дельты.
Изначально я решил закоррелировать таймаут и дельту с количеством анимируемых объетов. И соображения у меня были такие — чем больше элементов, тем больше стартовое значение таймаута, а дельта соответсвенно меньше, иначе будут сильные колебания таймаута и анимации.
Таким образом расчет сводился к:
timeout = numberObjects;
delta = 1/numberObjects;
Но это работало плохо, и я решил ввети поправочный коэффициент — ratio. Расчет свелся к:
timeout = numberObjects/ratio;
delta = ratio/numberObjects;
Я стал менять этот коэффициент и смотреть графики.
100 объектов:
500 объектов:
1000 объектов:
2000 объектов:
Этот график интересен тем, что на нем видно включение «спасительного» механизма очереди при FPS ниже 15 кадров.
Сравнительная табличка:
По табличке отбираем лучшее варианты и продолжаем тестировать.
Тут надо немного пояснить. Хотя мы указываем анимировать 1000 элементов, на самом деле из-за очереди первоначальных вызовов, одновременно у меня анимировались не более 300. Это из-за того, что при начале анимации, например, 300-го элемента, анимация первого уже заканчивалась.
Вот как это выглядит (кусок примерно 300 элементов):
1000 объектов, короткие анимации ( сравнение лучших из таблицы):
Теперь увеличим, длину анимации (я просто сделал максимальное масштабирование страницы) и снова затестим.
Как теперь это выглядит (все элементы — 1000):
Теперь видно, что анимируются все элементы одновременно.
1000 объектов, длинные анимации ( сравнение лучших из таблицы):
По графику видно, что «спасительный» механизм очереди не включается только при timeout ( elements/ratio ) и delta ( ratio /elements) равных 1 — розовая линия на графике.
Итак, с таймаутом и дельтой определились. Теперь самое время сравнить с нативными методами — setInterval, setTimeout, requestAnimationFrame:
Из графика видно, что при малом количестве элементов имеем минимальный оверхед. При включении механизма адаптации, длительность исполнения кода конечно увеличивается, зато FPS сохраняется на уровне 30. Нативные же методы просаживают FPS до 7-8.
Казалось, бы вот и всё. Но нет, все тесты я запускал на ноутбуке, а изначальная цель всей этой писанины — работа на разных системах, с разными конфигурациями. Поэтому проверяем все тоже самое, но на десктопе.
Сравнение с нативными методами на коротких анимациях:
Сравнение с нативными методами на длинных анимациях:
Видно, что и на ноутбуке (CPU: A8, RAM: 8Gb) и на десктопе (CPU: i5, RAM: 8Gb) работа механизм аналогична — FPS сохраняется на уровне 30. Разница лишь в том, на сколько происходит растягивание исполнения кода во времени.
Итоги
Хорошо:
- Механизм работает, FPS сохряняется
- Сохранение уровня FPS независимо от браузера (Firefox, Chrome)
- Сохранение уровня FPS независимо от производительности системы
Плохо:
- Независимость между собой разных анимаций. Из-за этого анимации запущенные позже при определенных условиях, могут завершаться раньше
«Ну вот теперь, то всё» — подумал я. Но то странное чувство… и я решил опять…
P.S.
Всем, хорошего настроения. Комментарии и критика приветствуются. Единственная просьба, если ставите 'минус', то черкните пару строк, за что именно.
Автор: IPri