
Всем привет! С вами Анна Жаркова, руководитель практики мобильной разработки ГК Юзтех, автор книги «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); } }
Как вы видите, также используется привязка к определенным потокам.
Есть несколько видов специальных арен:
Интерфейс / класс |
Ответственность |
Жизненный цикл |
|---|---|---|
|
Расширяет |
Явный ( |
|
|
Явный ( |
|
Чистку запускает |
Неявный (GC-driven) |
|
Реализация FFM: связывает время жизни Swift-объекта с |
Явный или 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.
Источники
Swift SDK for Android — Getting Started: https://www.swift.org/documentation/articles/swift-sdk-for-android-getting-started.html
Nightly Swift SDK for Android: https://www.swift.org/blog/nightly-swift-sdk-for-android/
SwiftJava (репозиторий): https://github.com/swiftlang/swift-java
DeepWiki по swift-java: https://deepwiki.com/swiftlang/swift-java/
FFM API (JEP 454): https://docs.oracle.com/en/java/javase/21/core/foreign-function-and-memory-api.html
Примеры для Android: https://github.com/swiftlang/swift-android-examples
Семпл из статьи: https://gitverse.ru/azharkova/swift-droid-news-sample
Skip.Tools: https://skip.tools/