Хабрахабр

Поиск в MapKit: Tips & Tricks

У неё есть официальная документация, которая уже содержит подробное описание методов API, поэтому сегодня мы поговорим о другом. MapKit — это программная библиотека, которая позволяет использовать картографические данные и технологии Яндекса в мобильных приложениях.

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

TL;DR Если не хотите читать всю статью, то вот два самых полезных пункта в качестве компенсации за чтение предисловия:

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

Ссылки на документацию в тексте будут для Android, классы и методы для iOS называются аналогично.

Что умеет поиск

Поиск умеет то, чего вы примерно ждёте от приложения с картами, когда хотите там что-то найти. Прежде всего, поговорим о том, что умеет поиск в MapKit.

Это самый навороченный вид поиска. Когда вы набираете в строке поиска «кафе», «улица Льва Толстого, 16» или «трамвай 3», то работает поиск по тексту. Можно попробовать сразу поискать вдоль маршрута или интересной вам улицы, уточнить желаемое количество результатов, задать позицию пользователя и так далее. Навороченный в том смысле, что он поддерживает максимальный набор параметров для настройки. Если после первого поиска вы захотели подвинуть карту или применить фильтры к запросу («аптеки с бассейном») — это перезапросы.

Он позволяет по нажатию на карту определить, какая улица или дом находится «под курсором» или какие организации есть рядом с этой точкой. Обратный поиск знаком большинству пользователей по вопросу «Что здесь?». Он может использоваться, например, для создания закладок в приложении. Поиск по URI нужен, когда вы хотите найти конкретный объект. Ни обратный поиск, ни поиск по URI не поддерживают перезапросы, потому что для них нечего уточнять. Нашли любимую кофейню, пометили звёздочкой — в следующий раз можно будет найти по URI именно эту организацию, где бы ни было расположено окно карты.

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

Как устроен запрос

Основной объект для работы с этой асинхронностью — поисковая сессия. Поиск, как и многие части MapKit, работает асинхронно. Посмотрим на небольшой пример.

Немного о примерах

У MapKit есть демо-приложение. Примеры в статье будут на Kotlin, чтобы было проще работать с опциональными значениями и boilerplate-кода было поменьше. showMessage, который время от времени появляется в коде, это любой удобный вам способ вывести строку текста на экран или в лог. Его можно использовать для проверки примеров, но для этого SearchActivity стоит преобразовать из Java в Kotlin.

// `searchManager` и `searchSession` – это поля. Менеджер нет смысла
// создавать на каждый запрос, а сессию просто нужно сохранять.
searchManager = SearchFactory.getInstance().createSearchManager( SearchManagerType.ONLINE
)
val point = Geometry.fromPoint(Point(59.95, 30.32))
searchSession = searchManager!!.submit("кафе", point, SearchOptions(), object: Session.SearchListener override fun onSearchResponse(p0: Response) { showMessage("Success") } }
)

Сразу после вызова submit управление вернётся к вашему коду, а когда MapKit получит ответ от сервера, то будет вызван SearchListener.

Поисковая сессия позволяет:

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

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

Search Options

Общий способ настройки запросов в поиск – это класс SearchOptions, который позволяет изменять параметры запроса.

  • Главный из этих параметров – SearchType. Он позволяет задать, хотите вы видеть в ответе топонимы, организации или транспорт (остальные типы вам, скорее всего, не понадобятся).
  • Другой важный параметр запросов – это сниппеты. Про них мы поговорим подробнее в разделе об устройстве ответа.
  • Если вы хотите получить геометрию топонима (например улицы или области), то нужно заказать её через setGeometry(true). Имейте в виду, что геометрия достаточно "тяжёлая" в плане передаваемых данных.
  • По умолчанию поиск не возвращает закрытые (временно или постоянно) организации, но если вам они нужны, то нужно выставить setSearchClosed(true).

