Site icon AppTractor

Over The Air обновления Kotlin-кода для Android-приложений

Как мы сделали настоящие OTA-обновления для Kotlin-приложений: не только UI, но и бизнес-логика, функции и состояние — при этом основную тяжёлую работу выполняет сам рантайм Android.

Мы научили Android-приложения обновлять самих себя без Play Store

Каждая Android-команда знает этот ритуал. Вы создаёте функцию, исправляете ошибку, меняете экран — неважно, что именно, — а потом ждёте. Ждёте сборку, проверку, поэтапное развёртывание и ещё пару дней, пока пользователи действительно нажмут «Обновить». Исправление на десять секунд превращается в событие на три дня.

Нам это надоело. Поэтому мы создали Ketoy — способ, позволяющий уже выпущенному Kotlin-приложению загружать новый код по воздуху и выполнять его нативно, без переустановки и без повторного прохождения через магазин приложений. И самое интересное здесь не в том, что он обновляет UI. Обновлять UI умеют многие. Ketoy обновляет бизнес-логику, целые функции и состояния за ними, и всё это без необходимости переписывать Android с нуля.

Вот как мы это сделали и какая одна архитектурная ставка позволила всему этому заработать.

Стена, в которую упираются все

Когда говорят про «обновления по воздуху» на Android, обычно имеют в виду одно из двух: server-driven JSON-разметку, которая рисует несколько кнопок, или WebView, притворяющийся нативным интерфейсом. И оба подхода упираются в одну и ту же стену, как только вам нужно настоящее поведение приложения.

Потому что настоящая функция — это не просто макет. Это ViewModel с состоянием, которое переживает поворот экрана. Это корутина, которая обращается к сети, StateFlow, который собирает UI, кусок логики, решающий, что происходит после нажатия «Купить». Сложность приложения никогда не заключалась в том, чтобы нарисовать прямоугольник на экране; она всегда была во всём, что находится за этим прямоугольником.

Поэтому честный вопрос звучал так: можно ли безопасно доставлять новую логику в уже работающее приложение и при этом сохранять нативную производительность? Не «можно ли подделать экран». Можно ли обновить саму программу?

Наивный ответ — «просто скачайте какой-нибудь байткод и запустите его». Но нельзя просто скачать DEX и передать его Android рантайму: это кошмар с точки зрения безопасности и нарушение политик. Нельзя и запускать Kotlin-компилятор на телефоне: он огромный, а Compose compiler plugin выполняет сложную работу во время сборки, которую устройство не может просто воспроизвести. Каждый очевидный путь оказывается тупиком.

Ставка: не перестраивать платформу, а использовать её

Вот идея, на которой держится вся система.

Мы компилируем Kotlin — включая Compose, корутины и ViewModel — на сервере в собственный компактный формат байткода, доставляем его на устройство и запускаем внутри небольшого интерпретатора. Но как только возникает задача, которую Android уже умеет выполнять хорошо, мы сразу отдаём её обратно Android.

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

Представьте нашу VM как очень дисциплинированного руководителя проекта. Она сама не кладёт кирпичи. Она знает план, вызывает нужного специалиста для каждой задачи и не мешает ему работать.

Где мы позволяем Android делать тяжёлую работу

Именно здесь архитектура начинает себя оправдывать. Три из самых сложных проблем Android — рендеринг, состояние и lifecycle — мы не решали. Мы просто маршрутизировали их.

Рендеринг → настоящий Compose, настоящий ART. Когда наш байткод говорит «нарисовать HorizontalPager с этими карточками», мы не реализуем заново пейлдинг, физику fling или snapping. Мы вызываем настоящий androidx.compose.foundation.pager.HorizontalPager. Он отрисовывается через реальный Compose runtime, скомпилированный Android ART в нативный код.

Интерпретатор интерпретирует намерение; пиксели на 100% нативные. Поэтому экран Ketoy ощущается как обычный нативный экран, а не как медленная эмуляция — потому что он и есть нативный.

Состояние → snapshot-система Compose. Здесь было особенно легко пойти неправильным путём. Мы могли создать собственное реактивное хранилище состояния. Но не стали. Когда байткод выполняет var count by remember { mutableStateOf(0) }, мы создаём настоящий mutableStateOf.

Чтение этого состояния во время композиций регистрирует настоящую зависимость Compose; запись вызывает настоящую рекомпозицию. У нас нет параллельной системы состояния. Compose уже прекрасно решил эту задачу, поэтому она остаётся ответственностью Compose, а рекомпозиция «просто работает».

Lifecycle → настоящие ViewModel. @KetoyViewModel превращается в настоящий androidx.lifecycle.ViewModel, получает обычный скоуп, кэшируется стандартным способом и так же стандартно отменяется в onCleared().

