Вступление
Приветствую! Я Владимир Ненашкин (@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.
ссылка на оригинал статьи https://habr.com/ru/articles/840128/
Добавить комментарий