Всем привет! Меня зовут Женя, я работаю iOS разработчиком в каршеринг-сервисе Ситидрайв, где мы с командой стремимся улучшить пользовательский опыт и сделать наше приложение более интуитивно понятным и функциональным. В этой статье я расскажу, как у нас организована работа с картой: как отображаем автомобили и другие объекты, какие проблемы возникали в процессе разработки, и почему мы выбрали формат данных GeoJSON. Также поделюсь особенностями работы с форматом, которые важно знать любому разработчику и расскажу о некоторых его преимуществах.

Как мы организовали свою работу

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

protocol MapProviderProtocol: AnyObject {
    var view: UIView { get }

    func show(wrappedPolygons: inout [PolygonWrapper])
    func show(wrappedPolylines: inout [PolylineWrapper])
    func show(wrappedCircle: inout CircleWrapper)

    func hide(wrappedPolygons: [PolygonWrapper])
    func hide(wrappedPolylines: [PolylineWrapper])
    func hide(wrappedCircle: CircleWrapper)

    func listenCameraPositions()
}

Протокол имеет и другие свойства:

  • Добавление маркеров авто и геолокации пользователя.

  • Обновление кластеризации.

  • Получение координат по нажатию  на слое с картой.

  • Перемещение viewport.

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

class MapFactory {
    func makeMap() -> MapProviderProtocol {
        toggleService.isEnabled(.googleMaps)
            ? GMaps()
            : DoubleGisMaps()
    }
}

Если позже мы захотим добавить работу с OSM (OpenStreetMap) или нативными картами (MapKit), нужно будет написать адаптер, который поддерживает и реализует методы MapProviderProtocol.

Далее управляемый экземпляр карты мы оборачиваем в класс, который соответствует MapProtocol. Этот протокол определяет поведение для объектов, управляющих бизнес данными:

protocol MapProtocol: AnyObject {
    var map: MapProviderProtocol { get }
    func show()
    func removeAllDrawnObjects()
}

Так, корневым объектом, который далее мы будем декорировать, является BaseMap класс. Он занимается обновлением и наблюдением за видимой областью карты в момент ее использования.

class BaseMap: MapProtocol {
    let map: MapProviderProtocol

    init(map: MapProviderProtocol) {
        self.map = map
    }
    
    func show() {
        map.listenCameraPositions()
    }

    func removeAllDrawnObjects() {}
}

Далее накидываем нужные слои, например, ParkingsMap, CarMap, LocationMap и так далее. Каждый из них отвечает за отображение своих собственных данных. Всего у нас есть 14 различных декораторов, которые инкапсулируют логику добавления графики на MapProviderProtocol.

Вот как может выглядеть реализация такого декоратора:

class ActiveCarMap: MapProtocol {
    private var selectedCar: MapMarker?
    private let car: Car
    private let decorator: MapProtocol
    
    let map: MapProviderProtocol

    init(decorator: MapProtocol, car: Car) {
        self.car = car
        self.map = decorator.map
        self.decorator = decorator
    }

    func show() {
        decorator.show()
        let marker = map.makeMarker(
            location: car.location,
            icon: car.icon
        )
        map.add(markers: [marker])
        selectedCar = marker
        selectedCar?.loadImage(from: car.pinImage)
    }

    func removeAllDrawnObjects() {
        decorator.removeAllDrawnObjects()
        if let marker = selectedCarMarker {
            map.remove(markers: [marker])
            selectedCar = nil
        }
    }
}

Новый слой принимает в инициализаторе MapProtocol объект –– это всегда ранее упомянутый BaseMap, который, как правило, мы передаём во view model при создании модуля. Вот как это выглядит на практике:

class MapViewModel {
    let map: MapProviderProtocol
    let baseMap: MapProtocol
    let mapComposite: MapCompositeProtocol
    let car: Car

