
Предисловие (можно пропустить)
Привет! Это мой первый пост на Хабре, буду рад услышать профессиональное и не очень мнение по поводу этой статьи. Я мобильный разработчик (таковым себя считаю) с опытом работы около года. В этой статье будет рассмотрено возможное решение проблемы, с которой вы можете столкнуться в процессе освоения Compose Multiplatform. Статья не претендует на истину в последней инстанции и тем более не является прямой инструкцией к выполнению. Вы всегда можете придумать свое, более эффективное и красивое решение, я лишь делюсь собственным опытом разработки.
Результат работы в конце
Что мы хотим?
В одном проекте, который мы решили делать полностью с использованием Compose Multiplatform, была поставлена задача реализовать работу Яндекс Карт. Приложение для сети сервисных центров, поэтому на карте должны отображаться метки СТО, а также собственная метка пользователя для вызова мастера на место.

Библиотека MapKit представлена как для Android, так и для iOS. На сайте приемлемая документация, в которой несложно разобраться, но больше всего в ходе работы мне помогли примеры с официальных репозиториев для iOS и Android с различными семплами.
Возможные способы реализации:
1. Использовать native cocoapods
С самого начала я попробовал проделать стандартную процедуру из документации Kotlin. Подключаем Pod прямо в описании build.gradle, можем даже указать название пакета или еще какие-нибудь флаги cinterop. Под капотом всей этой темы работает cinterop. Он прочтет заголовки уже скомпилированной библиотеки Objective-C и создаст нам klib файлы, которые позволяют легко "трогать" нужный нам функционал, не покидая common module. Кстати, в документации Kotlin даже в качестве примера используется YandexMapsMobile.

// build.gradle.kts
kotlin {
ios()
cocoapods {
summary = "CocoaPods test library"
homepage = "https://github.com/JetBrains/kotlin"
ios.deploymentTarget = "13.5"
pod("YandexMapsMobile") {
version = "4.4.0-lite"
}
}
}
// iosMain/*.kt
import cocoapods.YandexMapKit.*
Этот способ не сработал. Конкретно сама библиотека скомпилировалась, линковщик отработал, импорты тоже, даже установка токена работала. Но вот, как только в проекте появился следующий импорт:
import cocoapods.YandexMapsMobile.YMKMapView
То сразу посыпались ошибки линковщика: Undefined symbols: "OBJC_CLASS на-на-на
Проблема была вызвана именно OpenGL зависимостью, которую карты используют для отрисовки, поэтому линковщик выдает исключение, когда импортируется представление карты. Так как проект уже задерживался, нужно было в срочном порядке придумать другое решение.
2. Framework and custom library
Можно самостоятельно скомпилировать библиотеку YandexMapsMobile в framework, включая все зависимости и подключить, что называется, вручную. Данный способ тоже описан в документации и довольно широко применяется. Отличие от предыдущего метода заключается лишь в том, что мы просто собираем framework сами, а не возлагаем эту ношу на cocoapods.
"А как же мне достать framework, а не pod?" - подумал я. Оказалось все довольно просто. Получить .framework библиотеки можно несколькими способами:
Найти на официальной странице разработчиков
Найти на неофициальной странице от хороших людей
Собрать своими ручками проект с pod зависимостью и достать framework из DerivedData (тут ссылки нет – своими ручками все)
После того, как задача с .framework решена, можно приступать к его подключению в проект.
Кидаем куда-нибудь в проект ваш .framework
Создаем .def файл с информацией о нашем framework
Пишем конфигурацию в build.gradle.kts и радуемся
или нет
// build.gradle.kts
kotlin {
sourceSets {
val myYandexMapsMobileDefFilePath = "$projectDir/src/nativeInterop/cinterop/YandexMapsMobile.def"
val myYandexMapsMobileCompilerLinkerOpts = "-F${projectDir}/../iosApp/"
val myYandexMapsMobileIncludeDirs = "$projectDir/../iosApp/Pods/YandexMapsMobile"
iosArm64 {
compilations.getByName("main") {
val YandexMapsMobile by cinterops.creating {
// Path to .def file
packageName("cocoapods.YandexMapsMobile")
defFile(myYandexMapsMobileDefFilePath)
includeDirs(myYandexMapsMobileIncludeDirs)
compilerOpts(myYandexMapsMobileCompilerLinkerOpts)
}
}
binaries.all {
// Tell the linker where the framework is located.
linkerOpts(myYandexMapsMobileCompilerLinkerOpts)
}
}
}
}
Как написать .def файл и добавить собственные linker options можете узнать в документации Kotlin multiplatform.
На этом этапе ошибка не пропала, поэтому пришлось решать задачу "обходным путем".
План Б
Внимание, если у вас получилось все сделать с помощью предыдущих методов, то это отлично! Решение ниже актуально для меня.
Идея заключается в том, чтобы описать нужные функции представления карты в протоколе. Через Kotlin framework он передается в Swift (импорт из ComposeApp), там мы пишем реализацию на Swift (включая UIViewController
) и делаем инъекцию в DI, тем самым расшарив код для Kotlin. Далее уже в iosMain в Kotlin нужная реализация достается из DI, дополнительно настраивается и обертывается в UIKitView
.

