|
Professor Seleznov
|
 Киоски самообслуживания давно стали привычной частью повседневной жизни. Мы пользуемся ими в магазинах, на заправках, в кафе и кофейнях. В Додо и Дринкит киоски — один из главных источников заказа. В одной только России они установлены уже в 85% ресторанов, а скоро — будут во всех. То есть сейчас в Додо несколько тысяч киосков самообслуживания! Работают они на разных системах: iOS, Android, Windows. Писать нативный софт и интеграции под каждую платформу и форм-фактор было бы дорого и сложно. Так что мы используем SPA-приложение на React и встраиваем его в хост-приложение на целевой платформе. На Windows это приложение, использующее Electron, на iPadOS — нативное приложение на Swift c WKWebView внутри, а на Android — приложение на Kotlin с Android System WebView. Нативные приложения необходимы для реализации интеграций с чековыми принтерами и платёжными провайдерами. Но основной интерфейс киоска — это фронтенд-приложение, запущенное в WebView внутри нативного приложения. И когда что-то начинает работать медленно, это сразу чувствуют пользователи. Проблема в том, что при тысячах устройств на разных платформах понять, где именно возникают проблемы с производительностью, не так просто. Поэтому мы решили измерять производительность напрямую в React-приложении. С этой задачей столкнулся я — Андрей Боев, техлид команды XXX. В Dodo Engineering киоски развиваются несколькими командами: наша отвечает за frontend-приложение и пользовательский опыт, а другая команда — за платёжную инфраструктуру и интеграции с чековыми принтерами. В этой статье я расскажу, как мы добавили сбор метрики FPS и начали использовать её для системного мониторинга производительности интерфейса, а также почему путь к, казалось бы, очевидной метрике оказался не таким уж очевидным. Погнали! Осознание проблемы Как мы узнавали о проблемах раньше Раньше мы узнавали о проблемах от клиентов, знакомых, партнёров или из рандомных видео в интернете — и такое бывало. Выкатывали мы релиз, а через какое-то время кто-то замечал, что в ресторане всё тормозит, и снимал об этом видео. После этого мы всей командой вспоминали, что было в последних релизах, и гадали, что же могло сломаться. В общем, пробовали как-то исправить ситуацию. Под подозрение попадали релизы, вышедшие за два дня до даты съёмки или ранее. Если проблема была критичная, то мы откатывали релиз сразу, а если нет — анализировали перформанс в хроме. Чтобы всё проверить, я сбрасывал частоты процессора в x20 и смотрел, что занимает больше времени. Чекал, что чаще всего рендерится через рендер-таб. Отслеживал рост кеша сервис-воркеров. Вообще, когда ищешь виноватого, он всегда найдётся, причём неважно, был ли он вообще. В итоге я что-то находил и улучшал. Но иногда попытки улучшить перформанс делали только хуже. Оценив объём работы, вы зададитесь вопросом: «а это вы так каждый лаг в интерфейсе отрабатывали?» Да, каждый. Дело в том, что подобные подвисания интерфейса не просто рушат опыт пользователя — они отпугивают гостей. Возьмём, например, киоски самообслуживания в Дринкит. Это iPad'ы с приложением на Swift с WKWebView внутри. Последний чаще не тормозит, а просто перезагружается при возросшем потреблении ресурсов. Что это значит для гостя? Потеря заказа — корзина просто сбрасывалась. И хорошо, если гость не уйдёт, а просто закажет из приложения. В общем, это не тот бесшовный цифровой опыт, к которому мы стремимся. Очевидно, что с таким подходом долго жить нельзя. Первые попытки: iOS и метрики перезагрузки WebView Что у нас уже было Как известно, управлять можно только тем, что можно измерить. Так что первым делом я привёл в порядок метрики перезагрузки WebView в iOS-приложении. «А такие существуют?» — резонно спросите вы. Ну, смотрите, если WebView на iOS начнёт тормозить — из-за памяти, скажем — система его сама по себе перезапустит. Тут и появляется возможность измерить два параметра:
- Did Launch App — холодный старт приложения. «Впервые» его запускают в двух случаях — в начале дня и после того, как оно крашнулось;
- App was terminated — перезапуск WebView. Он может произойти из-за ошибки, нехватки памяти или по другой причине.
Что мы сделали? Взяв эти два параметра, мы построили графики и добавили алёрты на резкие всплески. Теперь когда на iOS крашилось приложение или перезапускался WebView, первыми узнавали об этом мы, а не гости.