    init(
        map: MapProviderProtocol,
        baseMap: MapCompositeProtocol,
        car: Car
    ) {
        self.map = map
        self.baseMap = BaseMap(map: map)
        self.mapComposite = mapComposite
        self.car = car
        
        setupMap()
    }

    func setupMap() {
        mapComposite.add(map: baseMap)
        
        let locationMap = LocationMap(decoratee: baseMap)
        mapComposite.add(map: locationMap)
        
        let activeCarMap = ActiveCarMap(decoratee: baseMap, car: car)
        mapComposite.add(map: activeCarMap)
    }
}

? Мы выносим baseMap как свойство модели, чтобы обеспечить опциональный доступ к объекту из других методов. В целом, можно самостоятельно организовать удобное взаимодействие по имеющейся архитектуре. Свойство baseMap.view может быть передано в контроллер на этапе сборки, чтобы подменить собой view контроллер.

Как видите, все MapProtocol объеди��яются внутри MapComposite. Этот объект хранит все элементы нашей матрёшки. Всё, что ему нужно делать, можно описать так:

protocol MapCompositeProtocol: AnyObject {
    func add(map: MapProtocol)
    func remove(map: MapProtocol?)
}

class MapComposite: MapCompositeProtocol {
    var maps: [MapProtocol] = []

    func add(map: MapProtocol) {
        maps.forEach {
            $0.show()
            self.maps.append($0)
        }
    }

    func remove(map: MapProtocol?) {
        maps.compactMap({ $0 }).forEach { map in
            map.removeAllDrawnObjects()
            self.maps.removeAll(where: { $0 === map })
        }
    }
}

Главное то, что при добавлении в композицию, каждый MapProtocol объект вызывает метод show(), добавляя себя на baseMap. При этом show() отдельного слоя вызывает какой-то из соответствующих методов MapProviderProtocol, что отсылает нас на нужный метод в SDK.

Процесс удаления объектов обратный –– вместо метода mapComposite.add(someMap) используем mapComposite.remove(someMap).

В итоге мы получаем примерно такую схему:

Из представленного подхода у нас есть:

  • Предельно понятные и наглядные экземпляры, определяющие слои данных.

  • Гибкое комбинирование имеющихся слоёв, например, может потребоваться добавить метку геолокации пользователя на карту, где её раньше не было – для нас это буквально пара строк.

  • Простое добавление слоёв с новым UI, например, метки дорожных ситуаций, комментарии и вообще всё, что придумает маркетинг – создаём новый инстанс MapProtocol, реализуем в нём 2 протокольные функции, в которых обязаны вызвать что-то из методов MapProviderProtocol для их отрисовки.

  • Вся схема тестируема с умеренным количеством костылей.

Текущие проблемы

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

До превращения в видимые полигоны эти данные, как правило, представляют из себя объекты с типами [[CLLocationCoordinate2D]], или [String: [String]] в зависимости от возраста фичи . Иногда к этому добавляются другие метаданные, и мы имеем кучу моделей, которые должны быть преобразованы на клиенте в кучу других типов, чтобы они соответствовали сигнатуре методов MapProviderProtocol.

Для такого преобразования мы делаем модели-обёртки по такой схеме:

Клиентские модели – типы-обертки – типы, предоставляемые SDK для последующего рендеринга
Клиентские модели – типы-обертки – типы, предоставляемые SDK для последующего рендеринга

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

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

Ещё одна проблема –– разные SDK по естественным причинам могут не иметь каких-то методов, что не позволяет написать конкретные реализации, объявленные в нашем MapProviderProtocol. Таким образом, сейчас мы имеем ряд ограничений при работе с Google картами, и неизвестно, что будет, если придётся поддержать, например, нативные Apple карты.

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

В итоге, если визуализировать ранее описанную схему, но добавить к ней частности, связанные с отображением областей на карте, получится более честная визуализация:

Суммарно наш код по работе с картами, не считая тестов, укладывается в ~5000 cтрок. Тут всё: геометрии, геолокация, маркеры, работа с viewport, кластеризацией и т.д. Это не самый простой для понимания код, и если есть возможность хоть немного снизить когнитивную нагрузку – это будет оптимально как для проекта, так и для здоровья разработчика.