Его viewModelScope.launch { … } работает в настоящем скоупе корутины. Поэтому состояние переживает поворот экрана, а корутины корректно отменяются — не потому, что мы сами написали всю эту инфраструктуру, а потому что передали задачу фреймворку, в котором она уже реализована.

Этот принцип повторяется повсюду. Всё, к чему приложению разрешено обращаться — сеть, хранилище, навигация, — проходит через capability registry: тщательно отобранный список нативных функций, которые host-приложение предоставляет загруженному коду. Загруженный код может вызвать только то, что ему разрешило приложение. Он не может напрямую обращаться к произвольному Android API, использовать рефлекцию или запускать неструктурированные coroutines.

Sandbox — это модель безопасности, а capability registry — дверь.

Что на самом деле находится в загружаемом пакете?

Подписанный бандл, который мы называем .ktx. Это сжатый Brotli байткод плюс пул строк, манифест с перечнем необходимых capabilities и подпись Ed25519. Размер небольшой: полноценный экран занимает всего несколько килобайт.

На устройстве процесс в хорошем смысле скучный: проверить подпись, отклонить всё, что не соответствует ключу приложения, затем загрузить и выполнить. Нет подписи — нет выполнения, никогда. Поскольку телефон выполняет ноль ИИ и ноль компиляции, поверхность атаки остаётся небольшой. Вся тяжёлая, сложная и зависящая от версий работа — запуск K2, запуск Compose compiler plugin — происходит на контролируемом нами сервере с жёстко зафиксированным набором инструментов. Приложению нужно только уметь читать байткод и вызывать функции.

Такое разделение оказалось подарком и для нас как для сопровождающих проекта. Мы компилируем в одной зафиксированной версии Kotlin и в одной версии Compose. При этом потребительское приложение может перейти на более новый Kotlin, новый AGP и новый Gradle, а наш встроенный компилятор продолжит создавать совместимые пакеты. Мы это проверили: host-приложение на версиях Kotlin и AGP, заметно опережающих наши, нормально отрисовывало наши бандлы. Нам не приходится гоняться за каждым upstream-релизом во всём стеке; мы поддерживаем только границу взаимодействия.

Что мы намеренно не стали создавать

Доверие требует честно говорить о границах. Ketoy силён именно потому, что он узкий по своему охвату.

Мы не поддерживаем произвольный Kotlin — только определённое подмножество. Никакого загружаемого DEX. Никаких рефлексий внутри sandboxed-кода. Никакая случайная сторонняя библиотека не может просто появиться из ниоткуда. Никаких XML views и Fragments. Если вам нужен WebView или диалог с CAPTCHA, они по замыслу остаются нативными — некоторые вещи должны жить в доверенной части host-приложения, и делать вид, что это не так, было бы безрассудно.

Мы также не пытались сделать максимальную скорость интерпретатора самоцелью, потому что она нам не нужна. Интерпретатор выполняет только тонкую координирующую логику: где-то копирует data class, где-то делает ветку when. Дорогие операции — макет, анимации, рендеринг — вообще не проходят через нашу VM. Поэтому утверждение «интерпретатор медленнее нативного кода» верно, но совершенно несущественно — это всё равно что переживать, что руководитель проекта печатает медленнее, чем каменщик кладёт кирпичи.

Что мы получили

В итоге получилось то, что раньше казалось недостижимым: Kotlin-приложение, способное безопасно менять собственное поведение уже в продакшене и при этом ощущаться полностью нативным.

Ошибка в checkout flow? Перекомпилируйте экран на сервере, опубликуйте бандл — и уже запущенные приложения подхватят его без проверки магазина в критическом пути. Новая функция для части пользователей? Доставьте её пакет, включите через гейт — готово. Карточка с ценой должна иначе реагировать на действия? Это логика и состояние, и оба прибудут совсем скоро.

Разработчик пишет обычный, идиоматичный Compose и Kotlin. HorizontalPager, remember, mutableStateOf, viewModelScope.launch, StateFlow — тот код, который вы и так написали бы. Он компилируется в bundle. Bundle доставляется. Телефон выполняет его нативно. Никому не нужно изучать странный DSL, и никому не приходится ждать три дня ради исправления одной строки.

Главный урок

При создании такой системы очень легко поддаться соблазну построить всё. Собственный рендеринг, собственный движок состояний, собственное управление жизненным циклом. Так вы получите второсортную копию Android, которую придётся поддерживать вечно.

Лучшее решение оказалось меньше и, если честно, интереснее: создать максимально тонкий слой, который решает, что должно произойти, — небольшую VM, песочницу, интерпретатор — а затем отойти в сторону и позволить ART и Compose делать то, что они уже умеют делать превосходно.

Мы не изобретали платформу заново. Мы научили приложение обращаться домой за новыми инструкциями и доверять платформе их выполнение. Оказалось, что именно этого всегда и было достаточно для «нативного OTA».

Попробовать Ketoy: https://ketoy.dev

Источник

Exit mobile version