Неудачный релиз, который виден по метрике Возьмём, например, график выше. Он построен на основе параметра App was terminated, который указывает на перезапуск WebView. Сразу можно предположить, что причина в плохом релизе. Смотрим на апдейт, раскатанный в предыдущий день, и ищем причину проблемы. Побочный эксперимент: метрики памяти А ещё мы пробовали отследить лаги в системе через метрики потребления памяти. Идея тут в том, что на iOS нативное приложение имеет доступ к Memory().formattedMemoryFootprint. А что оно делает? Это такое системное API, которое возвращает текущий memory footprint процесса. Мы пробросили его в WebView через message handler:
// В нативном Swift-коде — обработка сообщения от фронтенда case .reportMemoryFootprint: return (Memory().formattedMemoryFootprint(), nil)
На стороне фронтенда хук useIOSMemoryLog каждую секунду дёргал нативный бридж и сохранял значение в localStorage:
const TRACK_MEMORY_MS = 1000; // 1 sec const takeMetricsAndSaveInStorage = async (): Promise<void> => { const memory = await window.webkit?.messageHandlers .reportMemoryFootprint?.postMessage({}); LocalStorage.set(MEMORY_USAGE_KEY, memory || ``); };
collectlosMemory раз в 5 минут забирал значение из localStorage и отправлял в систему логирования:
const LOG_MEMORY_INTERVAL_MS = 1 * 60 * 1000; // 1 min export const MEMORY_USAGE_KEY = `ios_memory_usage`; export const collectIosMemory = (logger: ILoggerInstanceRef): void => { const deviceOs = detectDeviceOsByUserAgent(); if (deviceOs !== `iOS`) { return; } setInterval(() => { const memoryUsage = LocalStorage.get(MEMORY_USAGE_KEY); if (!memoryUsage) { return; } logger.current.info(`iOS Memory footprint`, { memoryUsage, metricLabel: `iosMemory`, }); }, LOG_MEMORY_INTERVAL_MS); };
Всё это подключалось к общему PlatformMetricsProvider. Он собирал все платформенные метрики в одном месте:
export const PlatformMetricsProvider: FC<PropsWithChildren> = ({ children }) => { useAndroidMetricsLog(); useCacheMetricsLog(); useResizeLog(); useSendHeartbeat(); useCollectRendererProcessMetrics(); useIOSMemoryLog(); // <-- вот тут // ... };
А ещё ко всем аналитическим событиям прикладывался memoryUsage. То есть мы видели, сколько памяти потребляло приложение на каждом ивенте. Выглядело всё это очень красиво, но… ничего полезного не показывало. Почему?
- Не было никакой связи с предыдущей метрикой App was terminated.
- Также не было связи и с ивентами. То есть на одном киоске один и тот же ивент на одном и том же планшете показывал разные цифры.
Мы хотели увидеть корреляцию: видим на графике рост потребления памяти, значит киоск завис или приложение крашнулось. Но на практике formattedMemoryFootprint лишь показывал рандомную цифру, которая никак не менялась, даже когда с киоском уже были проблемы. А причина была в том, что утечки памяти происходили внутри WebKit-процесса. iOS его изолирует от основного приложения, а потому нативный API их просто не видел. В итоге, когда WebKit-процесс всё-таки падал по памяти, мы получали webViewWebContentProcessDidTerminate безо всякого предупреждения на графике. Видео из Уфа-8 Однажды мы посмотрели видео, на котором тормозили слайдер с рекламными баннерами (мы зовем их импульсивками) на Windows-киоске. Стало очевидно, что метрики iOS тут не помогут и мы решили, что раз у нас для iOS приложения киоска есть свои метрики, то и для Electron они тоже нужны. Electron и попытки измерить «железо» Метрики памяти и процесса Мы стали собирать метрики потребления памяти в Electron-приложении. Начали с app.getAppMetrics(). Он возвращает массив объектов ProcessMetrix по одному на каждый процесс приложения: main, GPU, рендерные, утилитные.
app.getAppMetrics().forEach(({ cpu, memory, type }) => { logger.current.info(`App metrics hardware snapshot`, { cpu, memory, type, }); });
А что внутри:
- type — тип процесса: Browser, Tab, GPU, Utility и т.д. По сути, виден тип каждого процесса, как в диспетчере задач Chrome.
- cpu — объект CPUUsager с полем percentCPUUsage и idleWakeupsPerSecond. Тут видим процент использования CPU с момента последнего вызова. Первый вызов всегда возвращает 0;
- memory — объект MemoryInfo с workingSetSize и peakWorkingSetSize. Первый — аналог RSS, который показывает объём физической RAM в килобайтах, а второй — отражает максимально потребляемый объём памяти за всё время жизни процесса.
В результате мы получили новый набор метрик… Ни одна из которых не показала корреляции тормозами. Тогда мы перешли к сбору не обзорных метрик по всем процессам, а прицельных из конкретных контекстов:
- process.getSystemMemoryInfo() — общая и свободная системная память (total/free);
- process.memoryUsage() — память main-процесса: rss, heapTotal, heapUsed, external (C++ объекты, привязанные к JS);
- process.getCPUUsage() — CPU текущего процесса;
- process.getHeapStatistics() — подробная статистика V8-кучи из renderer-процесса (через preload bridge): totalHeapSize, usedHeapSize, heapSizeLimit, mallocedMemory, peakMallocedMemory и др;
- systeminformation (si.graphics()) — информация о GPU (видеокарты, дисплеи).
export const startLoggingMemory = (): void => { setInterval(() => { const systemMemoryInfo = process.getSystemMemoryInfo(); const totalSystemMemoryGb = systemMemoryInfo.total / 1024; const freeSystemMemoryGb = systemMemoryInfo.free / 1024; const usedSystemMemoryGb = totalSystemMemoryGb - freeSystemMemoryGb; const processMemoryUsage = process.memoryUsage(); const processResidentSetSizeMb = (processMemoryUsage.rss / 1024 / 1024).toFixed(2); const processHeapTotalMb = (processMemoryUsage.heapTotal / 1024 / 1024).toFixed(2); const processHeapUsedMb = (processMemoryUsage.heapUsed / 1024 / 1024).toFixed(2); const processExternalMemoryUsedMb = (processMemoryUsage.external / 1024 / 1024).toFixed(2); const cpuUsage = process.getCPUUsage(); const percentCpuUsage = cpuUsage.percentCPUUsage; logger.current.info(`Memory hardware snapshot`, { totalSystemMemoryGb, freeSystemMemoryGb, usedSystemMemoryGb, processResidentSetSizeMb, processHeapTotalMb, processHeapUsedMb, processExternalMemoryUsedMb, percentCpuUsage, metricLabel: `kiosk-desktop-memory`, }); }, publishIntervalMs); };
Всё это инициализировалось при старте приложения:
startLoggingMemory(); void getCpuManufacturer(); void collectGraphics(); logSystemUptimeEveryHour();
Давайте покажу для примера графики по метрикам, которые добавил выше. Посмотрим на точку Сестрорецк-1. В целом там потребляются стабильные 3,08 Gb. При этом мы знаем, что киоск тормозит. В пиццерии Уфа-8 (тот киоск, что на видео) такая же ситуация:
 Тут мы тоже смотрим на main-процесс, но никаких изменений не видим:
 Да и график потребления ресурсов CPU был у всех плюс-минус одинаковый:
 Получаем по итогу, что эти метрики не коррелируют с тормозами, значит надо искать дальше. Попытка через процессоры Тогда я решил собрать данные по процессорам и понять, какой из их типов тормозит. Для этого я собирал данные о моделях процессоров и об их текущей нагрузке. Как я собирал данные о процессорах? При запуске приложения через библиотеку systeminformation (si.cpu()) я логировал полный набор характеристик процессора: производитель, бренд, количество ядер, тактовую частоту и т.д. Так я смог узнать, на каком именно железе были фризы.
export const getCpuManufacturer = async (): Promise<void> => { try { const cpu = await si.cpu(); logger.current.info(`CPU Manufacture log`, { ...cpu, metricLabel: `kiosk-desktop-cpu` }); } catch (error) { /* empty */ } };
Дополнительно бренд процессора пробрасывался в renderer-процесс через context bridge, Так фронтенд отправлял его вместе с телеметрией:
export const exposeHardwareBridge = async (): Promise<void> => { try { const cpu = await si.cpu(); contextBridge.exposeInMainWorld(cpuManufactureKey, { cpuBrand: cpu.brand, }); } catch (error) { /* empty */ } };
 В процессе мы узнали, что на киосках используются разные процессоры. Раньше мы думали, что все они работают на Intel Core i3-1110U, но, как выяснилось, в какой-то момент он закончился, и начали ставить другие. У меня появилась гипотеза, что есть проблемные процессоры или даже сборки киосков. Я построил бенчмарки процессоров и сравнил киоски с разными процессорами по разным показателям. Но как я пойму, плохо или хорошо работает киоск? У меня же так и не появились метрики, глядя на которые я могу сделать такие выводы. Гипотеза про температуру (и тупик) Если вы думали, что наши страдания на этом закончились, то нет. Сейчас мы достигнем той самой ситуации, когда, согласно законам драматургии, остаётся только один выход — подниматься наверх. Человек, которого я попросил сходить в пиццерию, сказал: «киоск горячий». «Значит, троттлинг» — предположил я и тут же обратился к подключённой ранее systeminformation. Она как раз может трекать температуру процессора. Но при проверке логов оказалось, что она ничего не возвращает. Причина проста: киоск — это обычный Windows-компьютер, но наше приложение запускается без админских прав. А для чтения температуры через стандартные системные интерфейсы нужны привилегии администратора. То есть библиотека работает корректно, но ограничения прав и среда выполнения не дают получить данные о температуре. Остаются два варианта: ставить физический датчик в корпус или придумывать какие-то хаки. И это всё ещё не та метрика, которая наглядно показывает, тормозит киоск или нет. В итоге эта ветка никуда не привела. Web Vitals — мимо Web Vitals — это набор метрик, который показывает, насколько комфортно пользователю взаимодействовать с веб-приложением:
- LCP (Largest Contentful Paint) — когда на экране появляется основной визуальный контент.
- FID / INP (First Input Delay / Interaction to Next Paint) — насколько быстро интерфейс реагирует на первое действие пользователя. Мы использовали современный INP, который фиксирует задержки взаимодействий и отправляет их в лог через onINP.
- CLS (Cumulative Layout Shift) — насколько стабильно элементы остаются на своих местах и не «скачут» при загрузке.
- TTFB (Time to First Byte) — время до первого байта, которое показывает, как быстро сервер отвечает на запрос, собирается через onTTFB.
export const startCollectWebVitals = (): void => { onINP(data => sample(() => logger.current.info(`onINP`, { ...data, metricLabel: `web-vitals` }), 1), { reportAllChanges: true, }); onTTFB(data => sample(() => logger.current.info(`onTTFB`, { ...data, metricLabel: `web-vitals` }), 1), { reportAllChanges: true, }); onLCP(data => sample(() => logger.current.info(`onLCP`, { ...data, metricLabel: `web-vitals` }), 1), { reportAllChanges: true, }); onCLS(data => sample(() => logger.current.info(`onCLS`, { ...data, metricLabel: `web-vitals` }), 1), { reportAllChanges: true, }); };
Web Vitals на киоске собирался давно и фактически это была первая метрика, которую я смотрел. Но, как вы можете догадаться, она также никак не коррелировала с теми тормозящими киосками. Дело тут в том, что большую часть времени киоск крутит баннер — с ним не взаимодействуют, а значит и метрику не узнать. Саму Web Vitals мы не стали списывать со счетов. Планируем вернуться к ней в будущем, я допускаю что мы что-то делали не так. И здесь стало ясно: нам нужна метрика, которая отражает визуальную плавность, а не пользовательские действия. На обычных сайтах эти показатели отлично отражают опыт пользователя, но на киосках есть несколько ограничений. Во-первых, Web Vitals рассчитаны на браузер и интернет-страницы, а у нас фронтенд-приложение работает внутри WebView на разных платформах. Во-вторых, мы хотим понимать не просто скорость загрузки, а реальную плавность интерфейса во время работы — ведь даже если LCP или FID в норме, пользователь может видеть «тормозящий» интерфейс, например, при скролле меню или просмотре анимаций. Именно поэтому мы решили идти дальше и измерять FPS — количество кадров в секунду —, чтобы иметь наглядную и количественную метрику производительности интерфейса. FPS сразу показывает, когда приложение реально начинает подтормаживать, и позволяет выявлять узкие места на всех платформах. FPS Почему именно FPS FPS — наиболее наглядная и практически полезная метрика для того, чтобы трекать визуальную плавность интерфейса киоска. Он универсальна для всех платформ, реально показывает, тормозит или нет, и никак не зависит от пользовательских действий. Реализация Хук useFps принимает всего один параметр — windowWidth, количество последних замеров, которые он может хранить. Возвращает он массив fps (историю изменения FPS), currentFps (последнее значение) и reset для сброса.
export const useFps = (windowWidth: number): Returns => { const frames = useRef(0); const prevTime = useRef(performance.now()); const animRef = useRef(0); const calcFps = useCallback(() => { const t = performance.now(); frames.current += 1; if (t > prevTime.current + 1000) { const elapsedTime = t - prevTime.current; const currentFps = Math.round((frames.current * 1000) / elapsedTime); // ... } animRef.current = requestAnimationFrame(calcFps); }, [windowWidth]); // ... }; export const useLogFps = (): void => { const { fps, reset } = useFps(1000); const lastUpdateMs = useRef<number>(Date.now()); const { logInfo } = useLogger(__filename); useEffect(() => { if (Date.now() - lastUpdateMs.current > 1000 * 60) { const payload = calculatePerformanceMetrics(fps); logInfo(`FPS log`, { ...payload, }); reset(); lastUpdateMs.current = Date.now(); } }, [fps, logInfo, reset]); }; export const FPSTracker: FC = () => { useLogFps(); return null; };
Как это работает? Через requestAnimationFrame запускается рекурсивный цикл. Каждый вызов calcFPS увеличивает счётчик frames. Как только с предыдущего замера проходит больше 1000 мс, мы считаем FPS по формуле: FPS = frames x 1000 / elapsedTime. Так мы можем точно подсчитать число кадров в секунду, нормализованное к 1000 мс. Да, даже если реальный интервал чуть больше секунды. «Это всё здорово — скажете вы — а зависания-то как обрабатывать»? Рассказываю: если elapsedTime > 1500 мc, значит, между замерами прошло больше 1,5 секунды. Это могло произойти по разным причинам: браузер был заблокирован, вкладка неактивна или тяжёлый main-thread. В таком случае хук дописывает нули за каждую «пропущенную» секунду. Это важно, поскольку без них каждый фриз выглядел бы на графике как нормальный период работы со сниженным FPS, хотя в действительности он и в ноль мог упасть.
// фрагмент, отвечающий за заполнение нулями пропущенных секунд if (elapsedTime > 1500) { for (let i = 1; i <= (elapsedTime - 1000) / 1000; i++) { lastFpsValues.current = lastFpsValues.current.concat(0); } }
Ограничения метрики FPS Важно отметить, что измеряемый нами FPS — это не физический FPS экрана и не количество реально показанных кадров GPU. Мы измеряем частоту вызовов requestAnimationFrame, то есть JS-уровень render loop браузера. Это означает:
- мы видим, как часто браузер пытается отрисовать кадр;
- но не знаем, были ли кадры реально скомпозированы GPU и показаны на дисплее;
- не детектим напрямую dropped frames, GPU stalls и compositing-проблемы.
Тем не менее, в наших сценариях падение FPS стабильно коррелирует с визуально наблюдаемыми фризами, что мы подтвердили на реальных устройствах и видео из ресторанов. Поэтому мы используем FPS не как абсолютную графическую метрику, а как практичную прокси-метрику визуальной плавности интерфейса. Почему именно такой способ, а не альтернативы requestAnimationFrame-счётчик с окном в 1 секунду — это самый простой и надёжный инструмент. Он работает в любом браузере и чётко показывает, сколько кадров отрисовал браузер за последнюю секунду. То есть когда FPS падает, пользователь видит фризы, а мы — одно число, которое понятно даже без контекста. В это же время для Web Vitals нужно было бы смотреть на 4 разные цифры, что менее очевидно. Запускаем метрику и видим понятную цифру — например, на графике ниже видно, что сначала было 20 FPS, а после перезапуска приложения — 60:
 Это был первый раз, когда проблема чётко проявилась в метриках. Наверное, вы хотите спросить: «Разве эта дополнительная работа по подсчёту FPS не ухудшит перформанс?» Нет — и вот почему:
- requestAnimationFrame вместо setInterval — коллбэк вызывается, только когда браузер готов отрисовать следующий кадр. Он не создаёт дополнительной нагрузки поверх обычного render loop, а «паразитирует» на уже существующем цикле отрисовки.
- Минимальная работа на каждый кадр — пока секунда не прошла, выполняются только performance.now(), инкремент счётчика и одно сравнение. Это наносекунды.
- Обновление стейта происходит раз в ~1 секунду, а не на каждый кадр. При этом компонент FPSTracker рендерит null — у него нет DOM-дерева, а значит React при ре-рендере просто сравнивает null с null. Reconciliation пустого компонента раз в секунду — это пренебрежимо мало работы. Для сравнения: любая CSS-анимация на странице заставляет браузер делать layout/paint 60 раз в секунду — это на два порядка дороже.
- Нет тяжёлых вычислений — одно деление, один Math.round, один .slice().
- Массив ограничен скользящим окном — старые значения отбрасываются через .slice(), поэтому память не растёт со временем.
Что было дальше? Но на этом наши расчёты не заканчиваются. Теперь нам нужно посчитать процент киосков с медианным FPS ниже 30 от общего числа киосков, приславших FPS-логи за последние 8 часов. Всего таких киосков получилось 10%. Теперь я вешаю алёрт на повышение этого числа и дальше уже буду работать над уменьшением этого процента. Чтобы снизить цифру в 10% процентов тормозящих киосков я:
- добавил метрику аптайма, узнал что киоски работают без перезагрузки по полгода;
- посмотрел корреляцию с FPS и увидел её(!);
- добавил автоматическую перезагрузку системы раз в 24 часа. Это снизило процент тормозящих киосков с 10% до 0.1%. Да, релоад не решает проблему, которая приводит к тому, что киоск тормозит, а лишь лечит симптом, но от этого точно стало лучше.
Заключение История с FPS показала простую, но болезненную вещь: мы долго пытались объяснить пользовательский опыт через косвенные метрики — память, CPU, Web Vitals — но ни одна из них не отвечала на главный вопрос: видит ли пользователь тормоза? Мы потратили время на анализ «железа», гипотезы про троттлинг и сложные системные метрики, но это был тупиковый путь. Эти данные могут быть полезны для диагностики, но они не дают управляемой картины пользовательского опыта. Настоящий сдвиг произошёл в тот момент, когда мы выбрали метрику, которая напрямую отражает визуальную плавность интерфейса — FPS. Она:
- работает одинаково на всех платформах;
- не зависит от пользовательских действий;
- сразу показывает проблему без интерпретаций.
С появлением FPS мы перешли от реактивного подхода («нам прислали видео — идём разбираться») к проактивному:
- начали видеть деградации сразу после релиза;
- смогли ввести алёрты;
- получили численную оценку масштаба проблемы (например, % киосков с низким FPS).
Это позволило не просто находить баги, а управлять производительностью как процессом. Ещё важно упомянуть, что не все решения обязаны быть идеальными. Автоматическая перезагрузка киосков раз в 24 часа не устраняет корневую причину, но резко снижает влияние проблемы на пользователей. В продакшене это часто важнее, чем «идеальная архитектурная чистота». Главный вывод: если метрика не коррелирует с реальным пользовательским опытом — она бесполезна, даже если выглядит технически правильной. FPS стал для нас именно той метрикой, которая связала техническое состояние приложения с тем, что на самом деле видит гость у экрана киоска. А значит — дал возможность системно улучшать этот опыт. Расскажите в комментах, приходилось ли вам замерять производительность сотен или даже тысяч устройств одновременно? Каким инструментом для этого пользовались вы? Ну и плюсик в карму не забудьте накинуть. А на этом всё. Спасибо, что дочитали статью! Чтобы оставаться в курсе последних новостей нашей команды, подпишитесь на Telegram-канал Dodo Engineering. В нём много клёвого контента о нашей команде, продуктах и культуре.-Источник
|