Swift на Android: путь к сингулярности. От анонса SwiftJava до полноценного приложения. Часть 2


Всем привет! С вами Анна Жаркова, руководитель практики мобильной разработки ГК Юзтех, автор книги «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/
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.