Привет, хабр!

Поддержка Android в Swift 6 и swift-android-sdk от finagolfin это настоящий прорыв, который наконец-то позволил мне выпустить JNIKit, который я разрабатывал для проекта SwifDroid ещё со времён Swift 5. Теперь мы можем просто import Android, вместо того чтобы возиться с ручным импортом NDK header'ов, а сборка конечных бинарников теперь обеспечивается не отдельным тулчейном, а минималистичной SDK, которую в скором времени сделают официальной на Swift.org.

Сегодня я хочу показать как написать ваш первый Swift код для Android. Это будет увлекательное приключение, так что налейте чашечку чая и давайте начнём.

Что Вам Понадобится

  1. Docker

  2. VSCode и расширение Dev Containers

  3. Swift Stream IDE расширение для VSCode

Опционально, установленная Android Studio для тестирование библиотеки, которую мы разработаем, на реальном Android приложении.

Операционная система не важна, главное, чтобы она поддерживала Docker и VSCode.

Как только вы установили Docker, можете запускать VSCode.

Первым делом нужно установить расширение Dev Containers.

Поиск расширения Dev Containers в VSCode
Поиск расширения Dev Containers в VSCode

Далее нужно установить расширение Swift Stream IDE.

Поиск расширения Swift Stream IDE в VSCode
Поиск расширения Swift Stream IDE в VSCode

Создание Проекта

В левом меню VSCode кликните на иконку Swift Stream (с птичкой).

Кнопки старта нового проекта или открытия существующего
Кнопки старта нового проекта или открытия существующего

и смело жмите Start New Project

Теперь нужно ввести название проекта вашей Swift библиотеки

Ввод имени нового проекта
Ввод имени нового проекта

Вы можете видеть, что по умолчанию новый проект будет создан в вашей домашней папке. Это можно изменить выбрав иную папку нажав на три точки справа.

Теперь необходимо выбрать направление и тип проекта, Android -> Library в нашем случае.

Выбор типа и направления нового проекта
Выбор типа и направления нового проекта

И нажимаем Create Project.

Теперь необходимо указать Java namespace вашей библиотеки. Обычно это доменное имя наоборот, к примеру com.example.mylib

Ввод Java namespace для нового проекта
Ввод Java namespace для нового проекта

Так же необходимо указать минимальную версию Android SDK

Выбор минимальной версии Android SDK
Выбор минимальной версии Android SDK

Я бы рекомендовал выбрать 24 или 29, в зависимости от ваших нужд. Жмите Enter, чтобы перейти к выбору версии Android SDK для компиляции

Выбор версии Android SDK для компиляции
Выбор версии Android SDK для компиляции

На сегодняшний день хорошим выбором будет 35. Жмите Enter ещё раз, чтобы запустить процесс создания проекта.

На этом моменте VSCode создаст папку проекта со всеми необходимыми файлами и начнёт скачивание Docker-образа с заранее подготовленным окружением для разработки под Android.

Как только образ загружен, VSCode откроет новое окно для вашего проекта, которое уже будет работать непосредственно внутри dev-контейнера. При первом запуске контейнер постарается загрузить актуальный Swift тулчейн, Swift for Android SDK, Gradle, Android NDK, т Android SDK. Все эти сущности будут заботливо использоваться из кэша, который организован на Docker Volumes. То есть создание следующего проекта будет происходить за секунды.

Первый запуск может занять продолжительное время в зависимости от скорости вашего интернета и мощности компьютера, так что наберитесь терпения и пока выпейте чаю.

Логи контейнера с успешно установленными инструментами
Логи контейнера с успешно установленными инструментами

И теперь всё готово! Можно начинать писать код!

Преамбула

Что такое JNI?

Java Native Interface (JNI) – это мост который позволяет нативному коду обращаться к Java Virtual Machine (JVM). Когда вы пишете Java код, то вы используете Android SDK. Но когда вы используете языки как Swift или C++, которые не компилируются в Java байткод, вам уже нужен Android NDK для коммуникации с Java через JNI.

