Кроссплатформенная разработка
Наше Android-приложение написано на Swift
Внутри приложения работает почти 170 000 строк промышленного Swift-кода: наши модели, сетевой слой, бизнес-логика и более 120 моделей представления. Тот же самый код используется в приложении для iOS. Одна кодовая база — два приложения.
В прошлом месяце мы выпустили Android-приложение Polymarket для рынка США. Оно уже доступно в Google Play. Оно построено на Compose и Navigation3 и выглядит и ощущается как современное Android-приложение.
При этом большая его часть написана на Swift.
Внутри приложения работает почти 170 000 строк промышленного Swift-кода: наши модели, сетевой слой, бизнес-логика и более 120 моделей представления. Тот же самый код используется в приложении для iOS. Одна кодовая база — два приложения.
Вот как мы это сделали, что сломалось по дороге и почему мы бы повторили этот путь снова.
Вопрос, с которого всё началось
Когда мы получили разрешение на запуск в США, разработка iOS-приложения уже шла полным ходом. Android-приложения ещё не существовало.
Очевидный вопрос звучал так: «Насколько быстро мы можем выпустить Android?»
Но мы задали другой: как после выпуска Android избежать ситуации, когда нам придётся поддерживать два продукта, которые со временем начнут постепенно расходиться?
Любая команда, поддерживающая два нативных приложения, знает эту проблему. Ошибку исправили на одной платформе и забыли исправить на другой. Правило расчёта цены незаметно отличается между двумя приложениями. Две команды дважды реализуют одну и ту же функцию — немного по-разному.
Поэтому мы решили сделать ставку на Skip — набор инструментов, который компилирует Swift для Android и связывает его с Kotlin. Уже через неделю существующий сетевой слой и модели на Swift обеспечивали работу списка матчей NBA внутри Android-приложения. Этого доказательства оказалось достаточно, чтобы продолжать.
Где провести границу
У нас было преимущество: iOS-приложение было на 100% написано на Swift и разделено на пакеты с помощью Swift Package Manager. Первым шагом стала структурная перестройка. Всё, чем мы хотели делиться между платформами, отправилось в polymarket-shared. Всё специфичное для iOS осталось в polymarket-ios.
Правило было простым: делиться всем, чем только возможно.
Всё, что взаимодействовало с UIKit, SwiftUI, MapKit, PassKit или другими платформенными комплектами разработки, оставалось на стороне iOS. Всё остальное рассматривалось как кандидат на перенос в общий слой.
Чтобы эта граница действительно соблюдалась, платформенный код мы скрыли за протоколами, а нужная реализация внедрялась при запуске приложения с помощью swift-dependencies от Point-Free.
Возьмём платежи. Apple Pay существует только на iOS:
// In polymarket-ios
class ApplePayClient {
func requestPayment(amount: Double) async throws -> ApplePayResult { ... }
}
Чтобы окружающую логику можно было повторно использовать с Google Pay, общий пакет определяет протокол:
// In polymarket-shared
protocol PlatformPayClient {
func requestPayment(amount: Double) async throws -> PlatformPayResult
}
enum PlatformPayResult {
case applePay(...)
case googlePay(...)
}
А теперь часть, которая до сих пор кажется немного магической: Android-реализация написана на обычном Kotlin, использует нативный Google Pay SDK и при этом реализует протокол Swift.
class GooglePayClient(
val context: Context,
) : PlatformPayClient {
override suspend fun requestPayment(amount: Double): PlatformPayResult { ... }
}
На iOS AppDelegate внедряет клиент Apple Pay. На Android класс Application внедряет клиент Google Pay. После этого общий код просто запрашивает platformPay и получает реализацию для той платформы, на которой сейчас работает.
Тот же шаблон мы повторили для аналитики, хранилища, уведомлений и аутентификации. Когда эти границы были проведены, наша предметная логика зависела только от Foundation, Observation и небольшого набора пакетов Swift, которые уже работают на всех платформах.
А значит, она без изменений заработала и на Android.
Затем нам захотелось большего: общие модели представления
Общие модели и сетевой слой — это уже практически базовый уровень. Когда тесты начали успешно проходить на Android, мы задали вопрос, который действительно меняет экономику разработки:
А можем ли мы совместно использовать ещё и модели представления?
Наше приложение использует собственную разновидность MVVM. Каждая ViewModel — обычный класс Swift с аннотацией @Observable, основанный на небольшом общем базовом классе. Интерфейс передаёт действия пользователя через перечисление входных событий, получает состояние из наблюдаемых свойств, а навигация передаётся наружу через колбеки. Никакого UIKit. Никакого SwiftUI. Только состояние и поведение.
Именно это и сделало идею возможной, потому что объект @Observable совершенно не волнует, кто его рендерит:
- UIKit, на котором построена большая часть нашего iOS-приложения. Здесь мы придерживаемся соглашения читать состояние в
viewWillLayoutSubviewsилиupdateProperties. - SwiftUI на тех экранах, где мы его используем. Observation встроен.
- Compose на Android. Экраны читают те же свойства, а бридж наблюдения Skip запускает рекомпозицию, когда они меняются.
Одна модель представления. Три UI-фреймворка. Каждая платформа отображает её нативными средствами, а поведение между приложениями больше не может разойтись, потому что расходиться нечему: используется один и тот же объект.
Добраться до этого было непросто. Наши модели представления были основаны на ObservableObject, значительная часть кода ожидала свойства @Published и издатели Combine, а сами модели представления находились в пакете iOS. Каждую пришлось переносить в общий модуль вместе с её зависимостями и переводить на @Observable. Эта миграция принесла пользу и iOS: @Observable дал нам более чистую и быструю основу как для UIKit, так и для SwiftUI.
Чтобы доказать жизнеспособность подхода, мы выбрали один экран и полностью перенесли его — экран настроек. Через неделю настройки работали на iOS и Android с одной и той же моделью представления. В этот момент проект перестал быть просто демонстрацией технологии.
Дальше мы двигались экран за экраном. Каждая миграция попадала в main и выпускалась пользователям iOS в рамках обычного цикла обновлений. Риск оставался небольшим, код iOS становился здоровее, а объём общей продуктовой логики постоянно рос.
Сначала мы выпустили тизер
Был один вопрос, ответ на который мы категорически не хотели искать в неделю основного запуска: как вообще выглядит релиз Android-приложения, если в технологическом стеке присутствует Swift?
Поэтому сначала мы выпустили небольшой тизер — приложение списка ожидания Polymarket в Google Play. Звучит тривиально, но этот проект проверял весь конвейер:
- непрерывную интеграцию;
- упаковку Android-приложения;
- общий Swift-код;
- бридж Skip;
- аналитику;
- механизм релиза.
Это был самый маленький продукт, который мы могли отдать реальным пользователям и при этом проверить всю систему.
Когда показатели оказались нормальными, мы продолжили двигаться экран за экраном и интеграция за интеграцией, пока не получили приложение, которое хотели выпустить. В течение этих месяцев выгода постоянно накапливалась.
Пока Android-команда переносила и интегрировала приложение, iOS-команда продолжала выпускать новые функции и исправления. Android получал большую часть этой работы практически бесплатно, потому что общий слой продолжал развиваться под обоими приложениями.
Что нас больно укусило
Не всё заработало с первой попытки. Три проблемы стоили нам действительно много времени.
Время жизни объектов
Самая сложная проблема: объекты Swift с @Observable естественным образом не вписываются в модель жизненного цикла Android. На iOS время жизни объекта довольно предсказуемо. Показываете модальное окно — его модель представления создаётся непосредственно перед показом и уничтожается после закрытия. Переходите на новый экран — то же самое. Наши общие ViewModel проектировались именно под такое поведение.
Compose же вполне устраивает сохранение объектов для последующего повторного использования. В большинстве Android-приложений это преимущество. У нас это приводило к устаревшим данным, «зомби»-подпискам WebSocket, странному поведению повторной композиции и множеству других ошибок.
После нескольких неудачных попыток латать проблему мы сделали то, что действительно требовалось: создали собственные компоненты Compose, воспроизводящие поведение времени жизни объектов iOS. Мы реализовали стек навигации, представление вкладок, модальное окно, полноэкранное представление, плюс хелпер swiftViewModel, который создаёт новую модель представления для каждого нового экрана или представления и уничтожает её область при закрытии.
Мы по-прежнему не можем контролировать, когда сборщик мусора Android фактически освободит объект, поддерживаемый Swift. Но мы можем контролировать момент создания новой модели представления — а именно на этом основана наша модель мышления. После этого перенос функций стал гораздо более предсказуемым.
Combine постепенно уходит
Когда проект только начинался, значительная часть структурного кода была построена на Combine. OpenCombine нормально работает на Android. Проблема находится в мосте: Skip генерирует интерфейсы Kotlin для Swift Concurrency, но не для издателей Combine. Поэтому всё, что Android должен был вызывать напрямую, получило тонкий слой, переводящий Combine в async/await.
Например:
// In polymarket-shared
class PriceFeed {
// Combine works fine on both platforms,
// but Kotlin can't see this
let prices: AnyPublisher<Price, Never>
// So we expose a Swift Concurrency surface,
// which Skip bridges to Kotlin
var priceUpdates: AsyncStream<Price> {
prices.stream()
}
}
На iOS возникла зеркальная проблема. По мере перевода кода на Swift Concurrency и @Observable старые представления всё ещё ожидали издатели. Поэтому нам пришлось писать связующий код и в обратном направлении, предоставляя свойства @Observable в виде издателей там, где это было необходимо.
Сегодня Combine — скорее исключение. Новый код в первую очередь использует Swift Concurrency, Observation и swift-async-algorithms. Combine остаётся только в нескольких уголках системы, которые мы ещё не успели перенести. Каждая миграция уменьшает количество связующего кода, а конечная цель — избавиться от него полностью.
Человеческая проблема
Последний урок оказался связан с людьми. Как только появляется общий слой, лишь вопрос времени, когда кто-нибудь из команды в пятницу вечером импортирует UIKit прямо в общий код. Решение — не постоянная бдительность. Решение — непрерывная интеграция.
Каждый запрос на изменение, затрагивающий общий код:
- запускает общий набор тестов на эмуляторе Android;
- собирает Android-приложение;
- и только после этого он может быть объединён.
Когда этот защитный механизм появился, команда начала по умолчанию писать платформенно-независимый Swift.
Как выглядит повседневная разработка
Здесь возникает закономерный вопрос: а каково на самом деле работать с такой кодовой базой? Если вы iOS-разработчик — практически ничего не меняется. Вы пишете Swift в Xcode, а общий пакет является обычной зависимостью Swift Package Manager. Если вы Android-разработчик, проект выглядит как совершенно обычное Android-приложение: Kotlin, Compose, Gradle и Android Studio. Общий Swift поставляется в виде заранее собранных Android-библиотек. Инструмент skip export компилирует каждый общий модуль Swift в AAR, после чего Gradle подключает их как обычные зависимости.
Поэтому изменение Kotlin проходит через привычный цикл: редактирование → сборка → запуск. При изменении Swift добавляется один шаг:
- Изменить Swift-код в общем пакете.
- Запустить
skip export. Он перекомпилирует только изменившиеся модули и положит новые AAR в Android-проект. - Синхронизировать Gradle, затем собрать и запустить приложение как обычно.
На практике Android-разработчики проводят большую часть времени в Compose, создавая экраны поверх уже существующих моделей представления. Если им всё же нужно изменить Swift, дополнительный шаг раздражает, но остаётся чисто механическим.
Swift на Android: полевой отчёт
Swift работает на Linux уже около десяти лет, но Foundation с открытым исходным кодом всё ещё довольно молод, а использование Swift на Android в таком масштабе — совсем новая территория. Мы сталкивались с проблемами, сообщали о них рабочей группе Swift для Android и наблюдали, как экосистема буквально улучшалась у нас на глазах.
Если говорить откровенно, заработало гораздо больше, чем мы ожидали. Нас неоднократно удивлял объём возможностей, с которыми Skip справлялся:
- локализованные строки;
- журналы;
- встроенные ресурсы;
- отслеживание
@Observableиз Compose; - Keychain;
FileManager;- тестирование.
Экосистема тоже развивается в правильном направлении. Сделать этот путь практичным нам сильно помогли swift-dependencies от Point-Free, swift-clocks, swift-concurrency-extras, swift-identified-collections, swift-async-algorithms от Apple, swift-crypto, gRPC от Google. Ещё год назад всё это было бы гораздо сложнее.
Но шероховатости есть — особенно если смотреть глазами Android-разработчика.
- Цикл «изменить — скомпилировать». Дополнительный запуск
skip exportпостепенно начинает раздражать. Особенно сильно это ощущается, когда вы вносите изменения в Swift из Android Studio, где среда разработки пока не умеет выполнять этот шаг автоматически. - Отладка. Из Android Studio нельзя поставить точку останова в Swift. Пока.
- Размер двоичного файла. Swift действительно увеличивает APK. В нашем случае — примерно на 50 МБ при загрузке.
- Бридж для замыканий. Связывание замыканий Swift с Kotlin оказалось гораздо сложнее, чем мы ожидали. Именно здесь мы действительно упёрлись в границы возможностей инструментария, потому что замыкания Swift устроены далеко не просто.
Сделали бы мы это снова? Без колебаний. Любая альтернатива означала бы отказаться от Swift для предметной логики, которую мы умеем хорошо писать, и выбросить более 100 000 строк полезного Swift-кода, уже существовавшего на момент начала проекта.
Куда всё движется дальше
Android-приложение уже близко к функциональному паритету с версией для iOS, и мы продолжаем выпускать обновления, чтобы оно всё естественнее ощущалось на Android. Оба приложения используют одно и то же ядро.
Мы с гордостью являемся спонсорами Skip и рекомендуем этот путь любой команде с сильным опытом iOS, которая хочет продолжать писать на Swift, не отказываясь при этом от Android.
И наконец — спасибо Пьерлуиджи, Бишве и Педро из нашей Android-команды, Марку из Skip и рабочей группе Swift для Android за то, что сделали Swift на Android не просто возможным, а практически применимым.
-
Интегрированные среды разработки4 недели назадАналоги Cursor для разработчиков: что выбрать для работы с кодом
-
Новости4 недели назадВидео и подкасты о мобильной разработке 2026.29
-
Разработка4 недели назадSystem Design: как бы вы отправили 1 миллион уведомлений?
-
Новости3 недели назадВидео и подкасты о мобильной разработке 2026.30