Podfile (iosApp)
Представление самой карты написано на swift и используется библиотека, импортированная через pod. Kotlin framework тоже через pod добавлен (native cocoapods), поэтому pod я добавил, просто прописав в Podfile вручную.
# ../iosApp/Podfile
platform :ios, '14.1'
target 'iosApp' do
use_frameworks!
pod 'composeApp', :path => '../composeApp'
pod 'YandexMapsMobile', '4.4.0-lite' # Yandex Maps SDK
end
YandexMapView.kt (commonMain)
Для работы с представлением карты в проекте написана Composabel expect функция, она реализована в iosMain и в androidMain. Параметры естественно зависят от ваших потребностей.
@Composable
expect fun YandexMap(
modifier: Modifier = Modifier,
enabled: Boolean = true,
zoom: Float = 14f,
location: LatLng? = null,
startPosition: LatLng? = null,
points: List<PointMapModel>,
onPointClick: (id: Long) -> Unit,
customPosition: Boolean,
canSelectPosition: Boolean,
anotherLocationSelected: Boolean,
bottomFocusAreaPadding: Int,
onPositionSelected: (lat: Double, lng: Double) -> Unit,
onDragged: () -> Unit
)
Описание параметров (уникально для проекта)
enabled
– статус view (используется, если скрывается диалогом)zoom
– зум карты (не используется пока)location
– местоположение пользователя (latitude и longitude)startPosition
– стартовое положение камеры карты (запоминается)points
– список точек СТО (координаты, название и тд)onPointClick
– callback для выбора точки СТОcustomPosition
– выбранная пользователем точкаcanSelectPosition
– может ли пользователь сам выбрать точкуanotherLocationSelected
– статус (выбрана ли точка вручную)bottomFocusAreaPadding
– размер bottomsheet, перекрывающего картуonPositionSelected
– callback для выбора метку вручнуюonDragged
– callback если пользователь сдвинул карту (чтобы не следить за его местоположением)
YandexMapProtocol.kt (iosMain)
Я попробовал различными способами завернуть UIViewController
, но лучше ничего не получилось, чем его просто разместить как поле в протоколе.
interface YandexMapProtocol {
val viewController: UIViewController
fun addCameraListener(onDragged: () -> Unit)
fun addMapListener(onPositionSelect: (latitude: Double, longitude: Double) -> Unit)
fun addMapPointListener(onPointClick: (id: Long) -> Unit)
fun onMapStop()
fun onMapStart()
fun onMapMove(latLng: LatLng)
fun updateCustomPoint(latLng: LatLng? = null, visible: Boolean = true)
fun updateMyPoint(latLng: LatLng? = null, visible: Boolean = true)
fun updatePointsCollection(points: List<PointMapModel>)
fun setupFocusRect(bottomFocusAreaPadding: Int)
}
MapViewController.swift (iosApp)
Следующий код отвечает за реализацию представления с использованием YandexMapsMobile и UIKit. Для удобства можно размещать все классы по разным файлам, как это сделано в примерах, для статьи все занес в один файл.
MapViewController.swift
import Foundation
import YandexMapsMobile
import ComposeApp
import UIKit
class MapViewController: UIViewController {
private let locationMark: UIImage = UIImage(named: "location_mark")!
private let myLocatioPoint: UIImage = UIImage(named: "my_location_point")!
override func viewDidLoad() {
super.viewDidLoad()
mapView = YMKMapView(frame: view.frame)
YMKMapKit.sharedInstance().onStart()
view.addSubview(mapView)
map = mapView.mapWindow.map
addMyLocationPlacemark()
addCustomLocationPlacemark()
map.addCameraListener(with: mapCameraListener)
map.addInputListener(with: mapInputListener)
pinsCollection = map.mapObjects.add()
}
private func move(to cameraPosition: YMKCameraPosition) {
map.move(with: cameraPosition, animation: YMKAnimation(type: .smooth, duration: 0.2))
}
private func addMyLocationPlacemark() {
myPointPlacemark = map.mapObjects.addPlacemark()
myPointPlacemark.setIconWith(
myLocatioPoint,
style: {
let iconStyle = YMKIconStyle()
iconStyle.anchor = NSValue(cgPoint: CGPoint(x: 0.5, y: 0.5))
iconStyle.scale = 0.14
iconStyle.flat = true
return iconStyle
}()
)
}
private func addCustomLocationPlacemark() {
customPointPlacemark = map.mapObjects.addPlacemark()
customPointPlacemark.setIconWith(
locationMark,
style: {
let iconStyle = YMKIconStyle()
iconStyle.anchor = NSValue(cgPoint: CGPoint(x: 0.5, y: 1))
iconStyle.scale = 0.14
iconStyle.flat = false
return iconStyle
}()
)
}
private var mapView: YMKMapView!
private var map: YMKMap!
private var myPointPlacemark: YMKPlacemarkMapObject!
private var customPointPlacemark: YMKPlacemarkMapObject!
private var pinsCollection: YMKMapObjectCollection!
private lazy var mapCameraListener: MapCameraListener = MapCameraListener()
private lazy var mapInputListener: MapInputListener = MapInputListener()
private lazy var mapPointListener: MapPointListener = MapPointListener(controller: self)
func addCameraListener(onDragged: @escaping () -> Void) {
mapCameraListener.onDragged = onDragged
}
func addInputListener(onPositionSelect: @escaping (_ latitude: Double, _ longitude: Double) -> Void = {_, _ in }) {
mapInputListener.onPositionSelect = onPositionSelect
}
func addPointListener(onPointClick: @escaping (KotlinLong) -> Void) {
mapPointListener.onPointClick = { id, latLng in
self.mapMove(latLng: latLng)
onPointClick(id)
}
}
func startMap() {
YMKMapKit.sharedInstance().onStart()
}
func stopMap() {
YMKMapKit.sharedInstance().onStop()
}
func mapMove(latLng: LatLng) {
move(to: YMKCameraPosition(target: YMKPoint(latitude: latLng.latitude, longitude: latLng.longitude), zoom: 16.0, azimuth: 0.0, tilt: 0.0))
}
func updateMyPoint(latLng: LatLng?, visible: Bool) {
if myPointPlacemark != nil {
if latLng != nil {
myPointPlacemark.geometry = YMKPoint(latitude: latLng!.latitude, longitude: latLng!.longitude)
}
myPointPlacemark.isVisible = visible
}
}
func updateCustomPoint(latLng: LatLng?, visible: Bool) {
if customPointPlacemark != nil {
if latLng != nil {
customPointPlacemark.geometry = YMKPoint(latitude: latLng!.latitude, longitude: latLng!.longitude)
}
customPointPlacemark.isVisible = visible
}
}
func updateFocusArea(bottomFocusAreaPadding: Int) {
let yVal = Float(mapView.mapWindow.height() - (bottomFocusAreaPadding as Int))
mapView.mapWindow.focusRect = YMKScreenRect(
topLeft: YMKScreenPoint(x: 0, y: 0),
bottomRight: YMKScreenPoint(
x: Float(mapView.mapWindow.width()),
y: yVal < 0 ? 0 : yVal
)
)
}
func updatePointsCollection(points: [PointMapModel]) {
pinsCollection.clear()
if pinsCollection != nil {
points.forEach { point in
let pin = pinsCollection.addPlacemark()
pin.geometry = YMKPoint(latitude: point.latLng.latitude, longitude: point.latLng.longitude)
pin.setIconWith(
locationMark,
style: {
let iconStyle = YMKIconStyle()
iconStyle.anchor = NSValue(cgPoint: CGPoint(x: 0.5, y: 1))
iconStyle.scale = 0.14
iconStyle.flat = false
return iconStyle
}()
)
pin.setTextWithText(
point.name,
style: {
let textStyle = YMKTextStyle()
textStyle.size = 10
textStyle.placement = YMKTextStylePlacement.right
textStyle.offset = 5
return textStyle
}()
)
pin.userData = PointUserData(id: point.id)
pin.addTapListener(with: mapPointListener)
}
}
}
final private class MapInputListener: NSObject, YMKMapInputListener {
var onPositionSelect: (_ latitude: Double, _ longitude: Double) -> Void
init(onPositionSelect: @escaping (_ latitude: Double, _ longitude: Double) -> Void = {_, _ in }) {
self.onPositionSelect = onPositionSelect
}
func onMapTap(with map: YMKMap, point: YMKPoint) {
onPositionSelect(point.latitude, point.longitude)
}
func onMapLongTap(with map: YMKMap, point: YMKPoint) {}
}
final private class MapCameraListener: NSObject, YMKMapCameraListener {
var onDragged: () -> Void
init(onDragged: @escaping () -> Void = {}) {
self.onDragged = onDragged
}
func onCameraPositionChanged(with map: YMKMap, cameraPosition: YMKCameraPosition, cameraUpdateReason: YMKCameraUpdateReason, finished: Bool) {
if (cameraUpdateReason == YMKCameraUpdateReason.gestures) {
onDragged()
}
}
}
final private class MapPointListener: NSObject, YMKMapObjectTapListener {
var onPointClick: (KotlinLong, LatLng) -> Void
init(controller: UIViewController, onPointClick: @escaping (KotlinLong, LatLng) -> Void = {_, _ in}) {
self.controller = controller
self.onPointClick = onPointClick
}
func onMapObjectTap(with mapObject: YMKMapObject, point: YMKPoint) -> Bool {
let userData = mapObject.userData as! PointUserData
onPointClick(KotlinLong(value: userData.id), LatLng(latitude: point.latitude, longitude: point.longitude))
return true
}
private weak var controller: UIViewController?
}
private struct PointUserData {
let id: Int64
}
}
YandexMapProtocolImpl.swift (iosApp)
Реализация протокола лежит в модуле iOS. Ничего сложного, просто нужно кинуть туда реализацию ViewController и "дергаем" нужные методы. Кол-во и суть методов зависит от вашей конкретной реализации протокола.
KoinDI.ios.kt (iosMain)
Ниже описана функция для инициализации DI. Реализация карты передается через параметр и встраивается вместе с общим модулем и специфичным для платформы.
fun initKoinIos(
mapProtocol: YandexMapProtocol
) {
startKoin {
modules(
module {
single<YandexMapProtocol> { mapProtocol }
} +
commonModule() +
listOf(platformModule())
)
}
Napier.base(DebugAntilog())
}
iOSApp.swift
Собственно сам запуск DI и также установка токена карты. Запускать карту onStart()
в этом месте вовсе необязательно.
iOSApp.swift
import SwiftUI
import ComposeApp
import YandexMapsMobile
@main
struct iOSApp: App {
init() {
YMKMapKit.setApiKey(Constants().MAPKIT_API_KEY)
YMKMapKit.sharedInstance().onStart()
KoinDI_iosKt.doInitKoinIos(mapProtocol: YandexMapProtocolImpl())
}
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
YandexMapView.ios.kt (iosMain)
Реализация actual функции со специфичной логикой.
Главные моменты это изъятие из DI нужного модуля.
val yandexMapProtocol = koinInject<YandexMapProtocol>().apply {
addCameraListener(onDragged)
addMapPointListener(onPointClick)
}
Далее обертка в UIKitView
UIKitView(
factory = {
yandexMapProtocol.viewController.view
},
...
Полный код файла под спойлером или по ссылке.
YandexMapView.ios.kt
@OptIn(ExperimentalForeignApi::class)
@Composable
actual fun YandexMap(
modifier: Modifier,
enabled: Boolean,
zoom: Float,
location: LatLng?,
startPosition: LatLng?,
points: List<PointMapModel>,
onPointClick: (id: Long) -> Unit,
customPosition: Boolean,
canSelectPosition: State<Boolean>,
anotherLocationSelected: Boolean,
bottomFocusAreaPadding: Int,
onPositionSelected: (lat: Double, lng: Double) -> Unit,
onDragged: () -> Unit
) {
val yandexMapProtocol = koinInject<YandexMapProtocol>().apply {
addCameraListener(onDragged)
addMapPointListener(onPointClick)
}
LaunchedEffect(canSelectPosition) {
yandexMapProtocol.addMapListener { latitude, longitude ->
if (canSelectPosition.value) {
yandexMapProtocol.updateCustomPoint(
latLng = LatLng(latitude, longitude),
visible = true
)
onPositionSelected(latitude, longitude)
yandexMapProtocol.onMapMove(LatLng(latitude, longitude))
}
}
}
LaunchedEffect(Unit) {
yandexMapProtocol.onMapStart()
location?.let { latLng ->
Napier.d(tag = "YandexMap") { "Update map user location" }
if (customPosition) {
yandexMapProtocol.onMapMove(latLng)
}
yandexMapProtocol.updateMyPoint(latLng)
}
startPosition?.let { latLng ->
yandexMapProtocol.onMapMove(latLng)
if (anotherLocationSelected && canSelectPosition.value) {
yandexMapProtocol.updateCustomPoint(
latLng = latLng
)
}
}
yandexMapProtocol.updateCustomPoint(
visible = anotherLocationSelected
)
}
LaunchedEffect(enabled) {
if (enabled) {
yandexMapProtocol.onMapStart()
} else {
yandexMapProtocol.onMapStop()
}
}
LaunchedEffect(points) {
yandexMapProtocol.updatePointsCollection(points)
}
DisposableEffect(Unit) {
onDispose {
yandexMapProtocol.onMapStop()
}
}
UIKitView(
factory = {
yandexMapProtocol.viewController.view
},
modifier = modifier.fillMaxSize(),
update = {
location?.let { latLng ->
if (customPosition) {
yandexMapProtocol.onMapMove(latLng)
}
yandexMapProtocol.updateMyPoint(latLng = latLng)
}
if (canSelectPosition.value) {
yandexMapProtocol.updateCustomPoint(visible = anotherLocationSelected)
} else {
yandexMapProtocol.updateCustomPoint(visible = false)
}
yandexMapProtocol.setupFocusRect(bottomFocusAreaPadding)
}
)
}
Результаты
Еще раз повторюсь, что описанное решение не является самым оптимальным путем. Так как лучше всего описывать весь интерфейс на Kotlin, но это только, если удастся победить cinterop. А данной реализации нам хватило, скорее всего в дальнейшем мы напишем полный протокол и заведем это в отдельное SDK для внутреннего использования, чтобы подключать как модуль.
Быстродействие карты такое же как и в стандартной реализации, все-таки это native ?.
Полезные ссылки
Доклад про работу c нативными зависимостями в KMP проекте (про framework, cinterop)
Статья про реализацию Yandex Maps в pure iOS
Про вызов специфичных для платформы модулей
Добавление iOS зависимостей в KMP проект
Некоторые membres only, но никто вам не запрещает глянуть зеркала
Прощание
Спасибо за прочтение статьи! Когда я сам искал решение этой проблемы, мне было довольно сложно, не хватало поддержки от опытных разработчиков, поэтому, надеюсь, что мой материал все-таки кому-нибудь пригодится. Видео работы приложения приведено ниже. Комментарии и критика приветствуется, но учтите, мне 17, если обидите, то маме пожалуюсь ?.
Кому интересно, как мы захватываем местоположение пользователя и мониторим разрешения, то пишите в комментарии.
Если вам понравилось статья и хотите меня отблагодарить, то можете подписаться на мой TG канал https://t.me/stakan_live. Маленький блог о разработке и жизни.