Используя JNI вы можете делать практически всё то же самое, что и с Java, но делать это без головной боли это реальный челлендж.

Что такое JNIKit?

JNIKit вступает в игру как раз, чтобы убрать головную боль. Чтобы быть продуктивным и чувствовать себя комфортно на нужен удобный Swift-слой, который обернёт все низкоуровневые JNI вызовы созданные для C, во что-то намного более элегантное. Это то, для чего JNIKit был создан.

Проект

Структура

В сердце проекта конечно же Swift Package Manager. И ключевые зависимости: JNIKit, и AndroidLogging со swift-log.

Ваш Swift код по умолчанию живёт в Sources/<target_name>/Library.swift.

Android библиотека (Gradle проект) находится в папке Library. Эта папка будет автоматически сгенерирована после первого сборки Swift кода. Альтернативно, вы можете запустить её генерацию слева на панели расширения Swift Stream.

Swift-код

Всё начинается с метода initialize и он должен быть вызван один раз до вызова любых других нативных методов.

Этот метод экспортируется в JNI при помощи директивы @_cdecl.

Имя метода очень критично, оно должно строго соответствовать JNI-паттерну

Java_<package>_<class>_<method>
  • package это имя пакета содержащего класс, но вместо точек у него должны быть нижние подчеркивания

  • class это имя класса содержащего метод

  • method это наименование метода

Аргументы метода так же следуют JNI-соглашению. Первые два обязательны и всегда передаются автоматически самим JNI:

  1. envPointer: Этот параметр никогда не меняется и представляет из себя указатель на JNI окружение, которое по факту является интерфейсом между нативным кодом и JVM.

  2. clazzRef или thizRef: Здесь вы получаете clazzRef если ваш Java-метод статичный (как в нашем случае, где наш метод внутри Kotlin object). Вы так же можете получить thizRef если это метод инициализированного объекта. В первом случае это будет референс на класс, во втором референс на объект.

Все последующие аргументы представляют из себя параметры Java/Kotlin-метода. В нашем случае метод имеет один параметр: caller. Мы передаём его из приложения в качестве контекста, чтобы, к примеру, взять из него class loader приложения (больше об этом далее). Если бы у нас был thizRef, то он подошёл бы в качестве контекста и caller можно было бы уже не передавать.

#if os(Android)
@_cdecl("Java_com_habr_swiftlib_myfirstandroidproject_SwiftInterface_initialize")
public func initialize(
    envPointer: UnsafeMutablePointer<JNIEnv?>,
    clazzRef: jobject,
    callerRef: jobject
) {
    // Активируем Android logger
    LoggingSystem.bootstrap(AndroidLogHandler.taggedBySource)
    // Инициализируем JVM
    let jvm = envPointer.jvm()
    JNIKit.shared.initialize(with: jvm)
    // ДАЛЕЕ: кэшируем class loader
    // ДАЛЕЕ: пример `toString`
    // ДАЛЕЕ: пример `Task`
}
#endif

Тело метода показывает нам первичную конфигурацию swift-log с Android Logger, которую нужно делать только один раз.

Это даёт нам возможность использования логгера вывод которого будет доступен в LogCat.

let logger = Logger(label: "?‍? SWIFT")
logger.info("? Привет, Хабр!")

Далее в теле метода мы инициализировали подключение к JVM. Это тоже делается один раз.

На этом этапе наше приложение уже готово полноценно работать через JNI.

Class Loader и Кэш

С загрузкой классов можно легко попасть в ловушку ClassNotFoundException потому что по умолчанию JNI использует системный загрузчик, который ничего не знает о динамически загруженных классах или зависимостях вашего приложения.

Решение? Нам нужен загрузчик из контекста приложения, который легко получить из абсолютно любого Java-объекта через .getClass().getClassLoader(). Лучшей практикой является создание глобальной ссылки на объект загрузчика классоов в методе initialize и сохранение его в JNICache, т.к. он остаётся валидным на всё время жизни приложения. Таким образом мы будем экономить JNI вызовы и получим прирост в скорости работы приложения.

Вот как мы можем кэшировать загрузчик классов в методе initialize:

// Оборачиваем указатель на окружение
let localEnv = JEnv(envPointer)
// Конвертируем локальный референс объекта в глобальный
let callerBox = callerRef.box(localEnv)
// Defer-блок отработает для удаления локального референса
defer {
    // Удаляем локальный референс на объект
    localEnv.deleteLocalRef(callerRef)
}
// Создаём `JObject` из глобального референса на объект
guard let callerObject = callerBox?.object() else { return }
// Кэшируем загрузчик классов из caller-объекта
if let classLoader = callerObject.getClassLoader(localEnv) {
    // Сохраняем его в специальный JNICache
    JNICache.shared.setClassLoader(classLoader)
    logger.info("? class loader cached successfully")
}

Если бы был thizRef, то можно было бы взять загрузчик классов из него

Могу ли я использовать toString()?

Конечно, это классический Java-метод и для него сделана обёртка в JNIKit, а вызвать его можно на любом объект вот так вот просто:

logger.info("? caller description: \(someObject.toString())")

Использование окружения из другого потока

JNIEnv жестко привязан к потоку. Оно является тем самым мостиком который делает всю магию JNI, передавая вызовы в JVM.

Если вы переключаете поток, например используя Task, вы должны подключиться к окружению текущего потока. JNIKit предоставляет простой метод для этого: JEnv.current().

Task {
    // Access current environment in this thread
    guard let env = JEnv.current() else { return }
    logger.info("? new env: \(env)")
    // Print JNI version into LogCat
    logger.info("? jni version: \(env.getVersionString())")
}

Как выглядит код на другой стороне

Java

public final class SwiftInterface {
    static {
        System.loadLibrary("MyFirstAndroidProject");
    }
    private SwiftInterface() {}
    public static native void initialize(Object caller);
}

Kotlin

object SwiftInterface {
    init {
        System.loadLibrary("MyFirstAndroidProject")
    }
    external fun initialize(caller: Any)
}

Swift Stream IDE генерирует Kotlin-файлы для вас, поэтому все дальнейшие примеры будут на Kotlin.

Сборка Swift-проекта

Отлично, время сбилдить проект! На боковой панели переключайтесь на таб Swift Stream и нажимайте на Project -> Build.

Кнопка Build для сборки Swift-проекта
Кнопка Build для сборки Swift-проекта

При первой сборке необходимо будет выбрать схему Debug или Release во всплывающем окне. В дальнейшем её можно изменить нажав на соответствующую кнопку Scheme (на скриншоте выше).

Выбор схемы сборки проекта
Выбор схемы сборки проекта

В этот раз выберем Debug и начнётся процесс сборки.

На боковой панели Swift Stream вы можете выбрать Log Level, чтобы видеть больше или меньше деталей в процессе сборки:

  • Normal

  • Detailed (по умолчанию)

  • Verbose

  • Unbearable (когда вы хотите видеть абсолютно всё)

По умолчанию на Detailed уровне во время сборки вы увидите следующий вывод в окне Output:

?️ Started building debug
?‍♂️ it will try to build each phase
? Resolving Swift dependencies for native
? Resolved in 772ms
? Resolving Swift dependencies for droid
? Resolved in 2s918ms
? Building `MyFirstAndroidProject` swift target for arm64-v8a
? Built `MyFirstAndroidProject` swift target for `.droid` in 10s184ms
? Building `MyFirstAndroidProject` swift target for armeabi-v7a
? Built `MyFirstAndroidProject` swift target for `.droid` in 7s202ms
? Building `MyFirstAndroidProject` swift target for x86_64
? Built `MyFirstAndroidProject` swift target for `.droid` in 7s135ms
? Preparing gradle wrapper
? Prepared gradle wrapper in 1m50s
✅ Build Succeeded in 2m20s

Как вы можете видеть, Swift даже "на холодную" скомпилировался довольно быстро, всего ~30 секунд на все три таргета (arm64-v8a, armeabi-v7a, and x86_64). Большую часть времени (1 мин. 50 сек.) занял первый запуск команды gradle wrapper, но это делается лишь изредка.