Поиск решения

Наши проблемы при работе с геометриями лежат в плоскости согласования данных и интеграции нас с SDK провайдером. Было бы здорово иметь общий контракт, который одновременно поддерживается нативным MapKit, OSM, 2GIS, Google и вообще любым сервисом, отображающим карту.

Такой контракт есть, он называется GeoJSON и его задача унифицировать описание геометрий, предоставить интерфейс для самих данных.

GeoJSON предполагает, что все геометрии могут быть разделены на 7 типов:

  • Point, LineString, Polygon — простые однокомпонентные геометрии.

  • MultiPoint, MultiLineString, MultiPolygon — геометрия состоит из одной или нескольких фигур одного типа.

  • GeometryCollection — коллекция, где геометрия может состоять из одной или нескольких фигур любого типа.

Источник: https://geobgu.xyz/web-mapping2/geojson-1.html

Это хорошо задокументированный стандарт, который в настоящее время поддерживается всеми крупными вендорами карт. Статус стандарта позволяет, во-первых, написать логику статических анализаторов (своего рода линтер) и во-вторых, используя “сырой JSON”, использовать сторонние API для рендеринга.

Источник: https://geobgu.xyz/web-mapping2/geojson-1.html

В основе всего лежат точки. Здесь и далее любая геометрия обязана иметь массив coordinates и объявление типа в свойстве type – это зарезервированные ключи, на которые опирается статический анализатор во время валидации.

{
  "type": "Point",
  "coordinates": [30, 10]
}
Источник: https://geobgu.xyz/web-mapping2/geojson-1.html

Линии также объявляются вполне ординарно – массивом точек. 

{
  "type": "LineString",
  "coordinates": [
    [30, 10], [10, 30], [40, 40]
  ]
}
Источник: https://geobgu.xyz/web-mapping2/geojson-1.html

Далее добавляется ещё один уровень вложенности для массива coordinates, и мы получаем возможность рисовать полигоны – интуитивно понятные структуры. Можно отметить только тот факт, что стандарт обязывает, а реализация любого валидатора должна учитывать – начальная и конечные точки должны совпадать.

{
  "type": "Polygon",
  "coordinates": [
    [[30, 10], [40, 40], [20, 40], [10, 20], [30, 10]]
  ]
}
Источник: https://geobgu.xyz/web-mapping2/geojson-1.html

Полигон может содержать «‎дырки»‎, для этого массив coordinates должен содержать полигон для такого выреза.

{
  "type": "Polygon",
  "coordinates": [
    [[35, 10], [45, 45], [15, 40], [10, 20], [35, 10]],
    [[20, 30], [35, 35], [30, 20], [20, 30]]
  ]
}
Источник: https://geobgu.xyz/web-mapping2/geojson-1.html

Любое объединение примитивов во что-то с приставкой Multi – MultiPoint или MultiLineString интересно тем, что для рендеринга это становится одним геометрическим объектом.

{
  "type": "MultiPoint",
  "coordinates": [
    [10, 40], [40, 30], [20, 20], [30, 10]
  ]
}
Источник: https://geobgu.xyz/web-mapping2/geojson-1.html

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

{
  "type": "MultiLineString",
    "coordinates": [
      [[10, 10], [20, 20], [10, 40]],
      [[40, 40], [30, 30], [40, 20], [30, 10]]
  ]
}

Мы устроили стресс-тест и экспериментально замерили, что 150 000 отдельных полигонов составляет значительную нагрузку на ОЗУ. До вылета приложения не дошло, но суммарный расход с 200 Мб до 1.5 Гб мы увидели. Было интересно узнать, как с такими задачами справятся другие провайдеры, но пока мы сфокусированы на 2GIS.

Источник: https://geobgu.xyz/web-mapping2/geojson-1.html

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

