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 latestFetching the latest stable Swift release...Installing Swift 6.4.1Installing package in user home directory...Swift 6.4.1 is installed successfully!% swiftly use latestThe global default toolchain has been set to `Swift 6.4.1`% swift --versionApple 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:

// Инстанс Swiftpublic final class NewsService {    public init(apiKey: String) {}}// JNISwiftInstancepublic 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 по сохранённому указателю и адресу метаданных типа:

@Overridepublic 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$ = neworg.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.

Источники

ссылка на оригинал статьи https://habr.com/ru/articles/1096100/