Всем привет! С вами Анна Жаркова, руководитель практики мобильной разработки ГК Юзтех, автор книги «Kotlin Multiplatform на практике»

Продолжаем наш рассказ про Swift Android SDK и его возможности. В предыдущей части мы рассмотрели, как устроена данная технология под капотом.

Пришло время посмотреть на реальной практике, а заодно и обсудить Swift SDK для Android. Кстати, к весне 2026 выпустили уже стабильный официальный релиз, работающий с Swift, начиная с версии 6.3.

Обратите внимание, несмотря на наличие официального релиза, данная технология не рекомендуется для прод-разработки. Также вы не напишете на ней приложение полностью.

Какие сценарии и слои сейчас поддерживаются:

  • бизнес-логика;

  • модели данных;

  • сетевой слой и хранение;

  • архитектурные компоненты;

  • фоновая обработка.

Та же история, что с KMP года 3 назад, но с оговорками.

UI рекомендуется реализовывать нативно, отдельно на Android и iOS. Также парадигма «write once, run everywhere» сейчас не работает. Только shared logic без кроссплатформенного UI-слоя.

Попробуем реализовать на Swift SDK для Android простое приложение новостей newsapi.org.

Наше приложение будет состоять из следующих модулей:

  • :app — наше Android-приложение, мы сделаем нативнуюNewsViewModel, а также UI на Compose;

  • :news-shared (Swift PM, таргет :newslib) — общий модуль, в котором будет лежать логика по работе с сетью, модели данных, бизнес-логика.

Исходники примера целиком: gitverse.ru/azharkova/swift-droid-news-sample.

Начнем с установки утилит и библиотек:

swiftly — менеджер тулчейнов Swift от самой Apple. Ставим нужную версию Swift (сейчас 6.4.1):

% swiftly install latest
Fetching the latest stable Swift release...
Installing Swift 6.4.1

Installing package in user home directory...
Swift 6.4.1 is installed successfully!
% swiftly use latest
The global default toolchain has been set to `Swift 6.4.1`

% swift --version
Apple Swift version 6.4.1 (swift-6.4.1-RELEASE)
Target: arm64-apple-macosx26.0

Android Swift SDK — сам SDK для кросс-компиляции под Android

% swift sdk install https://download.swift.org/swift-6.4.1-release/android-sdk/swift-6.4.1-RELEASE/swift-6.4.1-RELEASE_android.artifactbundle.tar.gz --checksum a356b79f8f1daa6ae3a66f4c556966ca20c0208b3659805cf30a83a0a31d6772

Android NDK: если NDK уже установлен, достаточно прописать переменную окружения ANDROID_NDK_HOME.

% curl -fSL -o ndk.zip https://dl.google.com/android/repository/android-ndk-r30-$(uname -s).zip
% unzip -qo ndk.zip
% export ANDROID_NDK_HOME=$PWD/android-ndk-r30

Готово.

Создаем обычное Android приложение и реализуем структуру подключения модуля.

Модуль news-shared отвечает за общий код. В нем у нас будет одновременно Gradle-модулем и описание Swift Package. В src/debug лежат сгенерированные библиотечные файлы jniLibs (готовые .so), в src/main/java находятся bridge-компоненты на Kotlin со связывающими конструкциями, в src/main/swift лежат Swift-исходники с Package.swift.

Пропишем структуру Package.Swift. Нам необходимо заявить наш модуль динамической библиотекой. А в качестве зависимостей прописать swift-java нужной версии. Добавить плагин JExtractSwiftPlugin из пакета swift-java для генерации Java-биндингов при сборке., прописать версию Swift, добавить флаги многопоточности. Обратите внимание, файл конфигурации мы исключаем из SPM.

Setup: Package.swift

let package = Package(
    name: "newslib",
    products: [
        .library(name: "newslib", type: .dynamic, targets: ["NewsShared"]),
    ],
    dependencies: [
        .package(url: "https://github.com/swiftlang/swift-java", from: "0.2.0"),
        .package(url: "https://github.com/swift-server/async-http-client.git", from: "1.33.1"),
    ],
    targets: [
        .target(
            name: "NewsShared",
            dependencies: [
                .product(
                    name: "AsyncHTTPClient",
                    package: "async-http-client",
                    condition: .when(platforms: [.android])
                ),
                .product(name: "SwiftJava", package: "swift-java"),
            ],
            exclude: ["swift-java.config"],
            swiftSettings: [
                .swiftLanguageMode(.v6),
                .unsafeFlags(["-Xfrontend", "-strict-concurrency=minimal"]),
            ],
            plugins: [
                .plugin(name: "JExtractSwiftPlugin", package: "swift-java"),
            ]
        ),
    ]
)

