Минимизация перерисовок

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

Избыточные перерисовки приводят к следующим последствиям:

  • увеличению нагрузки на CPU и GPU;
  • снижению частоты кадров (FPS);
  • повышенному энергопотреблению;
  • задержкам при взаимодействии с картой;
  • ухудшению производительности на мобильных устройствах.

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


Как работает цикл рендеринга в Mapbox GL JS

Mapbox GL JS использует WebGL для отображения карты. Внутри библиотеки существует цикл обновления, который запускается при возникновении событий, влияющих на визуальное состояние карты.

Перерисовка может происходить после:

  • изменения положения камеры;
  • изменения масштаба;
  • поворота карты;
  • обновления данных источника;
  • изменения стиля слоя;
  • добавления или удаления слоёв;
  • выполнения анимации.

Например:

map.setZoom(12);

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

Аналогично:

map.setPaintProperty(
    'roads',
    'line-color',
    '#ff0000'
);

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


Избегание частых вызовов setData()

Одной из наиболее распространённых причин снижения производительности является частое обновление GeoJSON-источников.

Источник:

map.addSource('vehicles', {
    type: 'geojson',
    data: vehiclesData
});

Нежелательный вариант:

setInterval(() => {
    source.setData(generateNewData());
}, 100);

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

  • геометрию объектов;
  • тайловую структуру;
  • коллизии подписей;
  • отображение слоёв.

При большом количестве объектов нагрузка становится значительной.

Лучше группировать обновления:

setInterval(() => {
    source.setData(getUpdatedData());
}, 1000);

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


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

Часто обновляется весь GeoJSON-файл, хотя изменяется лишь небольшая часть объектов.

Плохо:

source.setData(hugeGeoJson);

Если в коллекции находятся десятки тысяч объектов, Mapbox вынужден заново обработать весь набор данных.

Более эффективный подход заключается в накоплении изменений и обновлении данных пакетами через определённый интервал времени.

Например:

const pendingChanges = [];

function updateVehicle(vehicle) {
    pendingChanges.push(vehicle);
}

Далее изменения объединяются в одну операцию обновления.

Такой подход уменьшает количество полных пересчётов источника.


Использование feature-state вместо изменения GeoJSON

Для динамических визуальных изменений часто используется повторный вызов setData(), хотя в большинстве случаев достаточно механизма feature-state.

Предположим, необходимо выделять выбранный объект.

Неэффективный вариант:

source.setData(updatedGeoJson);

Эффективный вариант:

map.setFeatureState(
    {
        source: 'buildings',
        id: 15
    },
    {
        selected: true
    }
);

Стиль слоя:

paint: {
    'fill-color': [
        'case',
        ['feature-state', 'selected'],
        '#ff0000',
        '#0080ff'
    ]
}

Преимущества:

  • отсутствует повторная загрузка данных;
  • изменяется только состояние объекта;
  • значительно уменьшается объём вычислений.

Для интерактивных интерфейсов данный подход считается одним из наиболее эффективных.


Минимизация изменений стиля

Каждый вызов методов изменения стиля инициирует перерасчёт визуального представления слоя.

Например:

map.setPaintProperty(
    'buildings',
    'fill-opacity',
    0.5
);

Если выполнять подобные операции многократно подряд:

map.setPaintProperty(...);
map.setPaintProperty(...);
map.setPaintProperty(...);
map.setPaintProperty(...);

возникает серия последовательных перерасчётов.

Рациональнее объединять изменения логически и выполнять их реже.

Особенно это касается:

  • setPaintProperty();
  • setLayoutProperty();
  • setFilter();
  • setStyle().

Осторожное использование setStyle()

Метод setStyle() является одной из самых дорогостоящих операций в библиотеке.

Пример:

map.setStyle('mapbox://styles/mapbox/dark-v11');

После вызова происходит:

  • загрузка нового стиля;
  • создание источников;
  • создание слоёв;
  • перестроение сцены;
  • повторная обработка ресурсов.

По сути карта строится заново.

Частое переключение стилей во время работы приложения может вызывать заметные задержки.

Если требуется изменить лишь несколько параметров отображения, лучше использовать методы изменения конкретных слоёв.


Ограничение количества активных слоёв

Каждый слой участвует в процессе рендеринга.

Например:

map.addLayer({
    id: 'roads',
    type: 'line',
    source: 'roads'
});

Если карта содержит сотни слоёв:

Roads
Roads labels
Buildings
Buildings labels
Trees
Trees labels
Water
Water labels
...

увеличиваются:

  • затраты на вычисление стилей;
  • число draw calls;
  • объём памяти.

Рекомендуется объединять данные в меньшем количестве слоёв, когда это возможно.

Например, вместо нескольких однотипных слоёв использовать выражения:

'circle-color': [
    'match',
    ['get', 'type'],
    'restaurant', '#ff0000',
    'hotel', '#0000ff',
    '#00aa00'
]