{
  "type": "MultiPolygon",
  "coordinates": [
    [
      [[30, 20], [45, 40], [10, 40], [30, 20]]
    ],
    [
      [[15, 5], [40, 10], [10, 20], [5, 10], [15, 5]]
    ]
  ]
}
Источник: https://geobgu.xyz/web-mapping2/geojson-1.html

Это значит, что можно объединить, например, зоны парковок по одному городу и её пригородам в один геометрический объект, и отдать на рендеринг.

{
  "type": "MultiPolygon",
  "coordinates": [
    [
      [[40, 40], [20, 45], [45, 30], [40, 40]]
    ],
    [
      [[20, 35], [10, 30], [10, 10], [30, 5], [45, 20], [20, 35]],
      [[30, 20], [20, 15], [20, 25], [30, 20]]
    ]
  ]
}
Источник: https://geobgu.xyz/web-mapping2/geojson-1.html

Поднимаемся на ещё один уровень абстракции, где нас ждёт контейнер для всех предыдущих геометрий. Это довольно сложный и скорее бестолковый тип геометрии, так как у него нет какого-то единого полезного признака, например, площади этой геометрии или протяжённости линий –– все объекты внутри неоднородны. По крайней мере, мне тяжело найти ему применение.

{
  "type": "GeometryCollection",
  "geometries": [
    {
      "type": "Point",
      "coordinates": [10, 30]
    },
    {
      "type": "MultiLineString",
      "coordinates": [
        [[10, 10], [20, 20]],
        [[40, 40], [30, 30], [40, 20], [30, 10]]
      ]
    }
  ]
}
Источник: https://geobgu.xyz/web-mapping2/geojson-1.html

Ну и доходим до того, что реально используется. Feature объединяет геометрию с непространственными атрибутами, в которых мы задаём любые наши параметры.Интерпретатор игнорирует объявленные в properties свойства до того момента, пока вы не скажете, что с этим делать. Так, у 2GIS используется отдельный файл со стилями, который должен содержать расшифровку объявленных ключей и значений, а при работе с MapKit нужно вручную выделить эти свойства и задать в качестве атрибутов для объектов рендеринга.

{
  "type": "Feature",
  "geometry": {
    "type": "Polygon",
    "coordinates": [
      [[15, 5], [40, 10], [10, 20], [5, 10], [15, 5]]
    ]
  },
  "properties": {
    "fill": "red",
    "area": 3_272_386
  }
}
Источник: https://geobgu.xyz/web-mapping2/geojson-1.html

Абстракцией выше Features собираются в главного десептикона FeatureCollection, который не добавляет нового поведения. Но интересно, что он может содержать разнородные объекты.

{
  "type": "FeatureCollection",
  "features": [
    {
      "type": "Feature",
      "geometry": {
        "type": "Polygon",
        "coordinates": [
          [[30, 20], [45, 40], [10, 40], [30, 20]]
        ]
      },
      "properties": {
        "fill": "green",
        "area": 3565747
      }
    },
    {
      "type": "Feature",
      "geometry": {
        "type": "Polygon",
        "coordinates": [
          [[15, 5], [40, 10], [10, 20], [5, 10], [15, 5]]
        ]
      },
      "properties": {
        "fill": "red",
        "area": 3272386
      }
    }
  ]
}

В целом, это довольно просто для понимания, поэтому давайте рассмотрим несколько неочевидных моментов при работе с GeoJSON.

Вспоминаем географию

Если кому-то придётся работать с морской или глобальной картой, можно столкнуться с проблемой визуализации областей в зоне антимеридиана. 

Значения для параллелей или долготы определяется интервалом -180* / 180*. Рендеринг геометрии, пересекающей место соединения этих координат неочевиден, так как непонятно, в какую сторону рисовать линию к координатам [-170:0] до [170:0]. Возможно, мы бы хотели отрисовать кратчайший путь, но из-за привязки к координатной плоскости эта линия должна пройти «‎обратно» по всему экватору.