Также мы будем использовать AsyncHTTPClient для платформы Android. Это специальная библиотека для решений SwiftJava для работы с сетью.

Теперь займемся скриптом build.gradle.kts

Setup: build.gradle.kts

В Gradle-модуле news-shared указываем путь к скрипту сборки Swift-части и зависимость от SwiftKit runtime:

// news-shared/build.gradle.kts (фрагмент)
apply(from = "../swift-android.gradle.kts")

dependencies {
    implementation(libs.androidx.core.ktx)
    api(libs.swiftkit.core) // org.swift.swiftkit:swiftkit-core
}

swiftkit-core представляет собой runtime-библиотеку org.swift.swiftkit:swiftkit-core, в которой находятся SwiftArena, JNISwiftInstance и вся инфраструктура управления памятью.

Основной код по настройке приложения вы можете найти в репозитории с демо-проектом.

Setup: swift-java.config

Конфигурация генерации лежит в корне Swift-таргета и выглядит так:

{
  "javaPackage": "ru.azharkova.sampleapp.newslib.swift",
  "nativeLibraryName": "newslib",
  "mode": "jni",
  "asyncFuncMode": "legacyFuture"
}

Здесь важны все параметры: имя пакета для сгенерированных Java-классов, имя нативной библиотеки, режим jni (помним: FFM на Android не работает) и режим асинхронных функций legacyFuture включает мост CompletableFuture < - > async/await.

Приступим к реализации сетевого слоя. На iOS/macOS мы бы использовали URLSession, но FoundationNetworking на Android недоступен. Совместно с Android NDK мы можем использовать SwiftNIO. Для этого подключим библиотеку AsyncHTTPClient. Для запросов мы будем использовать конструкции специального сетевого транспорта:

private static func makeTransport() -> some ClientTransport {
    #if os(Android)
    return AsyncHTTPClientTransport(configuration: .init())
    #else
    return URLSessionTransport()
    #endif
}

#if os(Android) срабатывает для Android-таргета, #if canImport(Foundation) срабатывает для iOS/macOS. С помощью условной компиляции один и тот же Swift-код собирается и под Apple-платформы, и под Android.

Такую фабрику транспорта подключаем в обычный сетевой клиент, который пишем на Swift:

final class NetworkClient {
    private let transport: any NetworkTransport

    init(transport: any NetworkTransport = makeTransport())

    func execute<Request: NetworkRequest>(_ request: Request) async throws -> NetworkResponse
}

Но работать с сырыми данными будет неудобно, некрасиво. Нам потребуются дополнительные компоненты - различные декодеры и мапперы. Да и об архитектуре надо не забывать. Соединим это все в NewsApiService:

final class NewsApiService: NewsServicing {
    private let apiKey: String
    private let client: NetworkClient
    private let responseDecoder: NetworkResponseDecoder
    private let articleMapper: NewsArticleMapper
    private let errorMapper: NewsServiceErrorMapper

    func fetchTopHeadlines(country: String) async throws -> [NewsArticle] {
        //...
        do {
            let response = try await client.execute(TopHeadlinesRequest(...))
            let payload = try await responseDecoder.decode(
                NewsAPIResponse.self, from: response.data)

            return payload.articles.map(articleMapper.map)
        } catch {
            throw await errorMapper.map(error)
        }
    }
}

Выбор за тем, как будут называться компоненты в вашем коде, как всегда, за вами.

Теперь нам нужна публичная точка вызова нашего кода со стороны Java/ Android. Этот же компонент будет прокси для нашего кода. И вот его мы в экспортированном виде будем вызывать со стороны нативного приложения:

public final class NewsService {
    private let service: NewsApiService

    public init(apiKey: String) throws {
        self.service = try NewsApiService(apiKey: apiKey)
    }

    public func fetchTopHeadlines(country: String) async throws -> [NewsArticle] {
        try await service.fetchTopHeadlines(country: country)
    }
}

