Хабрахабр

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

А что если поставить более амбициозную задачу — собрать своё собственное приложение с картой, без рекламы и с блэк-джеком?
В прошлой статье я рассказывал о том, как можно быстро сделать Web-звонилку. А если всего за пару дней?

Прошу под кат. Давайте сделаем это!

На выходе мы хотим получить приложение со справочными данными и картой. Для начала разберёмся, что нам предстоит сделать. Как разработчика меня в первую очередь интересует как раз карта, ведь показывать справочные данные мы и так умеем. И чтоб работало офлайн. Поэтому в статье сконцентрируемся на карте, а про справочник поговорим вскользь. А офлайн — довольно сильное ограничение в этом случае, ведь хороших библиотек с поддержкой офлайна не так много.

Выбираем движок карты

На рынке много источников, бесплатных и не очень. Первое, что нужно сделать — добыть данные для приложения. Там же можно взять и какое-то количество POI для нашего справочника. Для старта нам вполне подойдёт OpenStreetMap как открытый источник картографических данных.

На просторах интернета их довольно мало, бесплатных ещё меньше, а с поддержкой офлайна вообще единицы. Следующий шаг — выбираем картодвижок. Это векторный OpenGL движок, очень шустрый, поддерживает офлайн, Android, iOS, различные источники данных, кастомную стилизацию, оверлеи, маркеры, 3D и даже 3D-модели объектов! Предлагаю воспользоваться довольно крутым вариантом — mapsforge/vtm. Очень, очень круто.

Итак, приступаем! В репозитории достаточно много примеров для быстрого старта, есть готовые карты, есть плагин, позволяющий собрать собственную карту из данных в OSM формате.

MapView mapView = findViewById(R.id.map_view);
this.map = mapView.map(); File baseMapFile = getMapFile("cyprus.map");
MapFileTileSource tileSource = new MapFileTileSource();
tileSource.setMapFile(baseMapFile.getAbsolutePath()); VectorTileLayer layer = this.map.setBaseMap(tileSource); MapInfo info = tileSource.getMapInfo();
if (info != null) { MapPosition pos = new MapPosition(); pos.setByBoundingBox(info.boundingBox, Tile.SIZE * 4, Tile.SIZE * 4); this.map.setMapPosition(pos);
} this.map.setTheme(VtmThemes.DEFAULT); this.map.layers().add(new BuildingLayer(this.map, layer));
this.map.layers().add(new LabelLayer(this.map, layer));

Дополнительно позиционируемся в центр интересующего нас баундинг-бокса, чтоб не оказаться где-то за пределами выбранной локации при старте приложения. Создаём источник данных MapFileTileSource, указываем местонахождение файла карты. Добавляем слой домов и слой подписей. Устанавливаем дефолтную тему. Запускаем — чудеса! На этом всё.

Кажется, быстрее и проще и быть не может.

Делаем геокодинг

Сама по себе карта — это уже неплохо, но нужна интерактивность. Следующий важный шаг — реализация геокодинга. И здесь есть некоторая сложность. Мы хотим тапать в карту и видеть информацию по объекту, в который попали. Это, пожалуй, самый большой её минус. По большому счёту, полноценный геокодинг в нашей библиотеке отсутствует. Если ничего не изобретать, то мы можем воспользоваться имеющейся функциональностью.

