В прошлой статье я рассказывал о том, как можно быстро сделать Web-звонилку. А что если поставить более амбициозную задачу — собрать своё собственное приложение с картой, без рекламы и с блэк-джеком? А если всего за пару дней?
Давайте сделаем это! Прошу под кат.
Для начала разберёмся, что нам предстоит сделать. На выходе мы хотим получить приложение со справочными данными и картой. И чтоб работало офлайн. Как разработчика меня в первую очередь интересует как раз карта, ведь показывать справочные данные мы и так умеем. А офлайн — довольно сильное ограничение в этом случае, ведь хороших библиотек с поддержкой офлайна не так много. Поэтому в статье сконцентрируемся на карте, а про справочник поговорим вскользь.
Выбираем движок карты
Первое, что нужно сделать — добыть данные для приложения. На рынке много источников, бесплатных и не очень. Для старта нам вполне подойдёт OpenStreetMap как открытый источник картографических данных. Там же можно взять и какое-то количество POI для нашего справочника.
Следующий шаг — выбираем картодвижок. На просторах интернета их довольно мало, бесплатных ещё меньше, а с поддержкой офлайна вообще единицы. Предлагаю воспользоваться довольно крутым вариантом — mapsforge/vtm. Это векторный OpenGL движок, очень шустрый, поддерживает офлайн, Android, iOS, различные источники данных, кастомную стилизацию, оверлеи, маркеры, 3D и даже 3D-модели объектов! Очень, очень круто.
В репозитории достаточно много примеров для быстрого старта, есть готовые карты, есть плагин, позволяющий собрать собственную карту из данных в 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) {
Point layerXY = new Point();
mMap.viewport().toScreenPoint(pointOfInterest.position, false, layerXY);
Point tapXY = new Point(e.getX(), e.getY());
if (layerXY.distance(tapXY) > touchRadius) {
continue;
}
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. Правда, нам недостаточно будет стандартного SQLite, и придётся собирать свой, подключив к нему плагин RTree для геопоиска. Как это сделать, я уже рассказывал в статье, раздел «Делаем хороший поиск».
В этом случае мы получаем полный контроль над данными, можем сохранять всё, что требуется, и в нужном формате. Еще и Full Text Search сможем прикрутить и искать наши геообъекты и фирмы по названию, адресу и другим атрибутам.
Направление такое:
- Делаем таблицы:
- геообъектов (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 );
- Наполняем всё данными.
- При тапе в карту получаем GeoPoint и выполняем запрос:
SELECT id FROM geo_index WHERE minX>=-81.08 AND maxX<=-80.58 AND minY>=35.00 AND maxY<=35.44
- Последний шаг: фильтруем и выбираем подходящий объект.
Один из вариантов реализации можно посмотреть в репозитории.
В итоге мы уже умеем показывать карту и обрабатывать нажатия. Неплохо.
Добавляем важные мелочи
Давайте добавим пару важных функций.
Начнём с текущей геопозиции. В 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);
Пожалуй, мы готовы к релизу. Собираем билд, выкладываем в маркет и получаем заслуженные звёзды :)
Пара ложек дёгтя в огромной бочке мёда
Движок open source, развивается активно, но команда у него, прямо скажем, довольно скромная. По большому счёту это один человек под ником devemux86. И ещё пара ребят контрибьютят время от времени.
Порой встречаются артефакты в отрисовке, какие-то моргания и подёргивания. Но я ни разу не столкнулся с какими-то критическими проблемами и тем более падениями, что не может не радовать.
Есть еще один нюанс, который может не понравиться. Это отрисовка скруглений и окружностей. Пример того, как это выглядит, на скриншоте:
Если в исходной геометрии достаточно много точек (скругление гладенькая), то на карте вы можете увидеть довольно-таки «угловатую» окружность с множеством небольших выпуклостей и вогнутостей. Очевидно, это делается в угоду производительности и размеру map-файла, но выглядит не очень.
Пожалуй, это все минусы на сегодня. Вам решать, сможете вы с ними жить или нет. А мы тем временем используем эту библиотеку уже более 1,5 лет, полёт отличный, по крайней мере, на Андроиде.
Итоги
В этой статье я показал, что даже такую довольно нетривиальную задачу можно решить относительно быстро. Вы получили готовый скелет, с которым можно запрототипировать любой проект, подразумевающий использование офлайн-карты, за минимальное время.
Если возникнет интерес, в следующей статье покажу, как сделать этажи а-ля 2ГИС. И это на самом деле гораздо проще, чем кажется :)