Сгенерированный jextract-класс NewsService виден из Kotlin как обычный класс. Вызывать и инициализовать мы его будем не напрямую в ViewModel, а сделаем еще один прокси (Bridge). Bridge на Kotlin хранит инстанс сгенерированного компонента из Swift-класса, а также вызов его метода через suspend:

class NewsServiceBridge(apiKey: String) {
    private val swiftNewsService: NewsService = NewsService.init(apiKey)

    suspend fun fetchTopHeadlines(country: String): List<NewsArticleUi> {
        val articles = swiftNewsService.fetchTopHeadlines(country).awaitSwift()
        return articles.map(::toUiArticle)
    }
}

Обратите внимание на .awaitSwift(): это наше Kotlin-расширение для CompletableFuture.

Подключим все в ViewModel. На стороне Android используем компонент из пакета Jetpack. В него мы добавим вызов сервиса через бридж:

class NewsViewModel(
    private val apiKey: String = BuildConfig.NEWS_API_KEY,
    private val country: String = BuildConfig.NEWS_COUNTRY
) : ViewModel() {
    private val newsServiceBridge: NewsServiceBridge?
    //...

    fun refresh() {

        viewModelScope.launch {
            _uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)

            val session = newsServiceBridge

            try {
                val articles = session.fetchTopHeadlines(country = country)
                _uiState.value = NewsUiState(articles = articles)
            } catch (throwable: Throwable) {...}
        }
    }
}

Однако, в такой реализации оно не будет работать. Даже не скомпилируется. Дело в том, что нам нужно прокинуть связку для управления памятью Swift в наш Kotlin-код. Как это сделать, посмотрим ниже.

Кроссплатформенные решения всегда требуют более внимательного отношения к управлению памятью, потому что кода генерируется больше, генерируются промежуточные стабы и прокси. Объекты должны удерживаться и освобождаться в Swift-куче, при этом оставаясь видимыми для Kotlin/Java GC. Это требует дополнительной работы и синхронизации двух менеджеров памяти. Также нужно больше внимания к переключению контекста как внутри платформы, так и между ними.

JVM управляет памятью через сборщик мусора (garbage collection), а Swift использует автоматический подсчёт ссылок (ARC). Эти две модели ничего не знают друг о друге. Java GC не может видеть Swift-типы и Swift-кучу: когда jextract передаёт Java-объекту Swift-инстанс, на самом деле передаётся opaque-указатель (адрес в памяти). По этому адресу объект находится в Swift-куче, и GC его не видит вообще.

Для решения этой проблемы используется механизм SwiftArena, в которой регистрируются адреса Swift-объектов, вызываемых из Java:

public interface SwiftArena {
    void register(SwiftInstance instance);

    static ClosableSwiftArena ofConfined() {
        return new ConfinedSwiftMemorySession();
    }

    static SwiftArena ofAuto() {
        ThreadFactory cleanerThreadFactory = r -> new Thread(r, "AutoSwiftArenaCleanerThread");
        return new AutoSwiftMemorySession(cleanerThreadFactory);
    }
}

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

Есть несколько видов специальных арен:

Интерфейс / класс

Ответственность

Жизненный цикл

ClosableSwiftArena

Расширяет SwiftArena и AutoCloseable — для использования с try-with-resources

Явный (close())

ConfinedSwiftMemorySession

ClosableSwiftArena, ведёт список ресурсов

Явный (close())

AutoSwiftMemorySession

Чистку запускает SwiftCleaner через GC

Неявный (GC-driven)

AllocatingSwiftArena

Реализация FFM: связывает время жизни Swift-объекта с MemorySegment

Явный или GC

Для корректной работы нам потребуется арена с явным механизмом закрытия и вызова очистки и арена с автоочисткой. Арена AutoSwiftMemorySession при регистрации инстанса объекта создает и хранит специальный метод очистки, ссылка на это действие будет позже передана компоненту SwiftCleaner:

final class AutoSwiftMemorySession implements SwiftArena {
    private final SwiftCleaner swiftCleaner;

    public AutoSwiftMemorySession(ThreadFactory cleanerThreadFactory) {
        this.swiftCleaner = SwiftCleaner.create(cleanerThreadFactory);
    }