// Определяем координаты клика и находим тайлы в его зоне
float touchRadius = TOUCH_RADIUS * CanvasAdapter.getScale();
long mapSize = MercatorProjection.getMapSize((byte) mMap.getMapPosition().getZoomLevel());
double pixelX = MercatorProjection.longitudeToPixelX(p.getLongitude(), mapSize);
double pixelY = MercatorProjection.latitudeToPixelY(p.getLatitude(), mapSize);
int tileXMin = MercatorProjection.pixelXToTileX(pixelX - touchRadius, (byte) mMap.getMapPosition().getZoomLevel());
int tileXMax = MercatorProjection.pixelXToTileX(pixelX + touchRadius, (byte) mMap.getMapPosition().getZoomLevel());
int tileYMin = MercatorProjection.pixelYToTileY(pixelY - touchRadius, (byte) mMap.getMapPosition().getZoomLevel());
int tileYMax = MercatorProjection.pixelYToTileY(pixelY + touchRadius, (byte) mMap.getMapPosition().getZoomLevel());
Tile upperLeft = new Tile(tileXMin, tileYMin, (byte) mMap.getMapPosition().getZoomLevel());
Tile lowerRight = new Tile(tileXMax, tileYMax, (byte) mMap.getMapPosition().getZoomLevel()); //Получаем данные из базы, указав левый верхний и правый нижний тайлы
MapDatabase mapDatabase = ((MapDatabase) ((OverzoomTileDataSource) tileSource.getDataSource()).getDataSource());
MapReadResult mapReadResult = mapDatabase.readLabels(upperLeft, lowerRight); StringBuilder sb = new StringBuilder(); // Фильтруем полученные POI с учётом области клика
sb.append("*** POI ***");
for (PointOfInterest pointOfInterest : mapReadResult.pointOfInterests) sb.append("\n"); List<Tag> tags = pointOfInterest.tags; for (Tag tag : tags) { sb.append("\n").append(tag.key).append("=").append(tag.value); }
} // Фильтруем геометрии, попавшие в область клика
sb.append("\n\n").append("*** WAYS ***");
for (Way way : mapReadResult.ways) { if (way.geometryType != GeometryBuffer.GeometryType.POLY || !GeoPointUtils.contains(way.geoPoints[0], p)) { continue; } sb.append("\n"); List<Tag> tags = way.tags; for (Tag tag : tags) { sb.append("\n").append(tag.key).append("=").append(tag.value); }
}

Нужно найти тайл, получить ways (в терминологии OSM way — это линейный объект), и можно из них извлечь какую-то атрибутику. Получилось относительно многословно. Остальную логику придется накручивать самостоятельно: выбирать «правильный» из всего множества объектов, в которые попал клик, фильтровать по зум-левелам. Помимо ways есть возможность получить ещё и POI, но на этом всё. Фактически, мы теряем информацию об исходной геометрии и получаем в ответ на поиск просто набор линий. И ещё один момент. Если захочется сделать ещё и гео-редактор, то этого явно будет недостаточно.

Но для демонстрации подхода нас всё устраивает.

«Продвинутый» геокодинг

Для этого нам понадобится своя база. Вообще говоря, есть более продвинутый вариант. Правда, нам недостаточно будет стандартного SQLite, и придётся собирать свой, подключив к нему плагин RTree для геопоиска. В частности, можно воспользоваться SQLite. Еще и Full Text Search сможем прикрутить и искать наши геообъекты и фирмы по названию, адресу и другим атрибутам. Как это сделать, я уже рассказывал в статье, раздел «Делаем хороший поиск».
В этом случае мы получаем полный контроль над данными, можем сохранять всё, что требуется, и в нужном формате.

Направление такое:

  1. Делаем таблицы:
    • геообъектов (id, type, geometry, attributes)
    • фирм (id, attributes, geo_id) со ссылкой на геометрию здания, в котором она находится
    • геоиндекса на rtree вот так:

      CREATE VIRTUAL TABLE geo_index USING rtree(
      id, -- Integer primary key
      minX, maxX, -- Minimum and maximum X coordinate
      minY, maxY -- Minimum and maximum Y coordinate
      );

  2. Наполняем всё данными.
  3. При тапе в карту получаем GeoPoint и выполняем запрос:

    SELECT id FROM geo_index
    WHERE minX>=-81.08 AND maxX<=-80.58 AND minY>=35.00 AND maxY<=35.44

  4. Последний шаг: фильтруем и выбираем подходящий объект.

Один из вариантов реализации можно посмотреть в репозитории.

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

Добавляем важные мелочи

Давайте добавим пару важных функций.

В mapsforge/vtm для этого как раз имеется спец. Начнём с текущей геопозиции. Использование крайне простое. слой LocationLayer.

LocationLayer locationLayer = new LocationLayer(this.map);
locationLayer.setEnabled(true); // Позицию выставляем в центр карты для простоты, вообще, её надо получить с GPS
GeoPoint initialGeoPoint = this.map.getMapPosition().getGeoPoint();
locationLayer.setPosition(initialGeoPoint.getLatitude(), initialGeoPoint.getLongitude(), 1);
this.map.layers().add(locationLayer);