Отличная новость в том, что повторные запуски компиляции будут очень быстрыми, порядка 3 секунд на все три таргета! Это потому что уже всё в кэше.

Команда сборки так же автоматически генерирует Gradle-файлы для проекта Java-библиотеки для вас. Их теперь можно найти в папке Library.

Java/Kotlin проект

Исходный код

Swift Stream генерирует начальный шаблонный код для вашей библиотеки, который вдальнейшем вы будете развивать и поддерживать самостоятельно.

import android.util.Log

object SwiftInterface {
    init {
        System.loadLibrary("MyFirstAndroidProject")
    }

    external fun initialize(caller: Any)

    external fun sendInt(number: Int)
    external fun sendIntArray(array: IntArray)
    external fun sendString(string: String)
    external fun sendDate(date: Date)
    external fun ping(): String
    external fun fetchAsyncData(): String
}

Gradle-файлы

Swift Stream не только генерирует Gradle-файлы, но и управляет ими в дальнейшем. Он также создаёт Gradle-таргет под каждый таргет из вашего Package.swift и следит, чтобы Gradle-файлы были синхронизированы со Swift проектом.

В Library/settings.gradle.kts, происходит управление списком таргетов внутри специальных тэгов:

// managed by swiftstreamide: includes-begin
include(":myfirstandroidproject")
// managed by swiftstreamide: includes-end

В каждом Library/<target>/build.gradle.kts файле, происходит автоматическое управление зависимостями необходимыми для корректной работы Swift кода в этом таргете. runtime-libs:core является обязательной зависимостью, а остальные runtime-libs: внутри специальных тегов могут меняться в зависимости от вашего Swift кода:

implementation("com.github.swifdroid.runtime-libs:core:6.1.3")
// managed by swiftstreamide: so-dependencies-begin
implementation("com.github.swifdroid.runtime-libs:foundation:6.1.3")
implementation("com.github.swifdroid.runtime-libs:foundationessentials:6.1.3")
implementation("com.github.swifdroid.runtime-libs:i18n:6.1.3")
// managed by swiftstreamide: so-dependencies-end

По умолчанию, эти зависимости подтягиваются автоматически из SwiftDroid runtime-libs репозитория на JitPack, и репозиторий обновляется вместе с выходом новой версии Swift. Это означает, что вам не нужно возиться с ручным копированием .so файлов из Android SDK бандла!

Если вам всё-таки нужно больше контроля, то вы можете включить ручной режим, но всё ещё без ручного копирования каждого файла. Swift Stream IDE использует конфигурационный файл (.vscode/android-stream.json) в котором вы можете переключить режим soMode:

"soMode": "Packed"

"Packed" выбран по умолчанию и он означает, что Gradle импортирует всё из JitPack. Вы можете переключиться на "PickedManually" и перечислить только нужные вам .so файлы:

"soMode": "PickedManually",
"schemes": [
    {
        "title": "MyFirstAndroidProject Debug",
        "soFiles": [
            "libandroid.so",
            "libc.so",
            "libm.so"
        ]
    }
]

В этом же конфигурационном файле вы можете изменить и другие настройки проекта:

"packageName": "com.habr.swiftlib",
"compileSDK": 35,
"minSDK": 24,
"javaVersion": 11,

А ещё здесь же вы можете передать кастомные аргументы для команды swift build:

"schemes": [
    {
        "title": "MyFirstAndroidProject Debug",
        "swiftArgs": []
    }
]

Сборка Gradle проекта

Наконец-то мы добрались до сборки Android-библиотеки в файл .aar, для этого в боковой панели Swift Stream выберите Java Library Project -> Assemble.

Кнопка для сборки Gradle проекта
Кнопка для сборки Gradle проекта

Эта кнопка запустит либо команду gradlew assembleDebug или gradlew assembleRelease, которая сформирует вашу библиотеку для локального использования или дальнейшей публикации.

Добавим эту библиотеку в Android проект (локально)

Это самая веселая часть, т.к. мы увидим работу свифтв в вашем реальном Android приложении! Откройте существующий или создайте новый проект в Android Studio.