    @Override
    public void register(SwiftInstance instance) {
        Objects.requireNonNull(instance, "value");

        // Гарантируем, что instance не захватится во время очистки,
        // поэтому можем игнорировать предупреждение ниже.
        var cleanupAction = instance.$createCleanup();
        swiftCleaner.register(instance, cleanupAction);
    }
}

Возвращаемся к нашему коду. Посмотрим на сгенерированный Java-код для нашего NewsService. Несмотря на то, что у нас этот параметр указан не был, инициализатор Swift типа явно требует SwiftArena на Java:

// Инстанс Swift
public final class NewsService {
    public init(apiKey: String) {}
}

// JNISwiftInstance
public static NewsService init(java.lang.String apiKey, SwiftArena swiftArena) throws Exception {
    return NewsService.wrapMemoryAddressUnsafe(NewsService.$init(apiKey), swiftArena);
}

private NewsService(long selfPointer, SwiftArena swiftArena) {
    SwiftObjects.requireNonZero(selfPointer, "selfPointer");
    this.selfPointer = selfPointer;

    // Only register once we have fully initalized the object
    // since this will need the object pointer.
    swiftArena.register(this);
}

public static NewsService wrapMemoryAddressUnsafe(long selfPointer, SwiftArena swiftArena) {
    return new NewsService(selfPointer, swiftArena);
}

public static NewsService wrapMemoryAddressUnsafe(long selfPointer) {
    return new NewsService(selfPointer, SwiftMemoryManagement.DEFAULT_SWIFT_JAVA_AUTO_ARENA);
}

Поэтому нам нужно будет его передать.

Арена регистрирует объект только после полной инициализации, поскольку ей нужен валидный указатель. Вызов метода у обёртки представляет собой обращение по указателю $memoryAddress() к настоящему Swift-инстансу:

Передаваемый инстанс арены должен быть проинициализирован явно: без настоящей привязки SwiftArena поиск объекта по указателю может привести к аварийному завершению.

Обратную сторону жизненного цикла представляет функция уничтожения. Сгенерированный $createDestroyFunction возвращает Runnable, который вызывает SwiftObjects.destroy по сохранённому указателю и адресу метаданных типа:

@Override
public Runnable $createDestroyFunction() {
    long self$ = this.$memoryAddress();
    long selfType$ = this.$typeMetadataAddress();
    if (CallTraces.TRACE_DOWNCALLS) {
        CallTraces.traceDowncall("NewsService.$createDestroyFunction",
            "this", this,
            "self", self$);
    }
    return new Runnable() {
        @Override
        public void run() {
            if (CallTraces.TRACE_DOWNCALLS) {
                CallTraces.traceDowncall("NewsService.$destroy", "self", self$);
            }
            SwiftObjects.destroy(self$, selfType$);
        }
    };
}

При этом объект остаётся видимым для GC. Wrapper в Java-куче собирается GC как обычно, а вот Swift-память освобождается только тогда, когда арена (или cleaner) вызовет destroy-функцию.

Хорошо, но мы забыли указать, как мы эту арену создаем, откуда берем и к чему она привязана. Конечно же, к жизненному циклу ViewModel. Создаём ClosableSwiftArena через SwiftArena.ofConfined(), передаём её в bridge, а в onCleared() закрываем и вызываем очистку:

class NewsViewModel(
    private val swiftArena: ClosableSwiftArena = SwiftArena.ofConfined()
) : ViewModel() {

    private val newsServiceBridge: NewsServiceBridge?

    init {
        var createdSession: NewsServiceBridge? = null
        try {
            createdSession = NewsServiceBridge(
                swiftArena = swiftArena
            )
        } catch (throwable: Throwable) {
            initializationError = throwable
        }

        newsServiceBridge = createdSession
    }

    override fun onCleared() {
        newsServiceBridge?.close()
        super.onCleared()
    }
}

А в bridge при этом реализует интерфейс AutoCloseable для корректного вызова очистки арены для финального освобождения ресурсов:

class NewsServiceBridge(
    private val swiftArena: ClosableSwiftArena
) : AutoCloseable {
    // Здесь хранится именно generated Swift instance, а не Kotlin service.
    private val swiftNewsService: NewsService = NewsService.init(swiftArena)

    suspend fun fetchTopHeadlines(country: String): List<NewsArticleUi> {
        val articles = swiftNewsService.fetchTopHeadlines(country, swiftArena).awaitSwift()
        return articles.map(::toUiArticle)
    }

    override fun close() {
        swiftArena.close()
    }
}