Обратите внимание, что не все запросы поддерживают все комбинации параметров. Кроме перечисленных параметров есть ещё некоторые, которые могут вам пригодиться, их можно найти в документации к классу. В документации к каждому методу SearchManager или Session указано, какие параметры из SearchOptions он понимает.

Как устроен ответ

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

public class Response { public synchronized SearchMetadata getMetadata(); public synchronized GeoObjectCollection getCollection(); // ...
}

Если заглянуть внутрь GeoObjectCollection то можно увидеть, что в ней лежат какие-то Item-ы, которые могут быть или другими GeoObjectCollection или GeoObject-ами. Здесь getCollection() возвращает объекты в ответе, а getMetadata() – некоторые дополнительные данные, в которых есть, например, информация об окне ответа, типе ранжирования и количестве найденных результатов.

Внутри объекта есть имя (getName()), описание (getDescriptionText()), рамка (getBoundingBox()), набор геометрий (getGeometry()) и ещё несколько не очень понятных методов. Коллекций внутри коллекций в поиске не бывает (по крайней мере, пока), поэтому давайте посмотрим на GeoObject. Как понять, к какому городу относится топоним? Где номера телефонов у организации?

По методам объекта это понятно не очень.

GeoObject

Настало время подробнее рассказать про GeoObject.

Внутри него может быть дорожное событие, отдельный объект из результата поиска, манёвр в маршруте или объект на карте (POI), такой как памятник или какая-то приметная организация. GeoObject это такой базовый "карточный" объект.

Доступ к ним можно получить с помощью метода getMetadataContainer(). Всё самое интересное про объект хранится в метаданных. Если вы видите в документации что-то, что заканчивается словом Metadata, то вам, скорее всего, сюда. Ключом в этом контейнере является тип метаданных. В поиске разных "метадат" штук 15.

Первый тип – это метаданные, которые определяют к какому типу относится объект: топонимы (ToponymObjectMetadata), организации (BusinessObjectMetadata) или транспорт (TransitObjectMetadata). Метаданные можно разделить на несколько типов. В метаданных для организации – часы работы или сайт компании. В метаданных для топонима можно найти структурированный адрес и подробную геометрию. Если искали топонимы или организации, то у каждого объекта будет хотя бы одна из двух «метадат». Эти метаданные определяются типом поиска в запросе – если вы искали только топонимы, то у каждого объекта в ответе должны быть соответствующие метаданные.

Вот как можно найти номера телефонов для компании:

val phones = response.collection.children.firstOrNull()?.obj ?.metadataContainer ?.getItem(BusinessObjectMetadata::class.java) ?.phones

А вот так найти город в структурированном адресе:

val city = response.collection.children.firstOrNull()?.obj ?.metadataContainer ?.getItem(ToponymObjectMetadata::class.java) ?.address ?.components ?.firstOrNull { it.kinds.contains(Address.Component.Kind.LOCALITY) } ?.name

Главный тип, про который здесь надо знать, – URIObjectMetadata. Второй тип – метаданные, которые приезжают вместе с объектом, хотя вы об этом и не просили. Внутри URIObjectMetadata хранится уникальный идентификатор объекта, который нужно передавать в поиск по URI.

// Вот это значение нужно сохранить в «закладках», чтобы потом по нему
// можно было найти объект
val uri = response.collection.children.firstOrNull()?.obj ?.metadataContainer ?.getItem(UriObjectMetadata::class.java) ?.uris ?.firstOrNull() ?.value

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

val point = Geometry.fromPoint(Point(59.95, 30.32))
val options = SearchOptions()
options.snippets = Snippet.FUEL.value
searchSession = searchManager!!.submit("АЗС", point, options, this) ... override fun onSearchResponse(response: Response) { // Получаем информацию о доступном топливе для первого объекта showMessage(response.collection.children.firstOrNull()?.obj ?.metadataContainer ?.getItem(FuelMetadata::class.java) ?.fuels ?.joinToString("\n") { "Fuel(name=${it.name}, price=${it.price?.text})" } ?: "No fuel" )
}

