Последние полгода работали с товарищем над двумя приложениями. Оба с Kotlin Multiplatform, одно десктопное, уже в альфе, другое — на 4 платформы: android, ios, web, backend. Много чего повидали, хочу поделиться опытом.
Дисклеймер. Статья содержит последствия массового использования expect/actual, сцены жестокого обращения с XCode и эпизоды длительного ожидания нотаризации на релизных сборках под OSX. Не рекомендуется лицам, планирующим запуск KMP-проекта на несколько платформ без предварительной консультации с психотерапевтом.
Рационализация выбора: почему Kotlin Multiplatform
KMP во многом похож на андроид-разработку, а я в прошлом писал под Android около 6 лет. Сейчас на работе пишу бэкенд на Kotlin. Вот и выбрал Kotlin и Compose Multiplatform.
Но ведь надо еще и обосновать выбор, поэтому далее последует рационализация. Сперва пройдусь коротко по очевидным преимуществам и недостаткам.
Очевидные плюсы Kotlin Multiplatform
-
Можно писать Android, IOS, Desktop, Web и переиспользовать неспецифичный для платформы код.
-
Есть библиотеки как для написания агентов (koog, mcp-sdk), так и для развертывания локальных моделей (cactus).
-
Богатейшая экосистема, если таргеты — jvm (desktop, android). Есть SDK для всего, включая популярных LLM-провайдеров.
-
Kotlin — хорошо сделанный ЯП, уважающий обратную совместимость (привет, Dart/Flutter).
-
Compose Multiplatform — UI c hot reload, который легко писать, и почти без багов (тут Flutter на мой вкус лучше — меньше думаешь о платформе, когда пишешь).
-
Нативные билды под android (на desktop — обертка над jar, для ios — Skia).
Очевидные минусы
-
Чем больше платформ вы поддерживаете, тем чаще приходится дописывать специфичный код и тем больше ограничений по библиотекам.
-
Desktop приложения много весят из-за поставки JVM, Swing и прочих тяжестей самого фреймворка. Мобилки тоже весят чуть больше, чем нативные реализации.
-
Web еще в бете. Есть баги. Не все библиотеки поддерживают веб. Нужно думать о SEO заранее.
-
С IOS тоже не очень гладко. Например, debugging выглядит как MVP, а не полноценный дебаггер. Интероп со свифтом хуже, чем Kotlin с Java/Kotlin.
-
Desktop UI ощущается как мобильное приложение, которое насильно затащили на desktop.
Неочевидные минусы
Хочу поделиться менее очевидными вещами, которые всплыли в процессе разработки.
Бойтесь релизной сборки
Я закладывал на релиз максимум 1 день, не считая проверки от сторов. Фактически подготовка релизной версии для macOS заняла около недели чистого времени, буквально убил отпуск на это. В чем сложности? Давайте перечислю:
-
Для сборки нужны 2 app id — один для JRE, другой для самого приложения.
-
Потребуется минимум 3 ключа — два для store, один для того, чтобы можно было отправить кому-то dmg напрямую.
-
Сборки для сторов и для отправки напрямую — разные и собираются по-разному. Для последней нужна нотаризация.
-
Нотаризация длится минут 15. Если у вас есть нативные библиотеки, вы наверняка запустите ее раз 5 для отладки.
-
Для сборки aarch64 и X86/64 нужны разные версии jkd (или CI с разными OS).
-
Для того, чтобы выложиться на TestFlight, нужна минимально 18 версия JDK (jdk issue);
-
Нельзя просто подключить нативную библиотеку и собрать релизную сборку.
‣ Надо будет положить нужные ресурсы в jar до подписи.
‣ Придется подумать об обратной совместимости со старыми версиями macOS (63% всё еще сидят на каталине).
‣ Придется писать исключения для Proguard (обфускация и сжатие).
‣ Что-то в релизной сборке всё равно сломается. У меня за минуту до релиза сломался grpc.
Выше перечислил первое, что пришло на ум. Жаль, что не вел подробные записи.
Если вам предстоит готовить релизную версию под macOS, рекомендую посмотреть доки от Compose Multiplatform тут и пару статей на Medium: 1, 2. Мне они помогли закрыть процентов 70 проблем. И сразу хочу предупредить, что решить проблемы не разбираясь не получится, будь у вас хоть все токены мира и Codex 5.3 Extra high.
Нельзя найти сеньора под KMP
Посудите сами, кого вы возьмете с рынка?
Большинство разработчиков имеют экспертизу в одной платформе. Если вы поддерживаете 3 — Android, Ios и Web, вам придется искать того, кто будет знать 3 платформы и сам фреймворк, т.е. целых 4 области знаний. Такой разработчик точно не будет сеньором (и даже мидлом) в четырех платформах.
Почему это важно?
Когда понадобится решить специфичную для платформы проблему, нужны глубокие знания. Не всегда будет готовая библиотека. Не всегда решение легко нагуглить. Не всегда проблему сможет решить Claude или Codex. И еще с меньшей вероятностью LLM поможет в KMP-приложении, чем в популярном стеке.
Личный пример — я пытался засунуть в IOS-версию сертификаты минцифры и не смог за пару дней. Как я понял, это вообще невозможно. А на андроид ту же задачу решил минут за 15, включая время на тест на девайсе.
Думал, что без знаний IOS мне проще писать на KMP под IOS. Фактически оказалось, что нет. — Автор статьи
Бедность и оверинжиниринг
Когда я подбираю библиотеку, хочу выбрать такую, чтобы переиспользовать как можно больше логики. Это забирает один из главных плюсов KMP — java-экосистему.
Пример — использование дат. Даты в любом случае будут использоваться в общем коде, а из кроссплатформенных — kotlinx — библиотек у нас есть только kotlinx-datetime, которая еще в альфе. Проблема альфы в том, что контракты могут меняться.
Это значит, что если вы используете 2 библиотеки, которые внутри тянут разные версии kotlinx-datetime, — вы упадёте на компиляции. Выбор — искать такие версии библиотек, где их зависимости совпадут по контрактам альфа-зависимостей. Именно это случилось с нами, когда мы попробовали затянуть Koog. Issue до сих пор висит, с сентября прошлого года.
Другой пример — Web не имеет Dispatchers.IO. Это значит, что нельзя использовать его в общем коде, а значит придется иметь дополнительные ветвления. Этих ветвлений вообще будет много. Особенно, если вы захотите вынести общий jvm код — а почему нет, если код андроида шарится с бекендом и/или десктопом?
Постоянная боль выбора и немного об ослах
Особенно больно принимать решения. Приходится работать в духе “The Lesser Evil”. Приведу несколько примеров.
Выносить общий jvm код или нет? Если да, то он все равно будет не совсем общий, Android иногда отличается — той же рефлексией. Если нет — то как же неприятно в одном репозитории писать одно и то же рядом. Скажем, при поддержке трех платформ — IOS, Android и Desktop — вопрос лишь в том, писать ли одно и тоже 2 раза или 3.
Переиспользовать ли код бекенда? C одной стороны, удобно иметь одни и те же модельки, одну и ту же логику верификации данных. С другой — добавлять время на компиляцию и бекенда, и мобильных приложений, завязывать бекенд на java-версию андроида.
Помните, философ Буридан описал осла, который умер от голода между двумя одинаковыми стогами сена? Выбор логгера занял у меня часа 3 или 4. Хотелось logback для бекенда и общую библиотеку, которая бы находила sane defaults для остальных платформ. То же самое и с другими либами. Осёл хотя бы не знал, что каждый стог тянет несовместимую версию kotlinx-datetime.
Как гонять тесты? IOS и Android — долго. В android — надо еще контекст мокать. Может, принести еще и Desktop, только для тестов? Но это же лишняя платформа. А что, если и десктопную версию поддержать? Любое решение — ошибочное.
Необходимость открывать XCode — особая форма наказания.
Мне попадались треды на реддит, где люди выбирают Flutter, а не KMP, только потому что с первым не надо открывать XCode.
most painful for me in any mobile development was not some framework glitches or something like this but Xcode. I will definitely fight with biggest pleasure on any problem/challenge that i will face in integrating Flutter with Native IOS stuff (even writing C++) but minimise my intervention into Xcode tool
И я могу их понять. Что не так с XCode?
-
Долго открывается, тормозит, медленно индексирует, жрет много RAM/CPU.
-
Писать код неудобно — долго работают автодополнения, которые часто еще и бесполезны. Долгая обратная связь при ошибках компиляции.
-
Ошибки случайны. Иногда всё просто ломается. Кажется, на этом можно было бы построить рандомайзер.
По статистике Stackoverflow за 2025, XCode желают 6.1% разработчиков. Согласно исследованиям Sexual Disorder примерно у 5% наблюдается предрасположенность к мазохизму. Корреляция не означает причинно-следственную связь. Но и не исключает.
Как мы писали Агента
С агентным кодом проблем практически не было. Интернет завален примерами реализаций агентов. У всех популярных провайдеров LLM есть свои java-библиотеки и REST API. JetBrains развивает Koog и поддерживает mcp-sdk, доступны legacy-решения вроде langchain4j.
Но давайте обо всем по порядку.
Почему не langchain4j
Не видел смысла его использовать. Слишком раздуто, завалено багами и, предположительно, неактуальными legacy-решениями. Предположительно неактуальными, потому что 3 года назад индустрия только развивалась и пробовала всякое. Теперь это в кодовой базе langchain4j. Связываться не хотелось, учитывая, как много всего там написано.
Почему не koog
Koog всё еще в альфе. Мы попробовали с ним поиграться и наткнулись на ряд проблем, которые я описал в разделе Проблемы использования фреймворков статьи Агент на Kotlin без фреймворков. Основное — это баги и лишние зависимости. Например, если я делаю агента с Гигачат или через поставщика моделей в Россию без VPN (AiTunnel), зачем мне весь код на поддержку других моделей, вроде simpleOpenAIExecutor.
О реализации своего решения
Хотелось контролировать и понимать все аспекты кода агента, и я морально был готов за это заплатить. Тем более что альтернативы — раздутый langchain4j и koog в альфе.
Детали упрощенной версии агента раскрыты в статьях:
Сверх этого написано немного. Специфичный код: вроде классификации, саммаризации, дополнения данных из RAG, обработки ошибок — реализованы в отдельных нодах. В отличие от Koog, который тащит в зависимостях OpenTelemetry, мы в коде агента даём лишь одну функцию-callback на переход по нодам графа.
Разработчик Koog писал мне в ТГ:
У графов есть ровно 3 преимущества:
Визуализация
Вложенные ивенты в OpenTelemetry (за счет явной структуры, проще делать эвал + объяснять что происходит в агенте)
State machine persistence (сохранение в базы явной стейт-машины а не просто истории сообщений агента — для fault tolerance и recovery агента даже на другой машине) … Вы реализуете production-систему с мониторингом и recovery и вам нужно то что я описал => придется реализовать все 3 пункта руками с нуля
Имея один колбек в граф, можно было реализовать визуализацию на скрине выше двумя промптами в Codex. А вот State machine persistence для графа не сделали — руки не дошли.
Общение со всеми LLM-профайдерами (GigaChat, Qwen, OpenAI, Claude, AiTunnel) реализовали через REST api, чтобы вместе с sdk не тащить бесполезные зависимости. Например, не хочется иметь несколько библиотек для парсинга json, представления тула, http-сервера.
Для доступа российских пользователей к зарубежным моделям использовали AiTunnel — легко оплатить и не нужен VPN.
Проблемы своего решения в написании Агентов
В нашей реализации агента и LLM api использовались jackson и рефлексия. Ни то, ни другое не завелось в KMP (выше уже писал про ограниченный набор библиотек).
Самостоятельно приходится решать проблемы, вроде поддержки смены модели с имеющимся контекстом. Самостоятельно писать код обработки ошибок с ретраями. Самостоятельно поддерживать стриминг по разным протоколам.
Всё перечисленное решено в Koog из коробки — так что можно его еще раз посмотреть после выхода из альфы.
О тестировании агентного кода
Я пропущу вещи про тестирование в KMP, потому что о них можно почитать отдельно, и сфокусируюсь на тестировании агентного кода. Скажу только, что моя любимая библиотека для тестирования c 2020 года — Mockk.
Зачем вообще тестировать код агента?
Хочется понимать, как сказываются промпты, дополнительные проверки, RAG, размер контекса и тому подобное на итоговый общий результат работы агента.
Бывает и такое, что вчера какая-то модель, скажем, GigaChat Lite, работала на ура, а сегодня отупела до невозможности. Вот тогда хочется запустить модель на имеющихся тестах и сравнить ее результат с ней же в прошлом. На практике мы сталкивались с даунгредом в 6 раз.
Чтобы уметь тестировать такое, мы «замокали» вызов тулов, а всё остальное работает как есть. Выглядит так:
@ParameterizedTest(name = "scenario14_modifyFile[{index}] {0}")
@ValueSource(
strings = [
"Измени файл test_integration добавь новую строку World is over",
"В файл test_integration добавь строку World is over",
"Допиши в test_integration текст World is over новой строкой",
]
)
fun scenario14_modifyFile(userPrompt: String) = runTest {
val realToolMod = ToolModifyFile(filesUtil)
val toolModifyFile: ToolModifyFile = spyk(realToolMod)
val realToolFind = ToolFindFilesByName(filesUtil)
val toolFindFilesByName: ToolFindFilesByName = spyk(realToolFind)
val toolExtractText: ToolExtractText = spyk(ToolExtractText(filesUtil))
var currentContent = ""
val tempFile = "test_integration"
val appendText = "World is over"
coEvery { toolFindFilesByName.suspendInvoke(any()) } returns "["~/test_integration.txt"]"
coEvery { toolExtractText.invoke(any()) } answers { currentContent }
coEvery { toolModifyFile.invoke(any()) } answers {
val request = firstArg<ToolModifyFile.Input>()
val addedLines = request.patch.lineSequence()
.filter { it.startsWith("+") && !it.startsWith("+++ ") }
.map { it.removePrefix("+") }
.toList()
if (addedLines.isNotEmpty()) {
currentContent = listOf(currentContent, addedLines.joinToString("n"))
.filter { it.isNotEmpty() }
.joinToString("n")
}
"Modified"
}
runScenarioWithMocks(userPrompt) {
bindSingleton<ToolExtractText> { toolExtractText }
bindSingleton<ToolModifyFile> { toolModifyFile }
bindSingleton<ToolFindFilesByName> { toolFindFilesByName }
}
coVerify(exactly = 1) {
toolModifyFile.invoke(match { it.path.contains(tempFile) && it.patch.contains(appendText) })
}
}
Таких тестов у нас под сотню, бегут от 15 до 45 минут. Иногда модель проваливает тесты из-за таймаутов. Мы это засчитываем за провал, потому что конечный результат — то, что увидит пользователь. Цель тестов — замерить качество продукта с поставленной моделью, промптами, топологией графа агента, реализацией RAG, классификацией тулов и прочего.
Обращаюсь к самому себе из прошлого.
Если идешь через ад, продолжай идти. — Черчилль
Ты правильно сделал, что не использовал Koog для реализации логики агента, он еще в альфе. Kotlinx-datetime — тоже. XCode по-прежнему доставляет боль. Зато IOS Compose Multiplatform получила статус stable.
Если будешь писать только Desktop — не вижу ничего страшного в использовании Compose Multiplatform для UI, учитывая твой опыт. В худшем случае, потратишь недельку на подготовку релизного билда. О том, что приложение будет долго запускаться — ты и так знаешь. Кстати, можешь попробовать скачать его и поиграться (гитхаб, сайт).
Но не торопись браться за несколько платформ сразу. Легче разобраться в IOS, чем и в KMP, и в IOS. Легче взять популярные решения для платформы, чем пытаться написать общий код, имея ограниченный набор KMP-библиотек.
Автор: arturdumchev