Как только вы в проекте, первым шагом нужно добавить JitPack в качестве репозитория. Найдите ваш settings.gradle.kts и убедитесь, что вы подключили JitPack:

dependencyResolutionManagement {
    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
    repositories {
        google()
        maven { url = uri("https://jitpack.io") } // <-- это оно
        mavenCentral()
    }
}

Далее нужно прописать зависимости в build.gradle.kts модуля вашего приложения (это может быть app/build.gradle.kts). Здесь нужно подключить и ваш .aar файл и все необходимые свифту рантайм-зависимости:

dependencies {
    implementation(files("libs/myfirstandroidproject-debug.aar"))
    implementation("com.github.swifdroid.runtime-libs:core:6.1.3")
    implementation("com.github.swifdroid.runtime-libs:foundation:6.1.3")
    implementation("com.github.swifdroid.runtime-libs:foundationessentials:6.1.3")
    implementation("com.github.swifdroid.runtime-libs:i18n:6.1.3")
    // the rest of dependencies
}

Важно: Вам необходимо вручную прописывать здесь runtime-libs: зависимости потому что Gradle не может получить их из .aar файла. Но такого не будет если вы будете распространять свою библиотеку через репозиторий.

Получаем .AAR файл

После сборки Gradle проекта (выше) у нас получился .aar файл библиотеки, который вы сможете найти по адресу:

Library/myfirstandroidproject/build/outputs/aar/myfirstandroidproject-debug.aar

Скопируйте этот файл. Далее в папке вашего Android приложения перейдите в папку модуля (например, app/) и создайте там папку libs (она должна расположиться там же где лежит build.gradle.kts. Вставьте ваш .aar файл в папку libs.

И начнется магия! ?

Отлично, у нас всё есть! Теперь в коде вашего проекта, где-нибудь в классе Application или в методе onCreate вашей Activity, инициализируйте Swift:

SwiftInterface.initialize(this)

Синхронизируйте Gradle и запускайте ваш Android проект на устройстве или эмуляторе.

Момент истины: откройте LogCat и отфильтруйте по слову "SWIFT". Вы должны увидеть это славное послание:

 I  [?‍? SWIFT] ? Привет, Хабр!

Урра!!! Ваш Swift код запустился на Android!

Дальнейшие итерации разработки

Когда вы делаете изменения в вашем Swift код, вам придётся проделать следующее:

  1. На панели Swift Stream нажать Project -> Build

  2. Далее, нажать Java Library Project -> Assemble

  3. Скопировать новый .aar файл из папки outputs/aar в папку вашего Android проекта (app/libs), перезаписав старый файл

Вот и всё! Теперь вы кроссплатформенный Swift-разработчик!

Пример работы с JNI

Переходим к самой интересной части, к коду! Поговорим о том как можно коммуницировать между Swift и Java/Kotlin. Как я говорил выше, мы будем всё писать на Kotlin, просто потому что это стандарт для Android разработчиков в наши дни.

Мы покроем некоторые простые, но самые насущные сценарии в этот раз, а погрузимся глубже в следующих публикациях.

⚠️ Напоминание: необходимо вызвать SwiftInterface.initialize(this) перед вызовом других нативных методов!

Отправляем Int из Kotlin в Swift

Начнём с простого. Задекларируйте метод в SwiftInterface.kt:

external fun sendInt(number: Int)

Со стороны свифта имплементируйте следующее:

#if os(Android)
@_cdecl("Java_com_habr_swiftlib_myfirstandroidproject_SwiftInterface_sendInt")
public func sendInt(
    envPointer: UnsafeMutablePointer<JNIEnv?>,
    clazzRef: jobject,
    number: jint
) {
    let logger = Logger(label: "?‍? SWIFT")
    logger.info("#️⃣ sendInt: \(number)")
}
#endif

Вызовите его из вашего приложения:

SwiftInterface.sendInt(123)

Проверьте LogCat:

 I  [?‍? SWIFT] #️⃣ sendInt: 123

Это было просто, да? :)

Отправляем IntArray из Kotlin в Swift

Задекларируйте метод:

