YandexMapKit и SwiftUI: обратный геокодинг

Дано

  • Разработка мобильного приложения под IOS для мониторинга автотранспорта

  • Минимальная версия IOS — 15.5, SwiftUI

  • Использование YandexMapKit для отображения местоположения автомобилей на карте

  • Данные получаем по API с использованием GRPC

Цель

  • Необходимо предоставить информацию об истории поездок с отображением адресов начала и окончания движения

  • Обратный геокодинг (получение адреса по известным географическим координатам) нужно выполнять на стороне клиента, что обусловлено особенностями лицензирования YandexMapKit

Примечание

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

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

Решение

Для достижения нашей цели определим класс GeoCoder и его инстанс для исключения дубликатов объектов нашего класса:

import SwiftUI
import Combine
import YandexMapsMobile

final class GeoCoder: ObservableObject {
    static let shared = GeoCoder()
  
    // MARK: - Variables
    private lazy var searchManager = YMKSearch.sharedInstance().createSearchManager(with: .online)
    private var searchSession: YMKSearchSession?
    private var searchSessions: [YMKSearchSession] = []

  ...
}

Количество запросов на геокодирование ограничено лицензией YandexMapKit, поэтому мы будем кэшировать результаты геокодирования на устройстве, чтобы при каждом вызове метод сначала проверял кэш. Способ кеширования вы можете выбрать сами. Ввиду относительно небольшого размера кэша я избрал самый простой — @AppStorage

  // Хранилище кэша
  @AppStorage("geoCache")  var geoCache: [String: String] = [:]

Однако для работы Dictionary[String: String] необходим extension:

extension Dictionary: RawRepresentable where Key == String, Value == String {
    public init?(rawValue: String) {
        guard let data = rawValue.data(using: .utf8),
              let result = try? JSONDecoder().decode([String:String].self, from: data)
        else {
            return nil
        }
        self = result
    }
    
    public var rawValue: String {
        guard let data = try? JSONEncoder().encode(self),
              let result = String(data: data, encoding: .utf8)
        else {
            return "{}"  // пустой Dictionary представляем как String
        }
        return result
    }
    
}

Теперь определяем сам метод.
Обратите внимание, что для логирования я использую функцию writeLog (_ logText: String, logLevel: OSLogType = .default), работу которой демонстрировать не буду. Там используется OSLog и Crashlytics от Google. Вы можете использовать методы логирования своего проекта.

// MARK: - Methods
    func submitSearch(with point: YMKPoint, completion: @escaping () -> Void) {
        //проверяем кэш
        if let address = self.geoCache["\(point.latitude)_\(point.longitude)"] {
            writeLog("GeoCoder->submitSearch(): Already cached \(address)")
            completion()
        } else {
            //в кэше точку не нашли, отправляем запрос на обратное геокодирование
            let searchSession = searchManager.submit(
                with: point,
                zoom: 16,
                searchOptions: YMKSearchOptions(),
                responseHandler:  { response, error in
                    // обработка ошибок               
                    if let error = error {
                        writeLog("GeoCoder->submitSearch(): ERROR \(error.localizedDescription)", logLevel: .error)
                        completion()
                    }
                    //обработка результата
                    guard let response = response else {
                        return completion()
                    }
                    
                    if let obj = response.collection.children.first?.obj?.metadataContainer.getItemOf(YMKSearchToponymObjectMetadata.self) {
                        let object = obj as! YMKSearchToponymObjectMetadata
                        // Сохраняем даные в кэше. 
                        self.geoCache["\(point.latitude)_\(point.longitude)"] = object.address.formattedAddress
                        writeLog("GeoCoder->submitSearch(): SUCCESS \(object.address.formattedAddress)")
                        completion()
                    }
                }
            )
            searchSessions.append(searchSession)
        }
    }

Использование

Не забываем про определение объекта класса

@ObservedObject var geoCoder = GeoCoder.shared

И про «подключение» его в Struct, который отвечает за отображение конкретной поездки:

import SwiftUI
import Kingfisher
import SwiftProtobuf
import YandexMapsMobile
struct TrackView: View {
  @StateObject var geoCoder = GeoCoder.shared
  @State var track: API_V1_TrackInfo
  @State var startAddress: String
  @State var endAddress: String
  init(track: API_V1_TrackInfo) {
        self.track = track
        self.startAddress = "\( String(track.beginPoint.latitude)), \(String(track.beginPoint.longitude))"
        self.endAddress = "\( String(track.endPoint.latitude)), \(String(track.endPoint.longitude))"
  }
  var body: some View {
    
    ...
    
    LazyVStack {
      VStack(alignment: .leading){
          Text("\(track.begin.toLocaTimetring()) - \(startAddress)")
              .frame(maxWidth: .infinity, alignment: .leading)
              .multilineTextAlignment(TextAlignment.leading)
          Spacer()
          Text("\(track.end.toLocaTimetring()) - \(endAddress)")
              .frame(maxWidth: .infinity, alignment: .leading)
              .multilineTextAlignment(TextAlignment.leading)
          
      }
      .onAppear{
          let beginPoint = YMKPoint(latitude: track.beginPoint.latitude, longitude: track.beginPoint.longitude)
          let endPoint = YMKPoint(latitude: track.endPoint.latitude, longitude: track.endPoint.longitude)
          geoCoder.submitSearch(with: beginPoint , completion: {
              if let address = geoCoder.geoCache["\(beginPoint.latitude)_\(beginPoint.longitude)"] {
                  self.startAddress = address
                  
              }
              
          })
          geoCoder.submitSearch(with: endPoint , completion: {
              if let address = geoCoder.geoCache["\(endPoint.latitude)_\(endPoint.longitude)"] {
                  self.endAddress = address
              }
          })
      }
      .frame(maxWidth: .infinity)
      .padding(0)
  }.padding(0)  
    
  ...
    
  }
}

Результат

При открытии указаны координаты, которые заменяются адресами по мере загрузки

При открытии указаны координаты, которые заменяются адресами по мере загрузки

В результате наблюдаем следующие записи в логе:

Часть адресов уже есть в кэше, остальные геокодируем

Часть адресов уже есть в кэше, остальные геокодируем

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

© Habrahabr.ru