Ещё есть метаданные, которые добавляются к ответу целиком. Все метаданные, перечисленные выше, добавляются к отдельным объектам в ответе. Но они вынесены в методы SearchMetadata и их не нужно извлекать из какой-то специальной коллекции.

// Так можно получить список рубрик для ответа от поиска по организациям
response.metadata.businessResultMetadata?.categories // А так режим поиска (прямой или обратный) для поиска по топонимам
response.metadata.toponymResultMetadata?.responseInfo?.mode

Примеры использования

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

Поиск по тексту

Главный метод для поиска по тексту (да и для всего поиска, наверное) – это submit:

Session submit( String text, Geometry geometry, SearchOptions searchOptions, SearchListener searchListener
);

  • Параметр text ожидаемо содержит тот текст, который вы хотите поискать.
  • Параметр geometry чуть более хитрый. В зависимости от того, какая именно геометрия передана, поиск будет вести себя по-разному:
    • Если передать точку, то поиск будет производиться в небольшом окне рядом с этой точкой.
    • Если передать прямоугольное окно (BoundingBox) или полигон из четырёх точек, то оно будет использовано как окно поиска. Простой пример такого окна – видимая область карты.
    • Наконец, если передать полилинию, то описывающее её окно будет использовано как окно поиска, а ранжирование будет производиться с учётом этой полилинии.
  • Про SearchOptions и SearchListener мы уже говорили выше.

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

У метода submit есть брат-близнец submit:

Session submit( String text, Polyline polyline, Geometry geometry, SearchOptions searchOptions, SearchListener searchListener
);

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

Перезапросы

Часть методов у сессии простая и понятная: Перезапросы, в отличие от остальных типов запросов, делаются с помощью поисковой сессии, которую возвращает тот самый submit и его брат-близнец.

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

Фильтры

Фильтры — это когда «Wi-Fi» и «итальянская кухня». Раз уж мы заговорили о фильтрах. Связано это с тем, что одни и те же структуры данных используются и для получения фильтров из ответа поиска и для задания фильтров в перезапросе. У них, наверное, самый запутанный синтаксис из всех интерфейсов поиска в MapKit.

Boolean-фильтры предполагают только два взаимоисключающих значения – «есть» или «нет». Фильтры бывают двух типов. Enum-фильтры предполагают множество значений, которые могут запрашиваться вместе. Это может быть наличие Wi-Fi в кафе, туалета на заправке или парковки рядом с организацией. Это, например, тип кухни для кафе или виды топлива на заправке.

Давайте для начала посмотрим, как получить фильтры, доступные для текущего перезапроса:

private fun filters(response: Response): String? { fun enumValues(filter: BusinessFilter) = filter .values .enums ?.joinToString(prefix = " -> ") { e -> e.value.id } ?: "" return response .metadata .businessResultMetadata ?.businessFilters ?.joinToString(separator = "\n") { f -> "${f.id}${enumValues(f)}" }
}

Теперь, вооружённые знанием о доступных идентификаторах, будем искать те самые кафе итальянской кухни, в которых есть Wi-Fi. В получившейся строке для boolean-фильтров будет показан только идентификатор, а для enum-фильтров идентификатор самого фильтра и идентификаторы доступных значений. Сначала добавим boolean-фильтр:

val boolFilter = BusinessFilter( /* id= */ "wi_fi", /* name= */ "", /* disabled= */ false, /* values= */ BusinessFilter.Values.fromBooleans( listOf(BusinessFilter.BooleanValue(true, true)) )
)

Теперь enum-фильтр:

val enumFilter = BusinessFilter( /* id= */ "type_cuisine", /* name= */ "", /* disabled= */ false, /* values= */ BusinessFilter.Values.fromEnums( listOf(BusinessFilter.EnumValue( Feature.FeatureEnumValue( /* id= */ "italian_cuisine", /* name= */ "", /* imageUrlTemplate= */ "" ), true, true )) )
)

И, наконец, можно добавить фильтры к сессии и позвать resubmit():

searchSession!!.setFilters(listOf(boolFilter, enumFilter))
searchSession!!.resubmit(this)

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

Дополнительные результаты

И, если они есть, получить их. Ещё сессия позволяет проверить, есть ли дополнительные результаты поиска по вашему запросу. Пара методов hasNextPage и fetchNextPage нужна, чтобы просматривать следующие страницы списка. Например, когда вы ищете кафе в своём городе, скорее всего, все они не поместятся на одну страницу поискового ответа. И во-вторых, использование этих методов подразумевает, что остальные параметры не меняются. Тут нужно знать, что во-первых, вызов fetchNextPage приведёт к исключению, если метод hasNextPage вернул false. Совмещать эти режимы не нужно. То есть, сессия используется либо для уточнения запроса (resubmit()), либо для получения следующих страниц (fetchNextPage()).

Обратный поиск

Обратный поиск для удобства тоже называется submit:

Session submit( Point point, Integer zoom, SearchOptions searchOptions, SearchListener searchListener
)

Либо вы передаёте тип GEO и ищете топонимы, либо тип BIZ и ищете организации. Он отличается от остальных видов запросов тем, что требует на вход только один тип поиска. Третьего не дано.

Обратите внимание на то, что в ответе будет несколько объектов по иерархии (то есть в ответе будет дом, улица, город, и так далее). При обратном поиске с типом GEO есть моменты, которые требуют пояснений. В более сложных, искать по иерархии нужный. В простых случаях можно брать просто первый объект.

Представьте, что пользователь смотрит на карту в масштабе страны. Уровень масштабирования (zoom) нужен, чтобы выдавать адекватные результаты в зависимости от того, что пользователь видит на карте. Вполне достаточно городов. Тогда будет странно ему по нажатию показывать отдельную улицу или дом, если пользователю случайно удалось в них попасть. Вот для этого и предназначен параметр zoom.

val point = Point(55.734, 37.588) // При таком запросе первый объект в ответе будет «улица Льва Толстого, 16»
searchSession = searchManager!!.submit(point, 16, SearchOptions(), this) // А при таком – уже "район Хамовники"
searchSession = searchManager!!.submit(point, 14, SearchOptions(), this)

Поиск по URI

Здесь всё достаточно понятно – берём URI из URIObjectMetadata, запоминаем, через некоторое время приходим в поиск и по этому URI получаем ровно тот объект, который запомнили.

searchSession = searchManager!!.resolveURI(uri, SearchOptions(), this)

Как-то даже скучно.

Поисковый слой и светлое будущее

Слой задумывался для объединения поиска с картой. Рядом с SearchManager ещё есть штука под названием поисковый слой. Во многом он похож на объединённый SearchManager и Session, но встроенная работа с картой добавляет новых особенностей. Он сам умеет добавлять на неё результаты, передвигать карту так, чтобы эти результаты показать и делать перезапросы, когда пользователь двигает карту. На момент выхода MapKit 3. И разговор о них выходит за рамки этой статьи. Возможно, он сделает вашу работу с поиском проще. 1 мы уже успели обкатать поисковый слой в реальных приложениях, так что можете попробовать использовать его у себя.

Заключение

Наверняка остались ещё какие-то тонкие и нетривиальные моменты (например, мы почти не говорили о подсказках и поисковом слое). Надеюсь, что после прочтения статьи у вас появилось понимание, как работать с поиском в MapKit в полную силу. Что-то можно найти в документации, что-то уточнить в проектах на GitHub или спросить у нашей поддержки.

Пробуйте MapKit, используйте в нём поиск и приходите в Карты делать их ещё лучше!

S. А ещё приходите к нам в гости 29 ноября послушать про то, как устроен бэкенд автомобильной маршрутизации. P. Которую, кстати, тоже можно использовать в MapKit, но это уже совсем другая история.

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

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

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

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

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