Скорее всего, в процессе использования вы редко будете оказываться в такой ситуации, но это вызывает постоянный перерендеринг, соответственно, немного нагружает процессор. Есть только один недостаток — это постоянная пульсация «синей точки» на границе экрана, когда текущая локация находится за пределами карты. Но это уже совсем для перфекционистов. Избавиться от этого немного сложнее, нужно залезть в шейдер и поправить его. Как сделать — можно посмотреть тут.

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

View vLocation = findViewById(R.id.am_location);
vLocation.setOnClickListener(v -> this.map.animator().animateTo(initialGeoPoint));

Ещё нам понадобятся кнопки зума.

View vZoomIn = findViewById(R.id.am_zoom_in);
vZoomIn.setOnClickListener(v -> this.map.animator().animateZoom(500, 2, 0, 0)); View vZoomOut = findViewById(R.id.am_zoom_out);
vZoomOut.setOnClickListener(v -> this.map.animator().animateZoom(500, 0.5, 0, 0));

И вишенка на торте — компас.

View vCompass = findViewById(R.id.am_compass);
vCompass.setVisibility(View.GONE);
vCompass.setOnClickListener(v -> { MapPosition mapPosition = this.map.getMapPosition(); mapPosition.setBearing(0); this.map.animator().animateTo(500, mapPosition); vCompass.animate().setListener(new Animator.AnimatorListener() { @Override public void onAnimationStart(Animator animation) { } @Override public void onAnimationEnd(Animator animation) { vCompass.setVisibility(View.GONE); } @Override public void onAnimationCancel(Animator animation) { } @Override public void onAnimationRepeat(Animator animation) { } }).setDuration(500).rotation(0).start();
}); this.map.events.bind((e, mapPosition) -> { if (e == Map.ROTATE_EVENT) { vCompass.setRotation(mapPosition.getBearing()); vCompass.setVisibility(View.VISIBLE); }
});

Захватываем мир

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

Это по сути враппер для любых других источников тайлов, который позволяет отображать на карте сразу всё, что в него добавлено. И дела обстоят так, что с нашим движком это намного проще, чем кажется.
Нам нужно немного модифицировать метод загрузки карты, добавив в него MultyMapTileSource. В итоге нам остаётся подготовить карту мира с минимальной детализацией, добавить её самой первой в наш враппер, а поверх рисовать всё остальное. Просто киллер-фича. Шикарно, просто шикарно. Более того, мы можем сразу добавить все карты, какие у нас есть в каталоге с картами приложения! И не забываем, что это офлайн 🙂

// Создаём мульти-источник
MultiMapFileTileSource mmtilesource = new MultiMapFileTileSource(); File baseMapFile = getMapFile("cyprus.map");
MapFileTileSource tileSource = new MapFileTileSource();
tileSource.setMapFile(baseMapFile.getAbsolutePath());
mmtilesource.add(tileSource); // Добавляем все источники в MultiMapFileTileSource MapFileTileSource worldTileSource = new MapFileTileSource(); File worldMapFile = getMapFile("world.map");
worldTileSource.setMapFile(worldMapFile.getAbsolutePath());
mmtilesource.add(worldTileSource); // В качестве базовой карты используем мульти-источник
VectorTileLayer layer = this.map.setBaseMap(mmtilesource);

Собираем билд, выкладываем в маркет и получаем заслуженные звёзды 🙂 Пожалуй, мы готовы к релизу.

Пара ложек дёгтя в огромной бочке мёда

По большому счёту это один человек под ником devemux86. Движок open source, развивается активно, но команда у него, прямо скажем, довольно скромная. И ещё пара ребят контрибьютят время от времени.

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

Это отрисовка скруглений и окружностей. Есть еще один нюанс, который может не понравиться. Пример того, как это выглядит, на скриншоте:

Очевидно, это делается в угоду производительности и размеру map-файла, но выглядит не очень. Если в исходной геометрии достаточно много точек (скругление гладенькая), то на карте вы можете увидеть довольно-таки «угловатую» окружность с множеством небольших выпуклостей и вогнутостей.

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

Итоги

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

И это на самом деле гораздо проще, чем кажется 🙂 Если возникнет интерес, в следующей статье покажу, как сделать этажи а-ля 2ГИС.

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть