Как нарисовать тысячи объектов на карте и не убить FPS: эволюция рендера на Mapbox GL

от автора

В прошлой статье про нетривиальные анимации я обещал показать «ту самую карту с облаками, zoom и движением по кривой Безье». Пришло время. Начну, правда, не с самой анимации, а с того, что было до неё: прежде чем что-то красиво анимировать на карте, надо это что-то на карте хотя бы нарисовать.

Контекст: я делал геолокационную соцсеть. На карте – «облака», посты, привязанные к точке на местности. Их может быть много. Очень много. И вся история ниже – про то, как я пять раз упёрся в производительность и что с каждым разом делал.

Наивный подход: одно облако – одна аннотация

Первое, что приходит в голову на любой карте (хоть MapKit, хоть Mapbox), – это аннотации. У тебя есть объект с координатой, ты создаёшь MGLAnnotation, отдаёшь карте addAnnotation, а в делегате возвращаешь вьюшку:

let annotation = MGLPointAnnotation()annotation.coordinate = cloud.coordinatemapView.addAnnotation(annotation)func mapView(_ mapView: MGLMapView, viewFor annotation: MGLAnnotation) -> MGLAnnotationView? {    let view = CloudAnnotationView(...)   // UIView с картинкой облака    return view}

Работает отлично – пока облаков десятки. Дальше начинается боль:

  • Каждая аннотация – это UIView в иерархии. Сотни и тысячи UIView, которые карта пытается позиционировать на каждый кадр движения, – это гарантированная просадка FPS.

  • При зуме/скролле карта дёргает делегат, пересоздаёт и перекладывает вьюшки. На «тысяче мест» это уже не карта, а слайд-шоу.

  • На старых устройствах (а в 2016–2018 это был добрый кусок аудитории) всё это умирало ещё раньше.

Тупик кластеризации

Очевидный ответ на «слишком много маркеров» – кластеризация: рядом стоящие точки схлопываем в один кружок с числом. Я честно прошёл этот путь дважды.

Сначала подключил ObjC-библиотеку кластеризации (GClusterManager и компания). Стало лучше, но всё равно тупило: каждый кластер – это снова аннотация с imageView. Когда я писал эту статью, я нашёл это в собственной истории коммитов:

«Новая кластеризация, которая не тупит. Теперь мест дофига может быть на карте»

То есть я выкинул первую библиотеку и взял вторую (поверх Google Maps Utils). Помогло, стало заметно лучше, но кластеризация имеет свою проблему: при кластеризации точки в группе получают одну координату. Есть два основных способа расставлять кластеры на карте: это может быть центр прямоугольника и это может быть центр масс материальных точек – в данном случае это чаты на карте. Можно задавать разные веса, можно одинаковые, но в любом случае мы не видим самых главных вещей: где именно на карте точно сосредоточены чаты и где наибольшая активность. Ещё одна проблема кластеров в текущей реализации – это всё те же аннотации, то есть всё те же UIView.

Перелом: рисуем не аннотациями, а GL-слоями

У Mapbox (тогда – MGL, Mapbox GL Native) есть style layers: MGLCircleStyleLayer, MGLSymbolStyleLayer, MGLHeatmapStyleLayer. Ты не создаёшь объект на каждую точку – ты отдаёшь карте источник данных (MGLShapeSource с коллекцией фич) и один слой, который описывает, как эти точки рисовать. Дальше всё уходит на GPU.

Коммит, который всё развернул, называется буквально: «Теперь ВСЕ маркеры являются частями MGLSymbolStyleLayer». Следом – «Удалил всё, что связано с GoogleMaps»: минус полторы тысячи строк ObjC-кластеринга, CloudsMarker, CloodsClusterRender. Аннотация-UIView осталась ровно одна – маркер текущего пользователя.

Принцип:

// один источник на тысячи точекlet source = MGLShapeSource(identifier: "clouds", features: features, options: nil)style.addSource(source)// один слой, который описывает КАК рисовать – остальное на GPUlet layer = MGLSymbolStyleLayer(identifier: "clouds-icons", source: source)layer.iconImageName = NSExpression(forKeyPath: "icon")   // data-driven: имя картинки берётся из самой фичиlayer.iconAllowsOverlap = NSExpression(forConstantValue: true)style.addLayer(layer)

Ключевое слово – data-driven. Иконка, размер, цвет – это не свойство «на весь слой», а выражение (NSExpression), которое для каждой точки вычисляется из её данных и из текущего зума. Один слой обслуживает тысячи точек.

Три движка рендера на разных зумах

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

  • далёкий зум (< 9)MGLHeatmapStyleLayer, тепловая карта плотности. Никаких отдельных иконок: на масштабе страны человеку важно «где густо», а не каждая точка;

  • средний зум (9–12)MGLCircleStyleLayer, простые круги;

  • близкий зум (≥ 12)MGLSymbolStyleLayer, детальные иконки облаков.

Пороги – обычные константы:

static let smallCirclesZoomLevel: Float = 9.0static let circlesZoomLevel:      Float = 12.0

А переключение между движками – это не «if zoom < 9», выполняемый на CPU каждый кадр. Это снова выражение, которое гасит прозрачность слоя на нужном диапазоне зумов, и считает его GPU:

heatmapLayer.heatmapOpacity = NSExpression(    format: "FUNCTION($zoomLevel, 'mgl_interpolateWithCurveType:parameters:stops:', 'linear', null, %@)",    [ Self.smallCirclesZoomLevel: 1,      Self.smallCirclesZoomLevel + 0.2: 0 ]   // выше этого зума heatmap плавно исчезает)

Перенос zoom-зависимости с CPU на GPU-выражения (mgl_interpolate…/mgl_step…) был отдельным коммитом – и отдельным скачком плавности.

Главная фишка: круги постоянного РЕАЛЬНОГО размера

У каждого облака есть «аура» – радиус оповещения, нарисованный кругом на карте. Здесь принципиальный момент: радиус этого круга задан в метрах на местности, а не в пикселях на экране. То есть при зуме круг не остаётся одного и того же размера на экране (как обычная точка), а ведёт себя как реальный объект на земле – приближаешь карту, и круг растёт вместе с улицами под ним.

Как это сделать, если circleRadius у слоя задаётся в пикселях? Через тот же приём – интерполяцию по зуму, но с предварительно посчитанными «метры → пиксели» на опорных зумах.

Сначала функция «сколько метров в одном пикселе» на данной широте и зуме (обычная Web-Mercator математика):

func metersPerPixel(at lat: CLLocationDegrees, zoom: Float) -> CLLocationDistance {    let worldSize = pow(2.0, Double(zoom)) * 512          // размер мира в пикселях на этом зуме    return cos(lat * .pi / 180) * 2 * .pi * 6_378_137 / worldSize}

Дальше – берём размер ауры в метрах, переводим его в пиксели на двух опорных зумах (2 и 15) и отдаём карте экспоненциальную интерполяцию между ними:

let meters = CloodsUtils.auraRadiusBy(cloud: cloud)       // размер ауры в МЕТРАХlet rPx2  = Double(meters) / metersPerPixel(at: lat, zoom: 2)let rPx15 = Double(meters) / metersPerPixel(at: lat, zoom: 15)// радиус круга в пикселях интерполируется по зуму так,// что РЕАЛЬНЫЙ размер на местности остаётся постояннымlayer.circleRadius = NSExpression(    format: "FUNCTION($zoomLevel, 'mgl_interpolateWithCurveType:parameters:stops:', 'exponential', 1.75, %@)",    [  2: NSExpression(forConstantValue: rPx2),      15: NSExpression(forConstantValue: rPx15),      22: NSExpression(forConstantValue: rPx15) ])

И снова всё, что зависит от зума, считает GPU. Мы не пересчитываем радиусы кругов в Swift на каждый кадр движения карты – мы один раз описали закон, по которому они меняются.

Кстати, сам размер ауры в метрах – это не константа: он зависит от «веса» облака и от активности региона. В тихом регионе одно и то же облако получает ауру побольше, в шумном – поменьше, чтобы карта не превращалась в кашу из перекрывающихся кругов. Но это уже про данные, а не про рендер.

Анимируем то, что штатно не анимируется

Теперь та самая анимация, которую я обещал. Проблема: у UIView есть alpha, который Core Animation анимирует за тебя. У GL-слоя такого нет. circleOpacity – это NSExpression, а не анимируемое свойство. Нельзя написать UIView.animate { layer.opacity = 0 }.

Здесь и пригодился DisplayLinkAnimator из прошлой статьи – кадровый аниматор поверх CADisplayLink. Я анимирую прозрачность слоя сам, покадрово, переустанавливая константное выражение каждый кадр:

// кросс-фейд GL-слоя: каждый кадр пересобираем константное выражение opacityanimator.startAnimation(    animationDuration: 0.25,    animation: { [weak layer] _, progress in        layer?.circleOpacity = NSExpression(forConstantValue: progress)   // 0 → 1    },    needCompleteAnimationAfterStopping: true   // гарантированно доведём до конечного состояния)

Именно так сделан плавный переход между тремя движками рендера и появление/исчезновение аур: heatmap гаснет – круги проявляются, и наоборот. Сам механизм аниматора (ProMotion, timing-функции, синхронизация с обновлением экрана) я разбирал в прошлой статье, так что здесь не повторяюсь – важно, что он позволил анимировать то, у чего нет анимируемого свойства.

Ещё четыре оптимизации, без которых ничего не взлетело

Перевод на GL-слои был главным сдвигом, но не единственным. Коротко – остальное, что в сумме и дало плавность:

40 слоёв → 12. Сначала я держал по слою на каждый размер облака: ~20 слоёв под иконки и ~20 под круги. Плюс гонял геометрию в карту через JSONSerialization в geoJSON-строку на каждый апдейт. Коммит «вместо 20 слоёв для иконок теперь 2, вместо 20 слоёв для кругов – 10» свернул всё в data-driven выражения (forKeyPath: "icon", цвет viewed/normal через mgl_step) и убрал JSON-сериализацию – данные пошли прямо через MGLShapeSource(features:).

Рисуем не всё, а топ. Наивная ранняя установка «показывать ВСЕ облака – наверное, так быстрее» оказалась ровно наоборот. Появился клиентский QuadTree – пространственный индекс, который заменяет перебор всех точек спуском по дереву (и для тапа по облаку, и для выборки «кто сейчас в кадре»). Из найденных облаков рисуется только ограниченный топ: у меня это 15 аур в видимой области плюс до 100 «про запас» в расширенной зоне за краем экрана, чтобы они не выскакивали рывком при панорамировании. Число одновременно рисуемых объектов перестало зависеть от плотности данных. Как устроен сам индекс и как из тысяч облаков выбираются те самые «топовые» – отдельная следующая статья.

Дебаунс «первый + последний». При быстром зуме/скролле карта сыпала перекрывающимися сетевыми запросами облаков, а протухшие ответы перерисовывали уже неактуальную область. Решение: пока идёт загрузка, форсированный запрос откладывается, выполняется только последний – и только если его queryID ещё актуален. Вместо N запросов на жест – первый и последний.

Диффинг аур вместо пересоздания. Был баг: при движении карты ауры пересоздавались каждый апдейт и накапливались («облаков с аурами становилось всё больше»). Теперь showAuras считает set-разницу между тем, что уже нарисовано (lastCloudsWithAuras), и новым набором – и трогает только дельту: что появилось, что исчезло. Если дельта пустая – ранний выход, карта вообще ничего не пересобирает.

И поверх всего – тяжёлая перезагрузка облаков срабатывает не на каждый кадр движения (regionIsChanging), а только когда карта остановилась (regionDidChangeAnimated → idle). Во время самого движения обновляется лишь видимость уже существующих слоёв, без пересоздания.

Итог

Если выкинуть всю историю в одну мысль: на карте с тысячами объектов нельзя думать объектами UIKit. Каждый маркер как UIView-аннотация – это потолок в десятки точек. Как только рендер переезжает на GL-слои с data-driven выражениями, а зум-зависимость (размер, прозрачность, выбор движка) считает GPU, – карта перестаёт замечать количество точек. Дальше остаётся инженерная гигиена: не держать лишних слоёв, не пересоздавать то, что не изменилось, не слать запросы на каждый чих и грузить тяжёлое в фоне.

А «круги постоянного реального размера» через metersPerPixel + экспоненциальную интерполяцию – мой любимый приём из всего этого: один раз описываешь физический закон, и карта сама держит его на всех зумах )

В следующей части – тот самый клиентский QuadTree: как по тапу пальца за O(log n) понять, на какое из тысяч облаков ты попал, и как из всей массы выбрать те самые «топовые», что стоит показать.

ссылка на оригинал статьи https://habr.com/ru/articles/1051362/