Вступление

Приветствую! Я Владимир Ненашкин (@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.

Комментарии (0)