惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

P
Privacy & Cybersecurity Law Blog
SecWiki News
SecWiki News
T
Troy Hunt's Blog
Y
Y Combinator Blog
V
V2EX
美团技术团队
Last Week in AI
Last Week in AI
S
Security @ Cisco Blogs
IT之家
IT之家
博客园_首页
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
阮一峰的网络日志
阮一峰的网络日志
AI
AI
罗磊的独立博客
人人都是产品经理
人人都是产品经理
H
Hacker News: Front Page
N
News and Events Feed by Topic
P
Privacy International News Feed
V2EX - 技术
V2EX - 技术
Recent Commits to openclaw:main
Recent Commits to openclaw:main
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
GbyAI
GbyAI
L
LINUX DO - 热门话题
C
Cybersecurity and Infrastructure Security Agency CISA
Microsoft Azure Blog
Microsoft Azure Blog
Martin Fowler
Martin Fowler
月光博客
月光博客
WordPress大学
WordPress大学
Latest news
Latest news
Google DeepMind News
Google DeepMind News
S
Schneier on Security
N
Netflix TechBlog - Medium
腾讯CDC
T
Tailwind CSS Blog
TaoSecurity Blog
TaoSecurity Blog
S
Secure Thoughts
L
LINUX DO - 最新话题
Project Zero
Project Zero
Cyberwarzone
Cyberwarzone
D
DataBreaches.Net
Webroot Blog
Webroot Blog
B
Blog
www.infosecurity-magazine.com
www.infosecurity-magazine.com
S
SegmentFault 最新的问题
The GitHub Blog
The GitHub Blog
H
Help Net Security
L
LangChain Blog
A
Arctic Wolf
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Как мы искали тормоза в киосках — и пришли к FPS
Андрей Боев · 2026-05-12 · via Все публикации подряд на Хабре

Средний

14 мин

8K

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

В Додо и Дринкит киоски — один из главных источников заказа. В одной только России они установлены уже в 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. То есть мы видели, сколько памяти потребляло приложение на каждом ивенте.

Выглядело всё это очень красиво, но… ничего полезного не показывало. Почему?

  1. Не было никакой связи с предыдущей метрикой App was terminated.

  2. Также не было связи и с ивентами. То есть на одном киоске один и тот же ивент на одном и том же планшете показывал разные цифры.

Мы хотели увидеть корреляцию: видим на графике рост потребления памяти, значит киоск завис или приложение крашнулось. Но на практике 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 (тот киоск, что на видео) такая же ситуация:

total_used_memory_1.png

Тут мы тоже смотрим на main-процесс, но никаких изменений не видим:

main_process_1.png

Да и график потребления ресурсов CPU был у всех плюс-минус одинаковый:

cpu_percent.png

Получаем по итогу, что эти метрики не коррелируют с тормозами, значит надо искать дальше.

Попытка через процессоры

Тогда я решил собрать данные по процессорам и понять, какой из их типов тормозит. Для этого я собирал данные о моделях процессоров и об их текущей нагрузке.

Как я собирал данные о процессорах? При запуске приложения через библиотеку 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 */
    }
};
cpu_list.png

В процессе мы узнали, что на киосках используются разные процессоры. Раньше мы думали, что все они работают на 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_1.png

Это был первый раз, когда проблема чётко проявилась в метриках. Наверное, вы хотите спросить: «Разве эта дополнительная работа по подсчёту FPS не ухудшит перформанс?» Нет — и вот почему:

  1. requestAnimationFrame вместо setInterval — коллбэк вызывается, только когда браузер готов отрисовать следующий кадр. Он не создаёт дополнительной нагрузки поверх обычного render loop, а «паразитирует» на уже существующем цикле отрисовки.

  2. Минимальная работа на каждый кадр — пока секунда не прошла, выполняются только performance.now(), инкремент счётчика и одно сравнение. Это наносекунды.

  3. Обновление стейта происходит раз в ~1 секунду, а не на каждый кадр. При этом компонент FPSTracker рендерит null — у него нет DOM-дерева, а значит React при ре-рендере просто сравнивает null с null. Reconciliation пустого компонента раз в секунду — это пренебрежимо мало работы. Для сравнения: любая CSS-анимация на странице заставляет браузер делать layout/paint 60 раз в секунду — это на два порядка дороже.

  4. Нет тяжёлых вычислений — одно деление, один Math.round, один .slice().

  5. Массив ограничен скользящим окном — старые значения отбрасываются через .slice(), поэтому память не растёт со временем.

Что было дальше?

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

Всего таких киосков получилось 10%. Теперь я вешаю алёрт на повышение этого числа и дальше уже буду работать над уменьшением этого процента.

Чтобы снизить цифру в 10% процентов тормозящих киосков я:

  • добавил метрику аптайма, узнал что киоски работают без перезагрузки по полгода;

  • посмотрел корреляцию с FPS и увидел её(!);

  • добавил автоматическую перезагрузку системы раз в 24 часа. Это снизило процент тормозящих киосков с 10% до 0.1%. Да, релоад не решает проблему, которая приводит к тому, что киоск тормозит, а лишь лечит симптом, но от этого точно стало лучше.

Заключение

История с FPS показала простую, но болезненную вещь: мы долго пытались объяснить пользовательский опыт через косвенные метрики — память, CPU, Web Vitals — но ни одна из них не отвечала на главный вопрос: видит ли пользователь тормоза?

Мы потратили время на анализ «железа», гипотезы про троттлинг и сложные системные метрики, но это был тупиковый путь. Эти данные могут быть полезны для диагностики, но они не дают управляемой картины пользовательского опыта.

Настоящий сдвиг произошёл в тот момент, когда мы выбрали метрику, которая напрямую отражает визуальную плавность интерфейса — FPS. Она:

  • работает одинаково на всех платформах;

  • не зависит от пользовательских действий;

  • сразу показывает проблему без интерпретаций.

С появлением FPS мы перешли от реактивного подхода («нам прислали видео — идём разбираться») к проактивному:

  • начали видеть деградации сразу после релиза;

  • смогли ввести алёрты;

  • получили численную оценку масштаба проблемы (например, % киосков с низким FPS).

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

Ещё важно упомянуть, что не все решения обязаны быть идеальными. Автоматическая перезагрузка киосков раз в 24 часа не устраняет корневую причину, но резко снижает влияние проблемы на пользователей. В продакшене это часто важнее, чем «идеальная архитектурная чистота».

Главный вывод: если метрика не коррелирует с реальным пользовательским опытом — она бесполезна, даже если выглядит технически правильной. FPS стал для нас именно той метрикой, которая связала техническое состояние приложения с тем, что на самом деле видит гость у экрана киоска. А значит — дал возможность системно улучшать этот опыт.

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

А на этом всё. Спасибо, что дочитали статью! Чтобы оставаться в курсе последних новостей нашей команды, подпишитесь на Telegram-канал Dodo Engineering. В нём много клёвого контента о нашей команде, продуктах и культуре.