В этом случае геометрия должна быть «разрезана на две части» и отрисована отдельно. Поэтому могут быть «артефакты» в области соединения, если области содержат видимые границы.

Я пробовал несколько плоттеров, часть из которых справляется самостоятельно, но некоторые не отрисовывают объекты и не сообщают об ошибке. Например, MapKit требователен к данным в этом вопросе.

Оптимизация на уровне данных

GeoJSON объявляет дополнительное свойство bbox (bounding box), которое представляет собой контейнер, ограничивающий нашу геометрию. Это нужно для помощи интерпретатору и более быстрому рендерингу, так как позиции центра и углов будут уже известны. Вероятно, это будет заметно на большом количестве объектов или при частых перерисовках макета, но если нужно отрисовать 100 статичных квадратов, я думаю, можно пренебречь указанием bbox.

Когда строишь многоугольник, можно указывать координаты в любом направлении. В целом, регламент определяет, что внешняя граница полигона объявляется «против часовой», полигоны-вырезы «по часовой стрелке». И хоть интерпретаторы в большинстве случаев справляются с данными любой направленности, нужно исключить не нужные преобразования. Для э��ого есть даже удобный сервис.

Проблема больших данных

Во время работы мы столкнулись с большим размером исходного файла. В контексте использования на мобильном устройстве эта проблема стоит особенно остро. Кроме очевидного размещения в S3 хранилище, мы задумались о модерации самих данных. Вот что точно имеет смысл сделать:

  • Выделить повторяющиеся свойства атрибуции в следующем стиле:

"properties": {
  "stroke": "#00b45d",
  "stroke-width": 2,
  "stroke-opacity": 1,
  "fill": "#32d264",
  "fill-opacity": 0.5
}

Этот набор данных может повторяться в каждом объекте геометрии. Так почему бы его не объявить отдельным свойством:

"properties": {
  "style": "primary"
}
  • Ограничить максимальную точность координаты ––  это основной тип данных, поэтому сокращение цифр после точки, например, с 15 до 5 сократит размер файла почти втрое. Нужно будет проверять на практике, что это не ухудшит качество сервиса.

  • Применить алгоритмы упрощения. Мы не можем просто удалить случайные координаты, потому что некоторые координаты важнее других. Определение того, какие координаты должны быть сохранены, а какие можно безопасно убрать, требует алгоритма упрощения. В этом может помочь например, алгоритм Дуглас-Пикера:

Тем не менее, есть несколько забавных особенностей, которые в 99.99% случаях никак вам не помешают, но важно про них знать.

Проблема Юрия Лозы

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

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

При использовании GeoJSON ничего не получится, потому что большинство разработчиков мыслит и существует как Декарт –– мы живём в плоскоземелье, чтобы наши системы работали.

На самом деле мы отметили часть точек на плоскости, и плоттер их соединил. Если «развернуть»‎ полученный полигон, то можно понять, почему, например, нельзя “обвести” Антарктиду.

Эффект Меркатора

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

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

Рефлексия

На мобильном клиенте мы буквально удалили все классы-обертки и очистили MapProviderProtocol от специализированных методов типа show(polygons:), show(circle:), оставив единственный show(geoJSON:), данные для которого грузим из S3 уже в GeoJSON формате.

Полностью исключили операции над объектами [[CLLocationCoordinate2D]] и [String: [String]], что сейчас можно представить в виде такой схемы.

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

Что я отметил после перехода на GeoJSON:

  • Создание и редактирование областей –– удобный процесс благодаря использованию сторонних редакторов, таких как geojson.io.

  • Мобильный клиент поддерживает отображение любого валидного объекта –– доработок от разработчика не потребуется.

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

  • Чтобы подключить нового провайдера, не нужно пытаться реализовать show(circle:) и другие специфические методы или оставлять их пустыми, так как мы подчистили MapProviderProtocol.

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

После работы над этой задачей, мне кажется, мы смогли дёшево упростить значительный спектр задач по работе с нашей core фичей ?.