Итоговая цепочка: ViewModel уничтожается → onCleared() → bridge.close() → swiftArena.close() → destroy всех зарегистрированных Swift-объектов.

Управление памятью всегда связано с многопоточностью. JVM concurrency изначально несовместима со Swift concurrency. Но у нас есть решение. При этом мы можем выбрать, сделать все быстро и автоматически через конвертацию CompletableFuture <-> async/await или вручную через @_cdecl, подключение/отключение указателей к скоупу исполнения (как в графе для Kotlin/Native). Мы пойдем простым путем. Для поддержки CompletableFuture < - > async/await в конфигурационном файле добавим строку "asyncFuncMode": "legacyFuture". И на этом все. Сгенерированный метод будет выглядеть так:

public java.util.concurrent.Future<NewsArticle[]> fetchTopHeadlines(java.lang.String country,
SwiftArena swiftArena) {
    org.swift.swiftkit.core.SimpleCompletableFuture<long[]> future$ = new
org.swift.swiftkit.core.SimpleCompletableFuture<long[]>();
    NewsService.$fetchTopHeadlines(country, this.$memoryAddress(), future$);
    return future$.thenApply((futureResult$) -> {
      return Arrays.stream(futureResult$).mapToObj((pointer) -> {
        return NewsArticle.wrapMemoryAddressUnsafe(pointer, swiftArena);
      } // render(_:_:) @ JExtractSwiftLib/JNISwift2JavaGenerator+JavaTranslation.swift:2024
      ).toArray(NewsArticle[]::new);
    } // render(_:_:) @ JExtractSwiftLib/JNISwift2JavaGenerator+JavaTranslation.swift:2024
    );
}

Swift async-функция автоматически маппится в Future, а каждый элемент результата (указатель) оборачивается в Java-обёртку через wrapMemoryAddressUnsafe с той же ареной. Для сахара и удобства можно добавить расширение в Kotlin для вызова Future внутри корутины и корректной конвертации между ними:

private suspend fun <T> Future<T>.awaitSwift(): T = withContext(Dispatchers.IO) {
    get()
}

Добавляем вызов в наш bridge:

suspend fun fetchTopHeadlines(country: String): List<NewsArticleUi> {
    val articles = swiftNewsService.fetchTopHeadlines(country, swiftArena).awaitSwift()
    return articles.map(::toUiArticle)
}

Собираем Swift-модуль кросс-компиляцией под Android (swift build --arch aarch64-unknown-linux-android24), на выходе получаем .so, передаём на устройство через adb push, не забыв вручную положить рядом libc++_shared.so. После Gradle-сборки в build/generated/jniLibs/arm64-v8a оказывается следующий набор библиотек:

Тут и libnewslib.so / libnewsshared.so (наша логика), и среда исполнения Swift: libswiftCore.so, libswift_Concurrency.so, libFoundation.so, libdispatch.so, libSwiftJava.so и десяток других. Сгенерированного кода очень много, и держать его под контролем версий смысла нет.

Интересный нюанс: папка build модуля news-shared весит 992 МБ. Сама .so в приложении заметно скромнее, APK вырос примерно на 8 МБ, но процесс сборки тяжёлый и долгий.

Получаем Android-приложение, в котором лента новостей загружается Swift-кодом через AsyncHTTPClient, маппится в UI-модели и показывается в Compose:

Репозиторий сэмпла: gitverse.ru/azharkova/swift-droid-news-sample.

Когда же будет хорошо (приемлемо) это использовать:

  • Just for fun — хобби-проекты, эксперименты, участие в развитии технологии;

  • Расширение существующего iOS-приложения на Android — когда бизнес-логика уже написана на Swift и команда хочет переиспользовать её без переписывания на Kotlin.

Для всего остального подойдут более зрелые инструменты.

Что было бы полезно получить в дальнейшем:

  • Полная поддержка async/await для Android (сейчас реализуется через обходное решение на CompletableFuture);

  • Кросс-компиляция и поддержка тестирования;

  • Расширения для VSCode.

Публичная дорожная карта лежит на GitHub: github.com/orgs/swiftlang/projects/17.

Источники

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