Как я писал враппер для Яндекс Карт на KMP. Часть 1

от автора

Вступление

Приветствую! Я Владимир Ненашкин (@vollllodya), сейчас работаю на позиции KMP разработчика в компании EllowTech [ссылка уд. мод.]. Мы разрабатываем по большей части мультиплатформенные приложения на KMP, однако в этой статье расскажу про личный опыт написания библиотеки как пет-проекта.

В разработке совсем другого приватного пет-проекта понадобилось отображать объекты на карте. Только готового и поддерживаемого решения для работы с картой пока не было. Наиболее актуальным решением для нашей страны был выбран MapKit SDK от Яндекс Карт. В самом пет проекте до какого-то момента писался модуль для работы с картой и реализацией в платформенных исходниках, правда быстро подошел к тому, что дальше расширять доступный функционал становится всё труднее и труднее.

Как решение выбрал приостановить разработку того проекта и переписать этот модуль как отдельную библиотеку с сохранением официального API Яндекс Карт. И вот в этой статей я пишу про промежуточные итоги разработки библиотеки и трудности с которыми пришлось столкнуться.

Термины

  • Платформенный код – код в androidMainiosMain.

  • Common код – код в commonMain.

  • Платформенный тип – тип, используемый в платформенном коде. Пример: com.yandex.mapkit.map.MapYMKMapUIColorView и т.д.

  • Нативные вызовы – вызовы нативного коде. Например: используемый под капотом 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 (Википедия)

HexRGBAbits.png

HexRGBAbits.png

У нас получается что в этом 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/


Комментарии

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

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