external fun sendIntArray(array: IntArray)

Со стороны свифта принимайте массив:

#if os(Android)
@_cdecl("Java_com_habr_swiftlib_myfirstandroidproject_SwiftInterface_sendIntArray")
public func sendIntArray(
    envPointer: UnsafeMutablePointer<JNIEnv?>,
    clazzRef: jobject,
    arrayRef: jintArray
) {
    // Создаём легковесный логгер
    let logger = Logger(label: "?‍? SWIFT")
    // Получаем доступ к текущему окружению
    let localEnv = JEnv(envPointer)
    // Defer-блок отработает для удаления локального референса
    defer {
        // Удаляем локальный референс на объект
        localEnv.deleteLocalRef(arrayRef)
    }
    // Получаем длину массива
    logger.info("? sendIntArray 1")
    let length = localEnv.getArrayLength(arrayRef)
    logger.info("? sendIntArray 2 length: \(length)")
    // Получаем элементы массива
    var swiftArray = [Int32](repeating: 0, count: Int(length))
    localEnv.getIntArrayRegion(arrayRef, start: 0, length: length, buffer: &swiftArray)
    // Теперь можно использовать `swiftArray` как обычную Swift array
    logger.info("? sendIntArray 3 swiftArray: \(swiftArray)")
}
#endif

Вызовите из вашего приложения:

SwiftInterface.sendIntArray(intArrayOf(7, 6, 5))

Проверьте LogCat:

 I  [?‍? SWIFT] ? sendIntArray: 1
 I  [?‍? SWIFT] ? sendIntArray: 2 length: 3
 I  [?‍? SWIFT] ? sendIntArray: 3 swiftArray: [7, 6, 5]

Отправляем String из Kotlin в Swift

Задекларируйте метод:

external fun sendString(string: String)

Со стороны свифта:

#if os(Android)
@_cdecl("Java_com_habr_swiftlib_myfirstandroidproject_SwiftInterface_sendString")
public func sendString(envPointer: UnsafeMutablePointer<JNIEnv?>, clazzRef: jobject, strRef: jobject) {
    // Создаём легковесный логгер
    let logger = Logger(label: "?‍? SWIFT")
    // Получаем доступ к текущему окружению
    let localEnv = JEnv(envPointer)
    // Defer-блок отработает для удаления локального референса
    defer {
        // Удаляем локальный референс на объект
        localEnv.deleteLocalRef(strRef)
    }
    // Оборачиваем JNI string референс в `JString` объект
    // и получаем нативную Swift строку
    logger.info("✍️ sendString 1")
    guard let string = strRef.wrap().string() else {
        logger.info("✍️ sendString 1.1 exit: unable to unwrap jstring")
        return
    }
    // Используем нативную строку как только пожелаем
    logger.info("✍️ sendString 2: \(string)")
}
#endif

Вызовите из вашего приложения:

SwiftInterface.sendString("С любовью из Java")

Проверьте LogCat:

 I  [?‍? SWIFT] ✍️ sendString 1
 I  [?‍? SWIFT] ✍️ sendString 2: С любовью из Java

Отправляем Date объект из Kotlin в Swift

Задекларируйте метод:

external fun sendDate(date: Date)

Со стороны свифта:

#if os(Android)
@_cdecl("Java_com_habr_swiftlib_myfirstandroidproject_SwiftInterface_sendDate")
public func sendDate(envPointer: UnsafeMutablePointer<JNIEnv?>, clazzRef: jobject, dateRef: jobject) {
    // Создаём легковесный логгер
    let logger = Logger(label: "?‍? SWIFT")
    // Получаем доступ к текущему окружению
    let localEnv = JEnv(envPointer)
    // Defer-блок отработает для удаления локального референса
    defer {
        // Удаляем локальный референс на объект
        localEnv.deleteLocalRef(dateRef)
    }
    // Конвертируем локальный референс на JNI date объект в глобальный
    logger.info("? sendDate 1")
    guard let box = dateRef.box(localEnv) else {
        logger.info("? sendDate 1.1 exit: unable to box Date object")
        return
    }
    // Создаём `JObject` из глобального референса на date объект
    logger.info("? sendDate 2")
    guard let dateObject = box.object() else {
        logger.info("? sendDate 2.1 exit: unable to unwrap Date object")
        return
    }
    // Вызываем метод `getTime` на объекте и получаем миллисекунды с начала эпохи
    logger.info("? sendDate 3")
    guard let milliseconds = dateObject.callLongMethod(name: "getTime") else {
        logger.info("? sendDate 3.1 exit: getTime returned nil, maybe wrong method")
        return
    }
    // И распоряжаемся миллисекундами как только пожелаем :)
    logger.info("? sendDate 4: \(milliseconds)")
}
#endif