Один слой обычно работает быстрее нескольких аналогичных слоёв.


Использование фильтров вместо пересоздания слоёв

Распространённая ошибка выглядит следующим образом:

map.removeLayer('points');
map.addLayer(newLayer);

Подобная операция требует повторной регистрации слоя.

Гораздо эффективнее применять фильтрацию:

map.setFilter(
    'points',
    ['==', ['get', 'category'], 'hotel']
);

В этом случае структура слоя сохраняется, а изменяется только условие отображения.


Снижение количества анимаций

Каждая анимация вызывает постоянные обновления сцены.

Например:

function animate() {
    map.rotateTo(
        map.getBearing() + 1,
        { duration: 0 }
    );

    requestAnimationFrame(animate);
}

animate();

Подобный код инициирует перерисовку практически на каждом кадре.

При наличии большого количества объектов это может привести к существенному падению FPS.

Следует избегать:

  • постоянного вращения карты;
  • непрерывного изменения масштаба;
  • бесконечных визуальных эффектов без необходимости.

Debounce для пользовательских событий

События интерфейса могут генерироваться очень часто.

Пример:

input.addEventListener('input', event => {
    updateMap(event.target.value);
});

Во время набора текста обновление карты будет происходить после каждого символа.

Лучше использовать debounce:

function debounce(fn, delay) {
    let timer;

    return (...args) => {
        clearTimeout(timer);

        timer = setTimeout(() => {
            fn(...args);
        }, delay);
    };
}

Использование:

const update = debounce(value => {
    updateMap(value);
}, 300);

input.addEventListener('input', e => {
    update(e.target.value);
});

Количество обновлений карты сокращается в несколько раз.


Throttle для перемещения карты

Событие движения карты генерируется очень часто:

map.on('move', () => {
    updateData();
});

Во время перетаскивания пользователь может вызвать сотни событий за несколько секунд.

Решение — throttle.

function throttle(fn, delay) {
    let waiting = false;

    return (...args) => {
        if (waiting) return;

        waiting = true;

        fn(...args);

        setTimeout(() => {
            waiting = false;
        }, delay);
    };
}

Применение:

map.on(
    'move',
    throttle(() => {
        updateData();
    }, 200)
);

Количество дорогостоящих операций значительно уменьшается.


Использование moveend вместо move

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

Неэффективно:

map.on('move', loadData);

Эффективно:

map.on('moveend', loadData);

Событие возникает единожды после завершения панорамирования.

Аналогично работают:

zoomend
dragend
rotateend
pitchend

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


Контроль за обработчиками событий

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

Плохо:

map.on('move', updateA);
map.on('move', updateB);
map.on('move', updateC);
map.on('move', updateD);

Каждое событие вызывает четыре отдельных процесса.

Лучше:

map.on('move', () => {
    updateA();
    updateB();
    updateC();
    updateD();
});

Централизованная обработка облегчает контроль количества операций.


Кэширование вычислений

Часто встречается код:

map.on('move', () => {
    const bounds = map.getBounds();

    expensiveCalculation(bounds);
});

Если вычисления сложные и параметры не изменились, результат можно сохранить.

Пример:

let lastBounds = null;

map.on('moveend', () => {
    const bounds = map.getBounds();

    if (
        JSON.stringify(bounds) ===
        JSON.stringify(lastBounds)
    ) {
        return;
    }

    lastBounds = bounds;

    expensiveCalculation(bounds);
});

Кэширование уменьшает число повторных вычислений и связанных с ними обновлений интерфейса.


Удаление невидимых слоёв

Если слой временно не нужен, его можно скрыть.

map.setLayoutProperty(
    'traffic',
    'visibility',
    'none'
);

Это предпочтительнее постоянного удаления и повторного создания:

map.removeLayer('traffic');
map.addLayer(...);

Скрытие позволяет избежать затрат на повторную инициализацию слоя.


Использование векторных тайлов вместо крупных GeoJSON

Большие GeoJSON-файлы увеличивают время обработки и количество вычислений при обновлении карты.

Например:

{
    "type": "FeatureCollection",
    "features": [...]
}

При десятках или сотнях тысяч объектов объём данных становится критическим.

Векторные тайлы позволяют:

  • загружать данные частями;
  • рендерить только видимые области;
  • уменьшать объём памяти;
  • сокращать количество операций перерасчёта.

Для крупных картографических проектов это один из ключевых методов оптимизации.


Профилирование производительности

Минимизация перерисовок невозможна без измерений.

Основные инструменты:

  • Chrome DevTools Performance;
  • Chrome Rendering Tools;
  • WebGL Inspector;
  • Firefox Performance Profiler.

Следует отслеживать:

  • FPS;
  • время рендеринга кадра;
  • количество вызовов JavaScript;
  • загрузку GPU;
  • объём памяти.

Типичные признаки избыточных перерисовок:

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

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