Вступление
Приветствую! Я Владимир Ненашкин (@vollllodya), сейчас работаю на позиции KMP разработчика в компании EllowTech [ссылка уд. мод.]. Мы разрабатываем по большей части мультиплатформенные приложения на KMP, однако в этой статье расскажу про личный опыт написания библиотеки как пет-проекта.
В разработке совсем другого приватного пет-проекта понадобилось отображать объекты на карте. Только готового и поддерживаемого решения для работы с картой пока не было. Наиболее актуальным решением для нашей страны был выбран MapKit SDK от Яндекс Карт. В самом пет проекте до какого-то момента писался модуль для работы с картой и реализацией в платформенных исходниках, правда быстро подошел к тому, что дальше расширять доступный функционал становится всё труднее и труднее.
Как решение выбрал приостановить разработку того проекта и переписать этот модуль как отдельную библиотеку с сохранением официального API Яндекс Карт. И вот в этой статей я пишу про промежуточные итоги разработки библиотеки и трудности с которыми пришлось столкнуться.
Термины
Платформенный код – код в
androidMain
,iosMain
.Common
код – код вcommonMain
.Платформенный тип – тип, используемый в платформенном коде. Пример:
com.yandex.mapkit.map.Map
,YMKMap
,UIColor
,View
и т.д.Нативные вызовы – вызовы нативного коде. Например: используемый под капотом MapKit SDK на Android как
NativeObject
.
Требования
-
Покрытие
lite
версии MapKit SDK.Конечно, желательно покрыть всё, однако время ограниченный ресурс, поэтому первостпенную важность имеют: инициализация SDK, управление картой, управление камерой, добавление объектов на карту, работа с местоположением, самые простые элементы настройки карты.
-
Сохранение официального API.
Если метод находится в объекте
Map
, то у враппера он там и должен находиться.Если имя пакета имеет суффикс
.mapkit.map.user_location
– то он таким и остаётся. Меняется только частьcom.yandex
наru.sulgik
.Если названия в iOS и Android версиях SDK отличаются, то выбрать наиболее релевантное. iOS –
YMKLogoAlignment
, Android –Alignment
, выбрано –LogoAlignment
; iOS –YMKMap
, android –Map
, выбрано –Map
.Всё в iOS версии SDK имеет префикс
YMK
, его не используем
Конвертация объектов из SDK в библиотечные и обратно. Все поддерживаетмые объекты должны иметь
toNative()
иtoCommon()
.-
Поддержка Compose Multiplatform
Отрисовка карты на всех платформах.
Управление картой как взаимодействие с SDK. Controller API
Управление картой как полноценный Composable UI компонент, использование контекста композиции для добавления объектов и управления состоянием карты. States API. ?
Composable контент как
ImageProvider
?
-
Мультиплатформенные ресурсы
moko-resources (тот пет проект писался до появления compose multiplatform resources). Используязование как с compose так и без.
Compose Multiplatform Resources
Список требований вышел достаточно обширным из чего получился и обширный фронт работ.
Начало работы
Я уже работал с Yandex MapKit SDK и примерно понимал, что у меня должно получится и что для этого потребуется. Огромный плюс, в том числе из-за него возможно создание библиотеки в формате враппера, – это относительная схожесть API на Android и iOS. Да, есть расхождения, где используются конкретно платформенные фичи. Например: у некоторых MapObject
есть параметр цвета, на iOS – это UIColor, на Android – Int, и др.
Главная цель, это практически бесшовный переход с официального SDK на мою библиотеку. Это обеспечивается сменой пакета com.yandex.mapkit
на ru.sulgik.mapkit
и сохранением API по большей части.
Второй момент – это присутствие deprecated API. Такие части я решил не переносить, поскольку оно, как не сложно догадаться, deprecated, и: возможно, удалится ещё до выхода моей библиотеки.
Третья особенность – это подключение зависимости. Библиотека является мультиплатформенной и на Android мы просто подключаем зависимость официальной MapKit SDK.
sourceSets {
androidMain.dependencies {
api("com.yandex.android:maps.mobile:4.7.0-lite")
}
}
Однако на iOS у нас используются не исходники на Kotlin. Мы используем наливную iOS библиотеку. Для этого используем cocoapods (ныне “deprecated”).
kotlin {
cocoapods {
ios.deploymentTarget = "15.0"
framework {
baseName = "YandexMapKitKMP"
}
noPodspec()
pod("YandexMapsMobile") {
version = "4.7.0-lite"
packageName = "YandexMapKit"
}
}
}
Важно! Этот
pod
не подтянется транзитивно в проект, использующий мой враппер. Поэтому важно подключить этотpod
в своём проекте.
Способы враппинга
Объекты стоит разделить на разные типы и выбирать такой способ враппинга, подходящий именно ему. Для этого стоит обратить внимание на исходники официальной библиотеки
1. Прямой враппинг
Если объект хранит информацию, возвращает её и как-то изменяет, то стоит выбрать это метод.
Приведу пример: у нас есть тип Map
(документация). У него есть геттеры, сеттеры и методы. Мы делаем обёртку и хранив в ней ссылку на наивный объект, вызывая методы у нативного объекта и конвертируя данные, если требуется.
Посмотрим на common
код враппер.
public expect class Map {
public val cameraPosition: CameraPosition
public var isNightModeEnabled: Boolean
public fun set2DMode(enable: Boolean)
public fun wipe()
public fun move(cameraPosition: CameraPosition)
// ...
}
Если есть только геттер – то это
val
с “врапнутым” типомГеттер и сеттер –
var
Только сеттер – сеттер метод
Методы остаются методами, только параметры и возвращаемые значения тоже враппятся
Теперь посмотрим на этот тип в платформенном коде.
Android
public actual class Map internal constructor(private val nativeMap: NativeMap) {
public fun toNative(): NativeMap {
return nativeMap
}
public actual val cameraPosition: CameraPosition
get() = nativeMap.cameraPosition.toCommon()
public actual var isNightModeEnabled: Boolean
get() = nativeMap.isNightModeEnabled
set(value) {
nativeMap.isNightModeEnabled = value
}
public actual fun set2DMode(enable: Boolean) {
nativeMap.set2DMode(enable)
}
public actual fun wipe() {
nativeMap.wipe()
}
public actual fun move(cameraPosition: CameraPosition) {
nativeMap.move(cameraPosition.toNative())
}
// ...
}
public fun NativeMap.toCommon(): Map {
return Map(this)
}
iOS:
public actual class Map internal constructor(private val nativeMap: NativeMap) {
public fun toNative(): NativeMap {
return nativeMap
}
public actual val cameraPosition: CameraPosition
get() = nativeMap.cameraPosition.toCommon()
public actual var isNightModeEnabled: Boolean
get() = nativeMap.isNightModeEnabled()
set(value) {
nativeMap.setNightModeEnabled(value)
}
public actual fun set2DMode(enable: Boolean) {
nativeMap.set2DModeWithEnable(enable)
}
public actual fun wipe() {
nativeMap.wipe()
}
public actual fun move(cameraPosition: CameraPosition) {
nativeMap.moveWithCameraPosition(cameraPosition.toNative())
}
// ...
}
public fun NativeMap.toCommon(): Map {
return Map(this)
}
Тут и появляются toNative()
и toCommon()
функции конвертами, доступные только в платформенных исходиниках.
Можно заметить, что используется некий
NativeMap
, это просто import alias для платформенных типов.
import YandexMapKit.YMKMap as NativeMap // iOS import com.yandex.mapkit.map.Map as NativeMap // Android
data class с конвертерами
Этот способ применим, есть тип простой, имеет лишь геттеры, конструктор и не использует обращение к нативному коду. Рассмотрим пример с Circle
(документация)
common
код.
public data class Circle(
val center: Point,
val radius: Float,
)
В платформенном коде мы лишь конвертируем набивные типы в common и обратно. Но пишем мы это в двух вариантах для двух платформ.
public fun Circle.toNative(): NativeCircle {
return NativeCircle(center.toNative(), radius)
}
public fun NativeCircle.toCommon(): Circle {
return Circle(center.toCommon(), radius)
}
Когда не применям? Рассмотрим другой пример из того же пакета geometry
– Polygon
(документация).
Если посмотрим на исходники андроида, то найдём там и syncronized
, и обращение к наивному коду при первом обращении. А нам разве нужно синхронизация и нативные вызовы при работе toCommon()
?
@NonNull
public synchronized LinearRing getOuterRing() {
if (!this.outerRing__is_initialized) {
this.outerRing = this.getOuterRing__Native();
this.outerRing__is_initialized = true;
}
return this.outerRing;
}
Поэтому используем прямой враппинг с одним лишь отличием – наличием secondary конструктора у expect
в common
коде.
public expect class Polygon {
public constructor(outerRing: LinearRing, innerRing: List<LinearRing>)
public val outerRing: LinearRing
public val innerRing: List<LinearRing>
}
И уже в платформенном коде мы будем использовать платформенный тип и оставим ленивую инициализацию при конвертации платформенного типа.
public actual class Polygon internal constructor(private val nativePolygon: NativePolygon) {
public fun toNative(): NativePolygon {
return nativePolygon
}
override fun toString(): String {
return "Polygon(outerRing=$outerRing, innerRing=${innerRing.linearRingsListToString()})"
}
public actual constructor(
outerRing: LinearRing,
innerRing: List<LinearRing>,
) : this(NativePolygon(outerRing.toNative(), innerRing.map { it.toNative() }))
public actual val outerRing: LinearRing by lazy { nativePolygon.outerRing.toCommon() }
public actual val innerRing: List<LinearRing> by lazy { nativePolygon.innerRings.map { it.toCommon() } }
}
public fun NativePolygon.toCommon(): Polygon {
return Polygon(this)
}
Callbacks и listeners
Первый способ. Самая интересная часть это работа с колбеками. Важно понимать, что SDK держит слабые ссылки на все объекты такого типа, следовательно при враппинге мы должны оставлять возможность сохранять строгую ссылку пользователям, т.е. невозможен такой код:
// Common code
public expect abstract class CameraListener() {
public abstract fun onCameraPositionChanged(
map: Map,
cameraPosition: CameraPosition,
cameraUpdateReason: CameraUpdateReason,
finished: Boolean,
)
}
// Android code. Not valid
public fun CameraListener.toNative(): CameraListener {
return object : NativeCameraListener {
override fun onCameraPositionChanged(
map: NativeMap,
cameraPosition: NativeCameraPosition,
reason: NativeCameraUpdateReason,
finished: Boolean,
) {
onCameraPositionChanged(map.toCommon(), cameraPosition.toCommon(), reason.toCommon(), finished)
}
}
}
// Android code
public actual class Map internal constructor(private val nativeMap: NativeMap) {
public actual fun addCameraListener(cameraListener: CameraListener) {
nativeMap.addCameraListener(cameraListener.toNative())
}
// ...
}
В момент вызова addCameraListener
создаётся новый объект платформенного слушателя и никто её не сохраняет, объект числится и common
слушатель никогда не сработает. Даже если нам повезёт и GC не дойдёт до этой ссылки, то как быть с removeCameraListener
? Тут же toNative()
создаёт новую ссылку.
Второй вариант. А если просто иметь expect
класс и в платформенном коде имплементировать платформенный тип слушателя. Тут напишу сразу про iOS код, с Android всё хорошо
// Common code
public expect abstract class CameraListener() {
public abstract fun onCameraPositionChanged(
map: Map,
cameraPosition: CameraPosition,
cameraUpdateReason: CameraUpdateReason,
finished: Boolean,
)
}
// iOS code. Not valid
public actual abstract class CameraListener actual constructor() : NativeCameraListener,
NSObject() {
override fun onCameraPositionChangedWithMap(
map: NativeMap,
cameraPosition: NativeCameraPosition,
cameraUpdateReason: NativeCameraUpdateReason,
finished: Boolean,
) {
onCameraPositionChanged(
map.toCommon(),
cameraPosition.toCommon(),
cameraUpdateReason.toCommon(),
finished
)
}
public fun toNative(): NativeCameraListener {
return this
}
public actual abstract fun onCameraPositionChanged(
map: Map,
cameraPosition: CameraPosition,
cameraUpdateReason: CameraUpdateReason,
finished: Boolean,
)
}
IDE не ругается, всё казалось бы хорошо, однако пытаемся собрать под iOS таргет и получаем ошибку компиляции (подробнее)
Non-final Kotlin subclasses of Objective-C classes are not yet supported
И этот способ отпадает. Попробуем следующий.
Третий метод. Храним платформенный листенер в платформенной реализации expect
класса.
// Common code
public expect abstract class CameraListener() {
public abstract fun onCameraPositionChanged(
map: Map,
cameraPosition: CameraPosition,
cameraUpdateReason: CameraUpdateReason,
finished: Boolean,
)
}
// iOS code. Valid
public actual abstract class CameraListener actual constructor() {
private val nativeListener = object : NativeCameraListener, NSObject() {
override fun onCameraPositionChangedWithMap(
map: NativeMap,
cameraPosition: NativeCameraPosition,
cameraUpdateReason: NativeCameraUpdateReason,
finished: Boolean,
) {
onCameraPositionChanged(
map.toCommon(),
cameraPosition.toCommon(),
cameraUpdateReason.toCommon(),
finished
)
}
}
public fun toNative(): NativeCameraListener {
return nativeListener
}
public actual abstract fun onCameraPositionChanged(
map: Map,
cameraPosition: CameraPosition,
cameraUpdateReason: CameraUpdateReason,
finished: Boolean,
)
}
// iOS code
public actual class Map internal constructor(private val nativeMap: NativeMap) {
public actual fun addCameraListener(cameraListener: CameraListener) {
nativeMap.addCameraListenerWithCameraListener(cameraListener.toNative())
}
// ...
}
Теперь мы можем создавать слушатели в common
коде и платформенный слушатель не почистится и останется возможность выполнять removeCameraListener
.
Почему нет
toCommon()
? А для чего он нужен у колбеков? Такой метод практически не имеет смысла, а если требуется, это значит, что нужно написать код по-другому, чтобы обойтись без него
Трудности
Конечно, если рассматривать отдельные кейсы, да ещё и сразу с решением, то всё выглядит просто. Однако есть и сложные ситуации для враппинга, которые стоит рассматривать отдельно
Цвета
На iOS всё относительно просто. MapKit использует платформенный UIColor
. Он задокументирован и готовые решения для преобразования. В common коде просто создаётся value
класс Color
, который хранит значение в ARGB32
формате.
public data class Color private constructor(internal val value: Int) {
public companion object {
public fun fromArgb(argb: Int): Color {
return Color(value = argb)
}
}
}
public fun Color.toArgb(): Int {
return value
}
Для простоты восприятия создаются методы для создания и преобразования, из которых можно сразу понять, из чего можно получить валидный Color и во что можно его преобразовать. Но зачем? Первая причина – пользователю так понятнее, вторая – официальная документация и реализация на Android. Читаем её для метода CircleMapObject.getStrokeColor(): Int
(документация):
Sets the stroke color.
Setting the stroke color to any transparent color (for example, RGBA code 0x00000000) effectively disables the stroke. default: 0x0066FFFF
Сразу видим “sets” в документации к геттеру, ну все ошибаются. Читаем дальше RGBA
код. Что, кажется, означает RGBA32
(Википедия)
У нас получается что в этом Int
хранится цвет в достаточно непривычном формате, ну, наверное, у них была причина на это. В подтверждении этой теории нам даётся default: 0x0066FFFF. Если бы это был более привычный нам ARGB – то это по факту прозрачный цвет, alpha = 0F
.
Почему это важно? Мы хотим создавать цвет в common
коде и получать один результат на выходе. Для этого нужны конвертеры в UIColor
на iOS и в Int
на Android.
// iOS code
public fun Color.toNative(): UIColor {
return UIColor.colorWithRed(
red = ((value shr 16) and 0xff) / 255.0,
green = ((value shr 8) and 0xff) / 255.0,
blue = (value and 0xff) / 255.0,
alpha = ((value shr 24) and 0xff) / 255.0,
)
}
// iOS code
public fun UIColor.toCommon(): Color {
val red = (CIColor.red * 255).toInt()
val green = (CIColor.green * 255).toInt()
val blue = (CIColor.blue * 255).toInt()
val alpha = (CIColor.alpha * 255).toInt()
return Color.fromArgb((alpha shl 24) or (red shl 16) or (green shl 8) or (blue))
}
А на Android… А ничего там не нужно! Почему, спросите вы? В MapKit не используется никакой RGBA32! Это выяснилось эмперическим путём. Да, сначала были конвертеры с перестановкой битов альфы из начала в конец и обратно, но получался совсем не тот цвет… В итоге на Android нам хватит toArgb()
и fromArgb()
методов.
public actual class CircleMapObject internal constructor(private val nativeCircleMapObject: NativeCircleMapObject) :
MapObject(nativeCircleMapObject) {
public actual var strokeColor: Color
get() = nativeCircleMapObject.strokeColor.toColor()
set(value) {
nativeCircleMapObject.strokeColor = value.toArgb()
// ...
}
internal fun Int.toColor(): Color {
return Color.fromArgb(this)
}
Казалось бы, зачем они используют такой непривычный формат RGBA32
, но ответ достаточно прост – они его и не используют, просто говорят, что он есть.
PointF
Этот тип присутствует только на Android, на iOS он выражается другим способом. Присутствует он, например, в IconStyle
(документация Android) (документация iOS)
// common code
public data class IconStyle(
val anchor: PointF? = null,
val rotationType: RotationType? = RotationType.NO_ROTATION,
val zIndex: Float? = null,
val flat: Boolean? = false,
val isVisible: Boolean? = true,
val scale: Float? = 1f,
val tappableArea: Rect? = null,
)
На Android это платформенный android.graphics.PointF
, на iOS NSValue
.
Для Android всё очевидно, просто конвертируем
public fun PointF.toNative(): NativePointF {
return NativePointF(x, y)
}
public fun NativePointF.toCommon(): PointF {
return PointF(x, y)
}
Но на iOS нужно научиться распаковывать NSValue
в PointF
internal fun NSValue.toPointF(): PointF {
return CGPointValue.useContents { toCommon() }
}
У NSValue
берётся CGPointValue
, поскольку мы уверены, что это он. Мы получаем тип CValue<CGPoint>
, его дальге “распаковываем через“ useContents{}
он создаёт временную копию хранимого объекта и её уже можно безопасно передавать. this
внутри это блока и является искомый CGPoint
, его преобразовываем как и PointF
в Android
исходниках.
public fun CGPoint.toCommon(): PointF {
return PointF(x.toFloat(), y.toFloat())
}
Дальше чтобы сконвертировать обратно в CGPoint мы можем лишь создать через Make
функцию и получаем CValue<CGPoint>
public fun PointF.toNative(): CValue<CGPoint> {
return CGPointMake(x.toDouble(), y.toDouble())
}
Заключение
Для враппинга применяются различные приёмы, выбор зависит от типа. Я показал несколько вариантов, и если вы знаете ещё, то можете написать про это в комментариях или даже поучаствовать в поддержке библиотеки.
Но, кажись, в требованиях была прописана поддержка Compose Multiplatform. И я вам скажу, что она есть, и обширная. Но это история для второй статьи на эту тему, эта уже затянулась и совсем о другом. Как только следующая часть выйдет, здесь появится ссылка на неё.
Эта статья написана для тех, кто хочет разрабатывать на KMP, или просто интересуется этой технологией. Цель – поделиться личным опытом, который, как мне кажется, достаточно нестандартный и относительно интересный. Ну и конечно же поделиться свой библиотекой.
Найти код этой библиотеку можно у меня на GitHub а документацию на сайте. Если хотите помочь проекту, то всегда буду рад, создавайте pull requests, открывайте issue и ставьте звёздочки.
Я никак не связан с Яндекс. Я лишь автор библиотеки, позволяющий использовать их разработку, MapKit SDK, в "экосистеме" KMP проектов. Все api key, необходимые для работы с SDK получаются как и с официальной библиотекой, на сайте Яндекса. Я не претендую ни на ваши api ключи, ни на деньги с покупки тарифов Яндексу. Даже возможно, что эта библиотека привлечет Яндексу некоторое количество клиентов, заинтересованных в разработке под KMP.