Вызовите из вашего приложения:

SwiftInterface.sendDate(Date())

Проверьте LogCat:

 I  [?‍? SWIFT] ? sendDate 1
 I  [?‍? SWIFT] ? sendDate 2
 I  [?‍? SWIFT] ? sendDate 3
 I  [?‍? SWIFT] ? sendDate 4: 1757533833096

Получаем Date объект из Kotlin в Swift

Задекларируйте метод:

external fun sendDate(date: Date)

Со стороны свифта напишем метод возвращающий строку:

#if os(Android)
@_cdecl("Java_com_habr_swiftlib_myfirstandroidproject_SwiftInterface_ping")
public func ping(envPointer: UnsafeMutablePointer<JNIEnv?>, clazzRef: jobject) -> jobject? {
    // Обернём Swift строку в `JSString` вернём её JNI референс
    return "? Pong from Swift!".wrap().reference()
}
#endif

Вызовите из вашего приложения:

Log.i("HELLO", "Pinging: ${SwiftInterface.ping()}")

Проверьте LogCat:

 I  Pinging: ? Pong from Swift!

Исполняем асинхронный Swift код из Kotlin

Задекларируйте метод:

external fun fetchAsyncData(): String

Здесь важно знать, что оператор @_cdecl не поддерживает асинхронные методы. Поэтому мы будем использовать семафор вокруг асинхронной задачи. Этот подход работает в случае вызова метода с семафором из не-UI потока, в противном случае вы столкнетесь с дедлоком. Мы разберемся как иметь дело с UI в следующих публикациях.

#if os(Android)
@_cdecl("Java_com_habr_swiftlib_myfirstandroidproject_SwiftInterface_fetchAsyncData")
public func fetchAsyncData(
    env: UnsafeMutablePointer<JNIEnv>,
    obj: jobject
) -> jstring? {
    // Создаём семафор, чтобы ожидать асинхронный таск
    let semaphore = DispatchSemaphore(value: 0)
    // Создаём переменную для результата
    var result: String? = nil
    // Стартуем асинхронный таск
    Task {
        // Симулируем длительную асинхронную операцию
        try? await Task.sleep(nanoseconds: 5_000_000_000) // 5 seconds
        // Записываем результат в переменную
        result = "Async data fetched successfully!"
        // Отпускаем семафор
        semaphore.signal()
    }
    // Блокируем текущий поток семафором в ожидании результата
    semaphore.wait()
    // Проверяем доступен ли результат
    guard let result = result else { return nil }
    // Оборачиваем строку в `JSString` и возвращаем её JNI референс
    return result.wrap().reference()
}
#endif

Вызовите из вашего приложения (не на UI потоке):

CoroutineScope(Dispatchers.IO).launch {
    Log.i("ASYNC", "Swift async call started")
    try {
        val result = SwiftInterface.fetchAsyncData()
        Log.i("ASYNC", "Swift returned: $result")
    } catch (e: Exception) {
        // Handle error
    }
    Log.i("ASYNC", "Swift async call finished")
}

Проверьте LogCat:

 I  Swift async call started
 I  Swift returned: Async data fetched successfully!
 I  Swift async call finished

Обернём Java класс в Swift

Чтобы использовать какой-либо Java класс удобно, нам нужно написать для него обёртку. Давайте разберёмся как это сделать на примере java/util/Date:

public final class JDate: JObjectable, Sendable {
    /// Название JNI класса
    public static let className: JClassName = "java/util/Date"

    /// Обёртка над глобальным JNI рефернсом, содержит в себе также метаданные класса.
    public let object: JObject

    /// Конструктор для ситуации когда вы уже имеете `JObject`.
    /// 
    /// Это может быть полезно когда вы получаете `Date` объект из Java.
    public init (_ object: JObject) {
        self.object = object
    }

    /// Этот конструктор создаёт `Date` объект в JNI без аргументов
    public init? () {
        #if os(Android)
        guard
            // Получаем доступ к текущему окружению
            let env = JEnv.current(),
            // Находим класс `java.util.Date`
            // и загружаем его напрямую или из кэша
            let clazz = JClass.load(Self.className),
            // Вызываем создание нового объекта `java.util.Date` в JNI
            // и получаем его глобальный референс
            let global = clazz.newObject(env)
        else { return nil }
        // Сохраняем объект, чтобы использовать его в методах
        self.object = global
        #else
        // Для не-Android платформ возвращаем nil
        return nil
        #endif
    }

    /// Этот конструктор создаёт `Date` объект в JNI с аргументом
    public init? (_ milliseconds: Int64) {
        #if os(Android)
        guard
            // Получаем доступ к текущему окружению
            let env = JEnv.current(),
            // Находим класс `java.util.Date`
            // и загружаем его напрямую или из кэша
            let clazz = JClass.load(Self.className),
            // Вызываем создание нового объекта `java.util.Date` в JNI,
            // передавая в него параметр с миллисекундами,
            // и получаем его глобальный референс
            let global = clazz.newObject(env, args: milliseconds)
        else { return nil }
        // Сохраняем объект, чтобы использовать его в методах
        self.object = global
        #else
        // Для не-Android платформ возвращаем nil
        return nil
        #endif
    }
}

Выше показан необходимый минимум, чтобы начать работать с этим классом. Этот код даёт вам инициализировать java.util.Date объект из ничего или обернуть уже имеющийся референс.

Окей, скелет у нас есть. Теперь ему нужны мышцы, давайте напишем ему некоторые методы!

/// Returns the day of the week represented by this date.
public func day() -> Int32? {
    // Удобный вызов `java.util.Date.getDay()`
    object.callIntMethod(name: "getDay")
}

Уверен, что вы уловили идею! Теперь сами напишите аналогичным образом методы getHoursgetMinutesgetSeconds и getTime.

Немного иной метод с входящим параметром мы снова напишем вместе:

/// Tests if this date is before the specified date.
public func before(_ date: JDate) -> Bool {
    // Удобный вызов `java.util.Date.before(Date date)`
    // который принимает `Date` объект
    // и возвращает boolean результат
    object.callBoolMethod(name: "before", args: date.object.signed(as: JDate.className)) ?? false
}

И, как вы уже догадались, следующий метод after вы напишете самостоятельно по аналогии с before.

И, полагаю, чтобы нам сделать наш класс действительно полезным нам пригодится метод возвращающий Swift Date:

/// Converts this java `Date` object to a Swift `Date`.
public func date() -> Date? {
    // Получим миллисекунды с начала эпохи методом `getTime`
    guard let time = time() else { return nil }
    // Конвертируем миллисекунды в секунды и создаём Swift `Date` объект
    return Date(timeIntervalSince1970: TimeInterval(time) / 1000.0)
}

Теперь у вас есть базовое понимание того как Swift работает с Java/Kotlin через JNI! Я надеюсь, что вам удалось успешно скомпилировать и протестировать код в вашем Android проекте.

Это всё на сегодня, друзья.

Для более глубокого погружения в мир JNI вы можете ознакомиться с детальным README в репозитории JNIKit на Github.

Будем рады вам в нашем комьюнити в Telegram и Discord, не стесняйтесь задавать вопросы!

Подпишитесь, чтобы не пропустить следующие публикации! Мы обязательно поговорим о том как опубликовать вашу Swift библиотеку на JitPack, погрузимся в более сложные JNI случаи, и... конечно же построим UI!

До связи!

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