Kotlin Multiplatform: где общий код помогает, а где мешает
Kotlin Multiplatform лучше всего работает, когда вы делите общую логику предметной области, API-модели и тесты. Узнайте, где он экономит время, а где создаёт трения в команде.

Почему общий код превращается в спор
Общий код обычно превращается в спор, когда две команды хотят разного от одной и той же кодовой базы. Android чаще хочет скорости: написать бизнес-правила один раз, быстрее выпустить продукт и исправить всё в одном месте. iOS чаще хочет контроля: держать поведение ближе к приложению, следовать платформенным привычкам и избегать сюрпризов перед релизом. И у тех, и у других есть свои резоны.
Напряжение начинается, когда один общий модуль начинает решать работу сразу для обоих приложений. Kotlin Multiplatform может сократить дублирование, но он же меняет и то, кто владеет логикой. Общий модуль нередко получает одного условного владельца, а вторая команда чувствует себя гостем в коде, который всё равно выходит к её пользователям.
Это изменение важнее, чем многие ожидают. До общего кода каждая команда могла менять свои проверки, правила цен или обработку ошибок почти без обсуждений. После него любое небольшое изменение требует согласования названий, моделей данных, состояний ошибок и сроков релиза. Работа смещается от написания кода дважды к постоянным переговорам — снова и снова.
Одна ошибка тоже бьёт сильнее. Если общий код неправильно обработает проверку подписки или сломает обновление токена, Android и iOS могут перестать работать в один и тот же день. Code review быстро становится напряжённым, когда одна ошибка может сломать два приложения вместо одного.
Повседневные споры обычно возникают не из-за плохих людей, а из-за неверных границ. Если общий код возвращает текст уже готовым для экрана, одна команда теряет контроль над формулировками и форматированием. Если каждое приложение всё равно по-своему обрабатывает сетевые ошибки, дублирование возвращается. Тогда люди спорят в каждом pull request о вещах, которые никак не остаются маленькими: где живёт форматирование даты, кто отвечает за повторные попытки, какое состояние загрузки принадлежит общему коду и когда правило платформы должно остаться локальным.
Общий код лучше всего работает там, где граница скучная и очевидная. Правила предметной области, подпись запросов и тесты часто подходят отлично. Поведение UI, особенности аналитики и платформенная логика отображения часто не подходят. Если команда постоянно спорит об одном и том же файле, скорее всего, этот файл лежит не там, где нужно. Меньший общий слой обычно экономит больше времени, чем большой, к которому никто не хочет прикасаться.
Что делить в первую очередь
Самый безопасный общий код даёт один и тот же ответ на обеих платформах каждый раз. Расчёт цены, правила скидок, валидация ввода и изменения состояния подходят хорошо, потому что они не зависят от экранов Android или view controller на iOS. Когда команда один раз исправляет правило налога или проверку права на пробный период, оба приложения перестают расходиться.
Модели данных тоже стоит поставить в начало списка. Если оба приложения читают подписку, счёт или профиль пользователя одинаково, вы убираете целый класс багов. Одна сторона больше не считает поле необязательным, а другая — что оно всегда есть.
Kotlin Multiplatform лучше всего работает, когда первым общим кодом становится обычная бизнес-логика. Это общая логика предметной области, а не общий UI. Чистые функции — лучший первый шаг, потому что они принимают входные данные, возвращают результат и не трогают API устройства. Функция вроде canStartTrial(accountAge, previousTrialUsed, country) — скучный код, и именно поэтому его стоит перенести первым.
Обычно достаточно небольшого первого модуля:
- правила цен и скидок
- валидация форм
- проверка статуса подписки и прав доступа
- общие модели данных и простые мапперы
Этот первый модуль должен оставаться достаточно маленьким, чтобы его можно было переписать без драмы. Это звучит осторожно, но позже экономит время. Если уже этот небольшой кусок вызывает споры о названиях, неудобные шаги сборки или медленную отладку, вы узнаёте об этом рано — до того, как общий код расползётся по всему приложению.
UI-флоу лучше оставить на потом. В них много платформенных привычек, правил навигации и мелких крайних случаев. Команды часто спорят именно там, потому что пользователи Android и iOS ожидают немного разного поведения, даже если бизнес-правило одно и то же. Общайте правило, которое говорит, что план доступен. А логику экрана оставьте на каждой платформе, пока общий слой не заслужит доверия.
Простое правило помогает: если оба приложения должны вести себя одинаково и это можно проверить тестами, делайте это общим в первую очередь. Если код зависит от структуры экрана, жестов или платформенной полировки, пока оставьте его локальным.
Где лучше всего подходит networking
Networking часто становится самым чистым местом для общего кода в Kotlin Multiplatform. Android и iOS могут по-разному рисовать экраны, но обычно они вызывают один и тот же API, отправляют одни и те же данные авторизации и разбирают одни и те же ответы.
Этот общий слой должен владеть правилами, которые обязаны оставаться одинаковыми. Здесь строятся запросы. Здесь разбираются ответы. Здесь же должна жить авторизация, особенно обновление токена, правила заголовков и небольшие проверки, которые решают, нужно ли повторить запрос или попросить пользователя войти снова.
Хорошее разделение простое: платформенный код запускает сетевую работу, а общий код решает, что означает запрос.
Оставляйте поведение ОС вне общего кода
Фоновая синхронизация — это место, где команды часто заходят слишком далеко. iOS и Android по-разному будят приложения, по-разному ограничивают фоновую работу и по-разному открывают системные API. Если пытаться спрятать всё это в общем коде, абстракция быстро становится неудобной.
Пусть Android сам управляет WorkManager, уведомлениями, слушателями сети и хуками жизненного цикла приложения. Пусть iOS сам управляет BGTaskScheduler, обновлением по push и своими событиями жизненного цикла. Как только платформа решает: «сейчас время синхронизироваться», общий слой может взять управление на себя и выполнить реальные API-вызовы.
Такое разделение снимает споры. Каждое приложение оставляет у себя то, чем управляет операционная система, а оба приложения при этом переиспользуют бизнес-правила, стоящие за сетевым флоу.
Спрячьте HTTP-детали заранее
Выбор библиотек меняется чаще, чем команды ожидают. Небольшая обёртка вокруг HTTP-клиента даёт запас: позже можно заменить библиотеку, не трогая код предметной области. Остальная часть приложения не должна знать, идут ли запросы через Ktor сегодня или что-то другое в следующем квартале.
Обработка ошибок тоже должна жить в одном месте. Сведите низкоуровневые сбои к короткому набору ошибок приложения и переиспользуйте их везде. Таймаут, отсутствие соединения, истёкшая сессия, плохой ответ сервера или ошибка прав доступа должны означать одно и то же на обеих платформах.
В приложении с подпиской и Android, и iOS могут вызывать один и тот же endpoint «restore purchases». Общий код может собрать запрос, добавить авторизацию, разобрать ответ и превратить сетевые сбои в один набор правил. Платформенное приложение всё так же решает, когда вызывать этот запрос и как показать ошибку пользователю.
Покрытие тестами до расширения
Прежде чем переносить больше кода в общий модуль, зафиксируйте поведение, на которое вы уже опираетесь. Тесты делают это лучше, чем комментарии или память команды. Если правило подписки говорит, что приостановленный аккаунт не должен продлеваться, напишите этот тест до того, как начнёте делить биллинг-логику между Android и iOS.
Это важно, потому что общий код быстрее распространяет баги. Плохое правило в одном нативном приложении — неприятность. То же плохое правило в Kotlin Multiplatform может сломать оба приложения в один день.
Начните с unit-тестов вокруг бизнес-правил. Сосредоточьтесь на том, что позже легко неправильно понять: длительность пробного периода, даты продления, льготные периоды, правила скидок, проверки доступа и изменения состояния после ошибки оплаты. Эти тесты должны читаться как простые примеры того, как работает продукт.
Дальше идут contract tests. Они ловят тихие поломки, которые появляются после изменения API. Поле становится null. Появляется новое значение enum. Бэкенд присылает пустой список вместо того, чтобы не отдавать свойство. Если ваш общий код для networking и парсинга не умеет с этим работать, оба приложения сломаются в одном и том же месте.
Обычно достаточно небольшого набора проверок контрактов:
- обычные ответы API с ожидаемыми полями
- отсутствующие или null-поля
- неизвестные значения от сервера
- пагинация и пустые состояния
- тела ошибок и обработка таймаутов
Запускайте одни и те же общие тесты на каждом pull request. Не ждите ветки релиза или ночной сборки. Быстрая обратная связь не даёт мелким ошибкам стать большими. Если меняется общий модуль, полный набор его тестов должен запускаться каждый раз.
Фиксите нестабильные тесты сразу. Команды перестают доверять набору, которому не верят, а потом тесты перестают что-либо защищать. Чаще всего нестабильные тесты возникают из одних и тех же причин: реальное время, случайные данные, сетевые вызовы, которые не замоканы до конца, и скрытое состояние между тестами.
Если один тест падает раз в двадцать запусков, относитесь к нему как к багу продукта. Приведите его в порядок до того, как модуль станет больше. Обычно это и есть тот момент, когда общий код всё ещё экономит время, а не начинает вызывать споры.
Что оставить на Android и iOS
Kotlin Multiplatform лучше всего работает тогда, когда он сокращает повторяющуюся работу. И он начинает создавать трения, когда команды пытаются делить части приложения, зависящие от привычек платформы, правил магазинов или API устройства.
UI — самый очевидный пример. Пользователи Android и iOS ожидают разный flow экранов, отступы, жесты и мелкие детали взаимодействия. Если обе команды пытаются заставить один общий шаблон экрана работать везде, они обычно тратят больше времени на споры о крайних случаях, чем на создание функций.
Навигацию тоже лучше оставить локальной по той же причине. Deep links, поведение назад, модальные экраны и системная навигация ощущаются чуть по-разному на каждой платформе. Общая карта маршрутов может выглядеть аккуратно на бумаге, но локальный контроль проще отлаживать и проще менять.
Некоторые функции почти всегда должны оставаться в платформенном коде:
- доступ к камере и фотографиям
- покупки внутри приложения и биллинг магазинов
- настройка и обработка push-уведомлений
- разрешения, зависящие от ОС
- исправления доступности, завязанные на нативные контролы
Доступность требует особого внимания. Мелкие исправления часто зависят от конкретного нативного компонента, поведения screen reader и порядка фокуса в этом приложении. Если проблема VoiceOver появляется на iOS, команда iOS должна исправить её рядом с экраном, а не ждать, пока вырастет общий абстрактный слой.
Кеширование и офлайн-поведение находятся посередине. Общая логика предметной области может решать, какие данные нужны приложению и когда они устаревают. Но каждое приложение всё равно может по-своему настраивать хранение этих данных, предзагрузку и то, насколько агрессивно оно должно работать офлайн. Это особенно важно, когда пользователи Android ожидают более свободную фоновую работу, а iOS жёстче ограничивает то, что происходит в фоне.
Хорошее правило простое: делите решения, а поведение устройства оставляйте локальным. Делите бизнес-правила, модели запросов и тесты. Всё, что связано с нативным UI, флоу магазинов, датчиками, уведомлениями или доступностью, держите ближе к Android и iOS.
Такое разделение сохраняет скорость обеих команд. И оно же помогает избежать самой неприятной ошибки KMP networking: когда в общий код тащат платформенное поведение приложения только потому, что оно там компилируется.
Пошаговый план внедрения
Начните с одной функции, которая уже есть в обоих приложениях. Выберите что-то небольшое, стабильное и чуть скучное. Правила цен, проверки статуса аккаунта или валидация форм обычно лучше подходят для первого шага, чем логин, платежи или push-уведомления.
Прежде чем кто-то начнёт переносить код, нарисуйте границу на одной странице. Разделите её на три области: общий код, код Android и код iOS. Если кусок затрагивает UI, разрешения устройства, платформенные SDK или поведение app store, пока оставьте его на нативной стороне.
Kotlin Multiplatform идёт заметно плавнее, когда первый общий слой состоит в основном из чистой логики. Это значит модели данных, правила валидации, расчёты и небольшие функции преобразования. Команды обычно спорят о таких частях меньше, потому что ожидаемое поведение уже понятно.
Хорошая последовательность внедрения выглядит так:
- Зафиксируйте текущее поведение тестами.
- Перенесите модели и бизнес-правила в общий код.
- Перенесите небольшие helper-функции и мапперы.
- Добавьте сетевые помощники только после того, как правила устоятся.
- Выпустите один релиз, а потом остановитесь и оцените результат.
Шаг с тестами должен идти перед каждым переносом, а не после него. Если Android и iOS уже ведут себя одинаково сегодня, напишите тесты, которые это доказывают. Потом перенесите один кусок кода и снова запустите те же проверки. На день-два это кажется медленнее, но экономит неделю вопросов вроде «почему iOS теперь отличается?»
Networking тоже заслуживает осторожного старта. Сначала делите построение запросов, парсинг ответов и общее сопоставление ошибок. А платформенную HTTP-настройку, хранение авторизации и всё, что привязано к нативным библиотекам, оставляйте там, где это уже лежит, пока общий слой не заработает доверие.
После первого релиза намеренно сделайте паузу. Спросите обе команды, что вызвало трения: время сборки, отладка, названия, настройка тестов или неясная зона ответственности. Если боль небольшая и код остался стабильным, переносите следующий кусок. Если боли много, держите эксперимент узким и сначала исправьте процесс, прежде чем двигаться дальше.
Эта пауза важна. Общий код должен убирать дублирование, а не начинать еженедельный спор.
Простой пример из приложения с подпиской
Небольшое приложение с подпиской — хороший кандидат для Kotlin Multiplatform, если команда делит правила, стоящие за продуктом, а не экраны. В одном типичном сценарии Android и iOS предлагают месячные и годовые планы, бесплатный пробный период и несколько ограничений по функциям. Сложность не в том, чтобы нарисовать paywall. Сложность в том, чтобы оба приложения соглашались, кто получает доступ, когда заканчивается пробный период и что происходит после возврата денег или продления.
Команда помещает правила планов, права доступа и API-модели в общий код. Сюда входит логика вроде «пользователи premium могут экспортировать данные» или «пользователи trial теряют доступ через 7 дней, если биллинг не подтвердил платный план». Это общая логика предметной области, и именно такой код чаще всего расходится, когда каждое приложение хранит свою копию.
Networking здесь тоже хорошо подходит. Приложение получает статус подписки, метаданные продуктов и флаги аккаунта из backend через один общий клиент. Так модели запросов, парсинг ответов и обработка ошибок живут в одном месте. KMP networking особенно полезен, когда оба приложения обращаются к одному API и после каждого ответа должны применять одни и те же бизнес-правила.
А вот биллинг-флоу остаётся нативным. iOS оставляет себе покупки и восстановление через StoreKit. Android оставляет Google Play Billing. Такое разделение обычно спокойнее для команды, потому что каждая платформа всё равно следует своим правилам, особенностям SDK и требованиям ревью.
Общие тесты быстро приносят пользу. В этом приложении один тест проверяет пользователя, который начинает trial в 23:30, переходит через полночь и обновляет подписку до истечения полных 7 дней. Общий тест находит баг, при котором пробный период на одной платформе истекал на день раньше из-за ошибки на границе даты. Без общих тестов Android мог бы пройти, а iOS — сломаться уже после релиза.
Команда также не делит UI. Это экономит время. Продукт по-прежнему может настраивать paywall под стиль платформы, а инженеры не спорят долго о навигации, обёртках состояния и компромиссах в view layer. Kotlin Multiplatform помогает этому приложению потому, что убирает дублирующуюся логику, а не потому, что заставляет Android и iOS выглядеть или вести себя одинаково.
Ошибки, которые съедают спринт
Команды обычно теряют время, когда сначала делят не тот код. UI — самый частый капкан. Экран может выглядеть похоже на Android и iOS, но навигация, работа со состоянием, анимации и мелкие платформенные привычки отличаются достаточно сильно, чтобы один общий экран превратился в ежедневный спор.
Вместо этого начните с правил, стоящих за экраном. Логика цен, валидация, статус подписки и модели запросов обычно лучше подходят для общего кода. View layer — часто нет.
Ещё одна ошибка — прятать реальные платформенные различия под обёртками, которые всё время разрастаются. Доступ к камере, разрешения на push, локальное хранилище и фоновая работа не ведут себя одинаково на обеих платформах. Фальшивый общий API может превратиться в груду проверок, где никто уже не понимает, какой слой должен чинить проблему.
Путаница становится ещё хуже, когда команды переносят код без тестов. Если правило скидки уже работает на Android, перенос его в общую логику предметной области без unit-тестов — это ставка, а не план. Один плохой merge может сломать Android и iOS одновременно, и тогда команда два дня спорит, пришла ли ошибка из старого кода или из нового общего модуля.
Покрытие тестами в KMP важнее размера общего модуля. Небольшой общий пакет с хорошими тестами экономит время. Большой пакет со слабыми тестами — сжигает его.
Зона ответственности вызывает ещё один медленный спор. Если одна платформа принимает все решения за общий код, вторая команда начинает проверять выборы, к которым не имела отношения. Обычно это приводит к тихому сопротивлению, задержкам в ревью и обходным путям.
У общего кода должны быть общие правила. Инженеры Android и iOS оба должны влиять на форму API, обработку ошибок и на то, когда платформенный код — лучший выбор.
Kotlin Multiplatform также идёт не так, когда команда воспринимает его как переписывание с нуля. Переписывание выглядит аккуратно на плане, а потом затягивается, потому что приходится заново открывать все старые допущения. Лучше работает узкий рефакторинг: переносите один стабильный сценарий, докажите, что это экономит усилия, и только потом решайте, относится ли следующий кусок к общему коду.
Простое правило помогает. Если код меняется из-за бизнес-причин, делайте его общим в первую очередь. Если он меняется потому, что Apple и Google делают вещи по-разному, оставляйте его на каждой платформе.
Быстрые проверки перед тем, как делить больше
Команды обычно делят слишком многое сразу после первого успеха. Один общий валидатор работает, и тут же кто-то хочет общие экраны, общее состояние и половину приложения. Именно тогда Kotlin Multiplatform начинает экономить меньше времени и создавать больше споров.
Простая проверка помогает. Если вы не можете чётко ответить на эти вопросы, пока оставьте код раздельным.
- Могут ли оба приложения объяснить правило одной и той же простой фразой?
- Ходят ли Android и iOS к одним и тем же backend endpoints с одинаковой формой запроса?
- Если общий код сломается, заблокирует ли он вход, оплату или другой поток, который пользователям нужен сразу?
- Может ли команда отлаживать Kotlin-проблемы на обеих платформах, не дожидаясь одного специалиста?
- Назначен ли один человек, который отвечает за общий модуль и процесс его релиза?
Первый вопрос важнее, чем кажется. Если Android говорит «пробный период начинается после подтверждения email», а iOS — «после первого запуска», у вас нет одного правила. У вас есть два продуктовых решения, которые носят одну и ту же этикетку. Общая логика предметной области работает только тогда, когда бизнес-правило действительно одно и то же.
С networking та же история. Если оба приложения вызывают одни и те же endpoints, разбирают одни и те же поля и обрабатывают одни и те же ошибки, общий клиент часто имеет смысл. Если iOS использует один auth flow, а Android — другой, общий сетевой слой может превратить простые различия в запутанные проверки.
Риск тоже имеет значение. Общий баг распространяется быстро. Если одна ошибка может сломать checkout или отменить доступ платящим пользователям, добавьте тесты до того, как переносить этот код в общий модуль. Покрытие тестами в KMP должно расти раньше, чем растёт общая поверхность. Иначе вы обменяете дублирующийся код на дублирующуюся панику.
Навык команды — это то, что многие пропускают. Если только один разработчик может проследить Kotlin-стек через Android и iOS, общий модуль становится узким местом. С кодом может быть всё в порядке. С рабочим процессом — нет.
Зона ответственности помогает модулю оставаться здоровым. Один владелец не означает, что один человек пишет вообще всё. Это означает, что один человек решает про версионирование, просматривает рискованные изменения и говорит «нет», когда функция туда не подходит.
Если на большинство вопросов ответ «да», делайте следующим шагом ещё один маленький кусок. Если на два или три ответа уверенности нет, подождите спринт и сначала наведите порядок.
Что делать дальше
Выберите одну функцию, которую команда уже поддерживает дважды и ненавидит трогать. Хорошая первая цель — что-то вроде правил подписки, проверки права на trial, проверки цен или валидации формы. Если одна и та же ошибка постоянно появляется и на Android, и на iOS, это обычно лучший старт для Kotlin Multiplatform, чем совершенно новая функция.
Держите объём узким. Делите правила предметной области, возможно, один сетевой слой и тесты вокруг них. UI, навигацию и платформенные особенности пока оставьте в нативных приложениях. Так у вас будет честная проверка: вы действительно чините баги один раз, выпускаете немного быстрее и сохраняете одинаковое поведение в обоих приложениях?
Перед стартом работы запишите короткий план отката:
- Решите, что останется нативным, если общий модуль замедлит команду.
- Назначьте точку проверки после одного спринта или одного релиза.
- Укажите, кто может остановить rollout, если он создаёт больше трения, чем убирает.
- Определите один-два признака успеха, например меньше дублирующих исправлений или лучшее покрытие тестами.
Этот план важен. Когда модуль начинает тормозить работу, людям нужен быстрый способ сделать шаг назад вместо ещё одной недели споров.
Если команда продолжает спорить о разделении, приостановите расширение и проведите технический разбор. Большинство споров про общий код Android и iOS на самом деле не о Kotlin. Они возникают из-за неясной зоны ответственности, слабого покрытия тестами или плохой границы между общим и нативным кодом.
Oleg Sotnikov может помочь с таким разбором на практическом уровне. Как fractional CTO, он помогает командам спроектировать границу общего кода, проверить rollout и решить, что лучше оставить нативным, потому что так это будет дешевле и проще. Иногда одного внешнего взгляда достаточно, чтобы превратить запутанный спор в маленький следующий шаг, который команда действительно может проверить.
Начните с одной болезненной функции. Держите путь отката коротким. Если один и тот же спор возвращается снова и снова, пригласите свежий технический взгляд до следующего спринта.