Кроссплатформа: Flutter, React Native, KMP — сравнение и цена решения
Есть вопрос, который задают на каждом старте мобильного проекта, и почти всегда — неправильно. «Что лучше: Flutter или React Native?» На него нет ответа, как нет ответа на «что лучше: грузовик или седан». Правильная формулировка звучит скучнее и полезнее: сколько мы заплатим за то, что часть кода будет общей, и в какой валюте придёт счёт.
Счёт приходит не деньгами и не сразу. Он приходит осенью, когда вышла новая версия iOS и плагин камеры перестал собираться. Он приходит через год, когда ревьюер требует декларацию приватности у зависимости, которую вы не писали. Он приходит на третьем месяце найма, когда выясняется, что людей, способных починить баг в мосте между Dart и Objective-C, на рынке не десять тысяч, а десять. И одновременно приходят дивиденды: одна фича вместо двух, один баг вместо двух, одна команда с общим контекстом вместо двух, спорящих о том, кто первый сделает пуш-баннер. Эта статья — не проспект и не разоблачение: мы разберём механику трёх основных подходов, поймём, откуда берутся их сильные и слабые стороны, и построим модель стоимости, которую не стыдно принести на разговор с бизнесом. Предполагается, что вы читали карту трека и устройство платформ: всё сказанное там про батарею, смерть процесса, разрешения и ревью здесь работает без изменений. Кроссплатформенный фреймворк не отменяет мобильный контекст — он лишь решает, кто в вашей команде с ним столкнётся.
Что на самом деле означает «общий код»
Главное заблуждение звучит так: «пишем один раз — работает везде». Правда звучит иначе: общим становится то, что не касается платформы, а мобильное приложение в значительной части состоит из касаний платформы. Вопрос не в том, шарится ли код, а в том, где именно проходит граница.
Читайте картинку сверху вниз. Верхние строки — домен, модели, валидация, сетевые контракты, кэш — шарятся почти при любом подходе, и это самая скучная и самая надёжная экономия. Нижние строки — камера, пуши, биометрия, виджеты рабочего стола, платежи, сборка, подпись, ревью — не шарятся никогда. Между ними лежит UI, и вся война идёт за него. Отсюда первое правило, которое стоит держать в голове весь остаток статьи:
Считайте не строки кода, а экраны и интеграции. Приложение из тридцати экранов со списками и формами и тремя интеграциями — идеальный кандидат на кроссплатформу. Приложение из восьми экранов и двадцати системных интеграций — кандидат на натив, сколько бы строк ни было в доменном слое.
Второе правило вытекает из нижней строки схемы: релизный цикл не шарится вообще. Два магазина, два ревью, две подписи, две матрицы устройств, два набора крэш-репортов — об этом релиз и сторы. Ни один фреймворк не сократил эту работу ни на час.
Четыре стратегии: как вообще можно разделить код
Все подходы — это четыре ответа на один вопрос: на каком уровне провести границу между общим кодом и системой.
| Стратегия | Кто так делает | Что общее | Чем платим |
|---|---|---|---|
| Свой рендерер: рисуем весь UI сами | Flutter, Compose Multiplatform, Unity, Godot | почти всё, включая UI | догоняем каждую новую версию ОС вручную; доступность и текстовый ввод — своя реализация |
| Адаптер: описываем UI общим кодом, показываем нативные вью | React Native, NativeScript, исторический Xamarin.Forms | логика и описание UI | лишний слой между вашим кодом и вью; поведение расходится в деталях |
| Общее ядро: шарим логику, UI нативный | Kotlin Multiplatform, ядро на C++ (Dropbox, Telegram, карты) | домен, данные, сеть, часть состояния | UI пишем дважды; нужна экспертиза в обеих платформах |
| Веб в контейнере | Capacitor, Ionic, Cordova, PWA | всё, что умеет веб | потолок производительности, ограниченный доступ к системе, риск отказа на ревью |
Ни одна стратегия не «лучше»: они по-разному распределяют один и тот же объём работы между «сейчас» и «потом» и между вашей командой и сообществом фреймворка.
Кладбище как аргумент
Прежде чем разбирать конкретику, посмотрите на историю — она объясняет, почему опытные мобильные инженеры так осторожны с обещаниями фреймворков.
Первая закономерность: стеки умирают, а приложения остаются. Xamarin прожил тринадцать лет и был снят с поддержки в 2024 году — компании, построившие на нём продукт, получили обязательную миграцию не по своей воле. PhoneGap закрыт, Titanium практически исчез. Приложение живёт пять-десять лет; средний срок жизни кроссплатформенного фреймворка сопоставим. Выбирая стек, вы делаете ставку и на его вендора тоже.
Вторая: публичные отказы информативнее публичных успехов. Airbnb подробно описал, почему свернул React Native: технология работала, но стоимость поддержки моста, найма и инфраструктуры на их масштабе превысила выгоду. Dropbox так же честно рассказал, почему отказался от общего C++-ядра: экономия на написании кода была съедена стоимостью кастомной инфраструктуры, обучения и отладки на стыке. Оба текста — обязательное чтение перед любым решением о кроссплатформе, потому что написаны инженерами, которые сначала сделали, а потом посчитали. Зеркальный пример: Shopify публично поставил на React Native в 2020 году и не отказался от него — с большой долей общего кода и собственной командой, развивающей инфраструктуру. Разница между Airbnb и Shopify не в технологии: один считал кроссплатформу способом сэкономить, другой — продуктом, в который надо вкладываться.
Flutter изнутри
Flutter — самый радикальный из трёх. Он не пытается подружиться с системным UI: он берёт у системы прямоугольник и рисует в нём всё сам, от кнопки до курсора выделения текста.
- Язык — Dart. В отладке работает JIT, отсюда hot reload: изменение кода видно на устройстве за доли секунды без потери состояния экрана. В релизе Dart компилируется AOT в машинный ARM64 — это принципиально для iOS, где JIT запрещён на уровне ядра (см. платформы).
- Три дерева.
Widget— неизменяемое описание, дешёвое в создании;Element— живой узел с состоянием и связями;RenderObject— то, что умеет измеряться и рисоваться. Пересборка виджетов не означает перерисовку: фреймворк сравнивает описания и трогает только изменившиесяRenderObject. - Рендерер Impeller. Пришёл на смену Skia и решил конкретную проблему: раньше шейдеры компилировались лениво, в момент первой анимации, и это давало рывок при первом показе экрана. Impeller компилирует их заранее, во время сборки (документация).
- Изоляты вместо потоков. У изолятов нет общей памяти, они обмениваются сообщениями: гонок нет, но тяжёлые данные копируются при передаче. Выход в систему — платформенные каналы (асинхронный обмен сообщениями между Dart и нативным кодом) плюс
dart:ffiдля прямых вызовов C-функций без сериализации.
Почему у Flutter отдельный разговор про плавность, видно на схеме: конвейер кадра целиком принадлежит фреймворку.
// Обычный экран: состояние, оффлайн-баннер, ленивый список, работа с жизненным циклом.
// WidgetsBindingObserver регистрируют в initState и снимают в dispose — иначе поллинг
// продолжится в свёрнутом приложении и сожжёт батарею.
class _OrdersScreenState extends State<OrdersScreen> with WidgetsBindingObserver {
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
// paused наступает и при входящем звонке, и перед возможным убийством процесса.
if (state == AppLifecycleState.paused) context.read<OrdersModel>().stopPolling();
if (state == AppLifecycleState.resumed) context.read<OrdersModel>().refresh();
}
@override
Widget build(BuildContext context) {
final model = context.watch<OrdersModel>();
return Column(children: [
if (model.isStale) const StaleDataBanner(), // честно говорим, что данные старые
Expanded(child: ListView.builder( // ленивый список, а не Column целиком
itemCount: model.orders.length,
itemBuilder: (_, i) => OrderTile(order: model.orders[i]),
)),
]);
}
}
Выход в платформу — это код на трёх языках. Ручные MethodChannel со строковыми именами методов и Map<String, dynamic> дают опечатки, которых компилятор не видит, поэтому в проде берут Pigeon: он генерирует типобезопасные обёртки на Dart, Kotlin и Swift из одного описания.
// Dart: описание контракта для Pigeon — из него генерируются Kotlin- и Swift-интерфейсы.
@HostApi()
abstract class DeviceIntegrity {
@async
String attest(String nonce); // вердикт аппаратного аттестатора устройства
}
// Android: та же операция через Play Integrity API.
class DeviceIntegrityImpl(private val context: Context) : DeviceIntegrity {
override fun attest(nonce: String, callback: (Result<String>) -> Unit) {
IntegrityManagerFactory.create(context)
.requestIntegrityToken(IntegrityTokenRequest.builder().nonce(nonce).build())
.addOnSuccessListener { callback(Result.success(it.token())) }
.addOnFailureListener { callback(Result.failure(it)) }
}
}
// iOS: тот же контракт через App Attest. Это полноценный нативный код, и писать его
// должен человек, понимающий iOS, а не «просто Flutter-разработчик».
final class DeviceIntegrityImpl: DeviceIntegrity {
func attest(nonce: String, completion: @escaping (Result<String, Error>) -> Void) {
let service = DCAppAttestService.shared
guard service.isSupported else { completion(.failure(IntegrityError.unsupported)); return }
service.generateKey { keyId, error in
guard let keyId else { completion(.failure(error!)); return }
let hash = Data(SHA256.hash(data: Data(nonce.utf8)))
service.attestKey(keyId, clientDataHash: hash) { token, error in
completion(token.map { .success($0.base64EncodedString()) } ?? .failure(error!))
}
}
}
}
Запомните эту тройку файлов — это и есть настоящая цена «одной кодовой базы»: любая системная возможность стоит трёх реализаций вместо двух (Dart, Kotlin, Swift) плюс тесты на стыке. Экономия появляется только там, где таких возможностей мало.
Даёт: одинаковый до пикселя UI на обеих платформах, полный контроль анимации, высокую скорость разработки за счёт hot reload, предсказуемость — нет сюрприза «а на этом Android рендерится иначе». Стоит: движок добавляет несколько мегабайт на архитектуру; доступность работает через мост семантики, а не через настоящие системные элементы, и требует явной работы; системные мелочи (меню выделения текста, поведение клавиатуры, автозаполнение паролей, системный шаринг) повторяются, а не используются; сетевой стек по умолчанию свой — dart:io не наследует системные прокси, корпоративные сертификаты и network_security_config так же прозрачно, как нативный клиент, поэтому команда Dart выпустила пакеты cupertino_http и cronet_http, переключающие HTTP на системный стек. Это не мелочь: от неё зависят и пиннинг сертификатов, и работа за корпоративным VPN.
React Native изнутри
React Native делает противоположную ставку: UI описывается общим кодом, но на экране — настоящие нативные вью. <View> превращается в UIView на iOS и ViewGroup на Android.
Первые девять лет фреймворк работал через «мост»: JS-поток складывал вызовы в очередь, они сериализовались в JSON, пачками уезжали в нативный поток и так же возвращались. Отсюда классические жалобы: рывки при быстром скролле, задержка отклика на жест, невозможность синхронно спросить у нативной стороны размер элемента. Новая архитектура (по умолчанию с версии 0.76) мост убрала:
- JSI — тонкий C++-слой, позволяющий JS-объектам напрямую держать ссылки на нативные объекты и вызывать их синхронно, без сериализации.
- Fabric — рендерер с C++-ядром: теневое дерево, раскладка движком Yoga (тот самый flexbox), затем монтирование настоящих вью.
- TurboModules — нативные модули грузятся лениво, по первому обращению, а не все сразу на старте; это заметно улучшило старт приложений с десятками зависимостей.
- Codegen — типы из TypeScript превращаются в C++/Kotlin/ObjC-обвязку на этапе сборки: рассогласование контрактов ловит компилятор, а не пользователь. Hermes — движок JS под мобильные ограничения: байт-код компилируется заранее и грузится через
mmap, поэтому старт быстрее, а память ниже. На iOS он работает без JIT — это архитектурное ограничение платформы, а не недоработка.
// Спецификация TurboModule: из неё codegen сгенерирует нативные интерфейсы.
import type { TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native';
export interface Spec extends TurboModule {
getSecureFlagSync(): boolean; // синхронный вызов возможен благодаря JSI
storeToken(key: string, value: string): Promise<void>;
}
export default TurboModuleRegistry.getEnforcing<Spec>('SecureStorage');
// Экран: виртуализированный список плюс честная работа с жизненным циклом.
export function OrdersScreen() {
const { orders, isStale, refresh, stopPolling } = useOrders();
useEffect(() => {
// Процесс могут выгрузить в любой момент — таймеры гасим руками.
const sub = AppState.addEventListener('change', (s) =>
s === 'active' ? refresh() : stopPolling(),
);
return () => sub.remove();
}, [refresh, stopPolling]);
return (
<View style={styles.root}>
{isStale && <StaleDataBanner />}
<FlatList
data={orders}
keyExtractor={(o) => o.id}
renderItem={({ item }) => <OrderTile order={item} />}
initialNumToRender={10} // без виртуализации длинный список съест память
/>
</View>
);
}
Отдельная важная деталь — анимации и жесты. Наивная анимация через setState в JS-потоке обречена: любой тяжёлый вызов в этом потоке пропустит кадры. Промышленное решение — Reanimated с worklets: маленькие функции JS, исполняемые прямо на UI-потоке и не зависящие от загруженности основного JS. То же справедливо для react-native-gesture-handler. Если в проекте на React Native анимации написаны без них, проблема не во фреймворке.
// Worklet исполняется на UI-потоке: жест плавный, даже когда JS-поток занят.
const offset = useSharedValue(0);
const pan = Gesture.Pan()
.onChange((e) => { offset.value += e.changeX; }) // 'worklet' подставит babel-плагин
.onEnd(() => { offset.value = withSpring(0); });
const style = useAnimatedStyle(() => ({ transform: [{ translateX: offset.value }] }));
Даёт: настоящие системные элементы, а с ними бесплатно — жесты, доступность, автозаполнение, поведение клавиатуры; огромный пул React-разработчиков; богатую экосистему; возможность обновлять JS-часть без нового релиза; удобный brownfield-режим, когда один экран живёт внутри существующего нативного приложения. Стоит: отдельный JS-поток как источник трудноуловимых лагов; экосистема плагинов очень разного качества и с разной скоростью реакции на изменения ОС; отладка «на трёх этажах» (JS, C++-ядро, нативный слой) требует человека, знающего все три; версия react-native тянет согласованные версии десятков пакетов, и апгрейд мажора — плановая работа, а не рутина. Экосистема стандартизуется вокруг Expo; отказ от него обычно означает, что собственную инфраструктуру сборки и обновлений придётся строить самостоятельно.
Kotlin Multiplatform изнутри
KMP отвечает на вопрос иначе: UI нативный на обеих платформах, общее — только то, что не рисуется. Домен, валидация, сетевые клиенты, кэш, локальная БД, обработка ошибок, аналитика, фичефлаги.
Kotlin компилируется в разные выходы: JVM-байткод для Android, нативный бинарник через LLVM для iOS (Kotlin/Native), при желании JS и Wasm. Для iOS сборка выдаёт .framework (обычно XCFramework), который Xcode подключает как обычную зависимость. Механизм разделения — пара expect/actual: общий модуль объявляет ожидаемый интерфейс, платформенные модули дают реализации.
Это ровно приём «порты и адаптеры» из слоистой архитектуры, только граница проходит между платформами, а не между слоями приложения.
// commonMain: репозиторий, который работает и на iOS, и на Android.
class OrdersRepository(private val api: HttpClient, private val db: OrdersQueries) {
// Единственный источник правды — локальная БД. Сеть лишь обновляет её.
fun observeOrders(): Flow<List<Order>> = db.selectAll().asFlow().mapToList()
suspend fun sync(): SyncResult = try {
val remote = api.get("/orders").body<List<OrderDto>>()
db.transaction { remote.forEach { db.upsert(it.toEntity()) } }
SyncResult.Ok
} catch (e: IOException) {
SyncResult.OfflineKeepStale // обрыв сети на мобильном — штатная ситуация, не исключение
}
}
// commonMain: ожидаемое хранилище секретов, реализуемое отдельно на каждой платформе.
expect class SecureStore() {
suspend fun save(key: String, value: String)
suspend fun read(key: String): String?
}
// iOS: тот же репозиторий, но UI полностью нативный — SwiftUI ничего не знает о Kotlin.
@Observable
final class OrdersViewModel {
private let repository: OrdersRepository
private(set) var orders: [Order] = []
// Flow приезжает в Swift как AsyncSequence — это работа SKIE или рукописной обёртки:
// «из коробки» Kotlin/Native отдаёт колбэк в стиле Objective-C.
func start() async { for await batch in repository.observeOrders() { orders = batch } }
}
struct OrdersScreen: View {
@State private var model = OrdersViewModel()
var body: some View {
List(model.orders) { OrderRow(order: $0) } // системный список, системные жесты
.task { await model.start() }
}
}
Острые углы, о которых не пишут в маркетинге.
- Интероп идёт через Objective-C. Обобщения, sealed-иерархии и
suspend-функции проходят мост с потерями: sealed class превращается в набор классов без исчерпывающегоswitch, обобщения теряют типы. Лечится генератором SKIE или рукописным фасадом, отдающим Swift понятный API. Планируйте этот слой заранее, иначе iOS-команда получит библиотеку, которой неприятно пользоваться, и молча начнёт писать своё. - Память. Современный Kotlin/Native использует трассирующий сборщик мусора и снял старое ограничение на «замораживание» объектов между потоками, но объекты Kotlin, живущие в Swift, — это два разных менеджера памяти в одном процессе. Циклы «Swift держит Kotlin, Kotlin держит Swift» протекают и плохо диагностируются привычными инструментами.
- Compose Multiplatform — отдельное решение. Это уже не «общая логика», а «общий UI», то есть переход в первую стратегию со всеми последствиями: свой рендерер, свои системные мелочи, своя доступность. То, что он написан на Kotlin, не делает его нативным для iOS.
Итого. Даёт: максимальное качество UI (он просто нативный), нулевой риск «плагин не обновили», постепенное внедрение по одному модулю, отсутствие нового языка для Android-команды. Стоит: узкий рынок найма, обязательное наличие сильных iOS-инженеров, меньшая инструментальная зрелость и меньшая доля общего кода — обычно 30–60%, а не 90%.
Ещё три варианта, о которых спрашивают
- .NET MAUI — преемник Xamarin.Forms, C# и XAML поверх нативных элементов. Разумен, если у вас уже большая .NET-команда и внутренние библиотеки на C#. Помните историю Xamarin: ставка на стек одного вендора — ставка и на его продуктовую стратегию.
- Capacitor и Ionic — веб-приложение в системном WebView плюс мост к нативным API. Самый дешёвый вход, если веб-продукт уже есть и нужен «мобильный клиент к тому же самому». Потолок по плавности и доступу к системе ниже всех, а обёртки без собственной ценности регулярно получают отказ на ревью в App Store по пункту 4.2 о минимальной функциональности.
- Unity и Godot — если продукт по сути игра или тяжёлая 3D-визуализация, спор Flutter против React Native не имеет смысла: берите движок.
- Общее ядро на C++ или Rust — криптография, синхронизация, парсеры, движки карт: предельная производительность и полная свобода UI, но своя инфраструктура сборки, свои инструменты отладки и свои люди (ровно то, о чём писал Dropbox). На маленькой команде это самоубийство, на большой — иногда единственный вариант.
Чем именно платит кроссплатформа: мобильный контекст
Здесь начинается самое важное. Энергия, сеть, оффлайн, разрешения, жизненный цикл, ревью не абстрагируются фреймворком — они просто становятся чужой ответственностью, и от того, насколько хорошо чужой человек её нёс, зависит ваш продукт.
Батарея
Энергию тратят радио, экран и процессор, и фреймворк влияет на все три косвенно, но измеримо. Лишние перестроения UI гоняют GPU и держат процессор в высоких состояниях; таймер, который «просто обновляет счётчик раз в секунду», не даёт ядрам уснуть; setInterval в JS-потоке — типовой источник фонового расхода. Дисциплина одинакова для всех стеков: тайминги привязывают к жизненному циклу экрана, а не к глобальным таймерам; периодические задачи отдают системным планировщикам (WorkManager и BGTaskScheduler — см. фоновую работу), а не собственным циклам внутри рантайма. Отдельная статья расхода — сетевые пробуждения: дешёвый в вебе паттерн «опрашивать эндпоинт раз в десять секунд» на мобильном означает постоянно активное радио и заметный процент батареи за час. Кроссплатформенный код, перенесённый из веба вместе с привычками, приносит эти привычки с собой — самый частый способ получить отзыв «жрёт батарею».
Сеть с провалами
В мобильной сети запрос не «выполняется или падает»: он может висеть минуту в лифте, оборваться на середине при переходе Wi-Fi → LTE, вернуться дублем после ретрая. Здесь между стеками есть реальная техническая разница.
- React Native использует нативный сетевой стек (OkHttp и
NSURLSession), поэтому системные настройки, ATS, корпоративные прокси и нативный пиннинг работают привычным образом. - Flutter по умолчанию берёт собственную реализацию
dart:ioсо своим набором доверенных корней: предсказуемо, но отделено от системного поведения; для интеграции естьcupertino_httpиcronet_http. - KMP обычно использует Ktor, а тот опирается на нативные движки — то есть ведёт себя как нативный клиент.
Независимо от стека обязательна одна дисциплина — таймаут на каждый запрос, экспоненциальный backoff с джиттером, идемпотентные ключи для повторов, различение «нет сети» и «сервер ответил ошибкой». Подробно — в паттернах устойчивости; на мобильном это не продвинутая практика, а минимум.
Оффлайн
Оффлайн — состояние по умолчанию, а не режим, поэтому нужны локальная БД и стратегия синхронизации; подробно — в данных и оффлайне. Что доступно в каждом стеке:
| Стек | Локальное хранилище | Замечание |
|---|---|---|
| Натив | Room, SQLDelight, Core Data, SwiftData, GRDB | всё первого класса, поддержка вендора |
| KMP | SQLDelight, Room с поддержкой KMP, multiplatform-settings | общая схема и общие миграции — сильная сторона KMP |
| Flutter | Drift, sqflite, Isar, ObjectBox | всё поверх SQLite, зрелые, но сообщественные пакеты |
| React Native | op-sqlite, expo-sqlite, WatermelonDB | производительность сильно зависит от того, идёт ли трафик через JSI |
Важнее выбора библиотеки — риск зависимости. Показательная история: Realm много лет был самым популярным мобильным «не-SQL» решением с готовой облачной синхронизацией, а затем синхронизация была объявлена устаревшей, и командам пришлось искать замену (PowerSync, ElectricSQL, собственная логика). Урок универсальный: синхронизация — это ваша бизнес-логика, а не инфраструктура, которую можно арендовать навсегда. Разрешение конфликтов, версионирование и порядок применения изменений держите в своём коде — тогда смена движка хранения останется задачей на недели, а не на квартал.
Разрешения и приватность
Разрешение спрашивает система, а обёртка вроде permission_handler или react-native-permissions лишь вызывает нативный API. Всё, что описано в платформах, остаётся в силе: отказ бывает окончательным, формулировки в Info.plist и AndroidManifest.xml читает ревьюер, праймер нужен до системного диалога. Но у кроссплатформы есть и своя специфическая боль — транзитивные разрешения. Плагин, поставленный ради выбора фотографии, добавляет в манифест доступ к точной геолокации, потому что внутри тянет чужой SDK. Слияние манифестов происходит на сборке, тихо, и вы узнаёте об этом из карточки в магазине или из вопроса ревьюера. Обязательная гигиена:
# Android: что реально попало в манифест после слияния всех зависимостей
./gradlew :app:processReleaseManifest
grep -n "uses-permission" app/build/intermediates/merged_manifests/release/AndroidManifest.xml
# iOS: какие ключи приватности объявлены в собранном бандле
plutil -p build/Runner.app/Info.plist | grep -i usagedescription
Сюда же — декларации приватности на iOS: Apple требует, чтобы приложение и подключённые SDK объявляли собираемые данные и причины использования отдельных API. Когда требование вводили, кроссплатформенные команды встали в очередь: сначала манифест должен появиться в движке (Flutter, Hermes, React Native), затем в каждом плагине, и только потом сборка проходит проверку. Своим кодом это не чинится — только ожиданием чужого релиза. То же повторяется с каждым новым системным требованием: обязательный ежегодный targetSdk в Play, принудительный edge-to-edge в свежих Android, поддержка 16-килобайтных страниц памяти для нативных библиотек. Для натива каждая такая волна — задача на день, для кроссплатформы — ожидание движка и десятка плагинов.
Жизненный цикл: главная ловушка для пришедших из веба
В вебе вкладку не убивают втихую. На мобильном процесс убивают штатно, и состояние, лежащее в памяти фреймворка, исчезает вместе с ним.
Ключевой момент: у нативного Android есть onSaveInstanceState, и система сама предлагает восстановиться, а у кроссплатформенных фреймворков этого нет по умолчанию. Flutter даёт явный механизм (RestorationMixin и restorationId), который подключают руками и осознанно; у React Native встроенного аналога нет вообще — состояние надо самому сериализовать в хранилище и поднимать при старте. KMP в этом смысле честнее всех: и onSaveInstanceState, и NSUserActivity остаются на своих местах, потому что UI нативный.
Проверять это надо принудительно, а не надеяться: на Android включите в настройках разработчика «Не сохранять активности», на iOS завершайте свёрнутое приложение из Xcode. Приложение, которое после этого показывает пустой экран или теряет заполненную форму, сломано, даже если в офисе всё работает. Второй сценарий стоит увидеть целиком: асинхронная работа, пересекающая границу фреймворка и платформы, когда процесс умирает посередине.
Мораль не про фреймворки, а про мобильную разработку вообще: колбэк — не гарантия. Единственная защита — идемпотентность на сервере и восстановление статуса при старте. Но в кроссплатформе цепочка длиннее (общий код → мост → нативный модуль → система), значит и мест, где обрыв останется незамеченным, больше.
Ревью в сторах и обновления «по воздуху»
Формально ревью не зависит от стека — приложение оценивают по поведению; практически различия есть.
- Обёртки над сайтом — главная мишень: пункты 4.2 и 4.7 App Store Review Guidelines дают самую частую причину отказа для WebView-подхода.
- Обновление кода «по воздуху» — уникальное свойство JS-стеков. React Native позволяет доставить новый JS-бандл без релиза (EAS Update и аналоги), и это легально, пока обновление не меняет назначение приложения и не подменяет собой ревью. Мощный инструмент и мощный способ выстрелить себе в ногу: у Flutter и KMP такой возможности нет в принципе, там любое изменение логики проходит полный цикл. И помните, что Google Play ограничивает загрузку исполняемого кода в рантайме: JS-бандл допустим, произвольные нативные библиотеки нет.
- Сторонние SDK внутри плагинов — источник неожиданных вопросов ревьюера: аналитика, которую вы не заказывали, трекинг, требующий App Tracking Transparency, отсутствующая декларация приватности. Практический вывод: относитесь к дереву зависимостей как к части поставки, а периодический аудит
pubspec.lock,package-lock.jsonилиgradle.lockfile— не паранойя, а подготовка к ревью.
Размер бинарника и холодный старт
Любой рантайм — это мегабайты и миллисекунды. Порядок величин: KMP добавляет меньше всех, React Native с Hermes — заметно, Flutter — больше всех, потому что несёт собственный движок отрисовки. Для приложения, которое ставят по мобильной сети, лишние мегабайты — это реальные проценты конверсии установки, а лишние сотни миллисекунд до первого кадра — реальные проценты отказов на старте. Измерять надо на дешёвых устройствах и с чистой установки; методика — в производительности.
Цена решения: как посчитать честно
Ошибка типичной оценки в том, что считают только первую строчку таблицы.
| Статья затрат | Два натива | Flutter / React Native | KMP |
|---|---|---|---|
| Первая версия продукта | 100% | 60–75% | 75–85% |
| Типовая продуктовая фича потом | 100% | 55–70% | 70–80% |
| Фича с системной интеграцией | 100% | 110–130% (три реализации вместо двух) | 100–110% |
| Поддержка инфраструктуры стека | почти ноль, делает вендор | 5–15% времени команды постоянно | 3–8% |
| Реакция на новую версию ОС | сразу, силами команды | ждём движок и плагины | UI сразу, логика не затронута |
| Найм и онбординг | предсказуемо | быстро на входе, узко на глубине | медленно, но люди сильные |
| Риск ухода вендора | минимальный | средний | средний |
Проценты — ориентиры, а не истина; важна структура. Разложим на примере: приложение доставки, 34 экрана, из них 28 — списки, формы и карточки, а 6 — карта с кластеризацией, камера для сканирования штрихкода и экран курьера с фоновой геолокацией; интеграции — пуши, платежи, биометрия, deep links, виджет.
- Два натива. 14 человеко-месяцев на iOS и 14 на Android, из них примерно по 4 дублируют доменную логику и работу с данными. Итого 28.
- Flutter. 28 типовых экранов — около 9 месяцев вместо 18. Но карта, камера и фоновая геолокация становятся тройной работой (Dart-обёртка плюс две нативные реализации плюс отладка на стыке) — 8 месяцев вместо 6. Плюс инфраструктура: сборка, CI под два таргета, дежурство по обновлениям движка — 2 месяца в первый год. Итого около 19 против 28: экономия примерно треть, а не половина.
- KMP. Общий домен и данные экономят 4 из 28, UI пишется дважды, системные вещи стоят ровно столько же, сколько в нативе. Итого 24–25 — при нулевом росте риска по качеству интерфейса.
Дальше — то, чего в смете обычно нет вовсе. Налог на мост: каждая нетривиальная системная возможность стоит трёх реализаций и тестов на стыке, то есть на 20–40% дороже, чем в двух нативах. Сезонный налог: осенью выходят новые iOS и Android, нативная команда адаптируется сразу, кроссплатформенная ждёт релиз движка и обновление плагинов — закладывайте 2–4 недели буфера ежегодно. Налог на обслуживание плагинов: в зрелом проекте их 30–120, часть заброшена, форк и поддержка чужого кода становятся регулярной работой. Налог на потолок: когда продукт упирается в производительность или в отсутствующую возможность, вы пишете нативный модуль — это не провал, а плановая статья расходов. Стоимость выхода: переезд с кроссплатформы на натив — это переписывание клиента, 60–80% стоимости новой разработки, поэтому решение оформляют как ADR с альтернативами и критериями пересмотра, а не по симпатии к языку.
И честные дивиденды, о которых забывают скептики: одна команда с общим контекстом реально быстрее двух; фичи выходят на обеих платформах одновременно, а не «на Android через спринт»; паритет поведения достигается по построению, а не сверкой скриншотов; аналитика и A/B-эксперименты настраиваются один раз. На продукте, где скорость проверки гипотез важнее последнего процента гладкости анимации, это перевешивает все налоги выше.
Как выбирать
Дерево не заменяет мышление, но фиксирует главное: выбор определяется характером продукта и составом команды, а не свойствами фреймворка. Два вопроса полезно задать до всякой техники. Первый: кто починит нативный баг в пятницу вечером? Если ответа нет, вы не готовы к кроссплатформе независимо от её достоинств. Второй: что мы сделаем, если фреймворк перестанет развиваться? Если ответ «перепишем всё», зафиксируйте это как принятый риск, а не как то, чего не случится.
Гибрид: как входить и как выходить
Чистые решения существуют только на старте; в реальности почти всё интересное — гибриды, и это нормально. Вход в существующее нативное приложение (brownfield) поддерживают все три стека: один экран или один модуль внутри работающего приложения. Это лучший способ проверить гипотезу без ставки на всё: возьмите некритичный, но не игрушечный экран (история заказов, справка, профиль), доведите до продакшна и измерьте не «скорость разработки», а размер бинарника, время старта, число крэшей и время до первого неожиданного бага. Три месяца такого эксперимента дешевле, чем два года выводов задним числом.
Порядок риска. KMP встраивается наименее болезненно: он не трогает UI, его вносят по одному модулю и так же выносят. Flutter и React Native приносят с собой рантайм и свою модель навигации, поэтому «немножко Flutter» — это уже плюс мегабайты и вопрос, как две навигации уживаются друг с другом.
Признаки, что пора выходить (Airbnb сформулировал их лучше всех): бо́льшую часть времени команда тратит на инфраструктуру фреймворка, а не на продукт; каждая вторая задача упирается в нативный модуль; на найме вы ищете «человека с опытом Flutter и iOS», и таких единицы; релизы задерживаются из-за обновлений движка. Одного признака мало, трёх вместе — достаточно. Как страховаться заранее. Держите доменный слой изолированным от фреймворка, не размазывайте вызовы платформенных API по экранам, покрывайте домен тестами, не зависящими от UI. Тогда даже полная замена клиентского стека сохранит самое ценное — правила предметной области. Об этом — архитектура мобильного приложения.
Правила выживания в проде
- Один архитектурный владелец на стек — человек, отвечающий за версии движка, апгрейды и дерево зависимостей; без него дерево гниёт незаметно. И апгрейд движка — плановая работа, раз в квартал по расписанию: отставание на два мажора превращает обновление в проект.
- Все платформенные вызовы — за одним интерфейсом, ни одного прямого обращения к плагину из экрана: тогда замена плагина — правка в одном файле. Критичные для бизнеса возможности (платежи, биометрия, пуши) лучше писать самим — это меньше кода, чем кажется, и полный контроль в момент, когда ревьюер задаёт вопрос.
- Тесты домена — на общем коде, UI-тесты — на устройствах; см. тестирование и мобильное тестирование в треке QA.
- Метрики стека в мониторинге: размер бинарника, время до первого кадра, доля пропущенных кадров, крэши по версиям движка — в дашборде, а не в чьей-то голове.
- Нативный инженер на каждой платформе даже в самой кроссплатформенной команде. Это не избыточность, а страховка от простоя.
Типичные ошибки
- Обещать бизнесу двукратную экономию. Реалистично 25–45% на клиентской разработке, и то не на любом продукте. Завышенное обещание разрушает доверие сильнее любого технического долга.
- Выбирать стек по языку, который нравится команде. Язык — самая дешёвая часть решения; дорогие части — экосистема, найм, риск вендора, потолок возможностей.
- Считать веб-React-разработчика мобильным инженером. Он не знает про смерть процесса, разрешения, фоновые лимиты и ревью — то есть про всё, что делает мобильную разработку мобильной.
- Тащить веб-привычки: поллинг вместо пушей, спиннер вместо оффлайн-состояния, список без виртуализации, отсутствие дебаунса. Сюда же — не изолировать плагины: прямые вызовы из экранов превращают замену библиотеки в сквозной рефакторинг.
- Игнорировать доступность: в стеке с собственным рендерером она не появляется сама, её нужно строить, и лучше с первого экрана.
- Считать OTA-обновления заменой ревью: они спасают в инциденте, но злоупотребление — прямая дорога к разговору с площадкой.
- Не проверять восстановление после смерти процесса — самый частый дефект кроссплатформенных приложений и самый простой в обнаружении: одна галочка в настройках разработчика.
Мини-итог
- Общим становится то, что не касается платформы: домен, данные и типовые экраны шарятся почти всегда; системные интеграции, сборка, подпись и ревью — никогда.
- Три стратегии: свой рендерер (Flutter), адаптер к нативным вью (React Native), общее ядро с нативным UI (KMP). Плюс веб в контейнере — дёшево, но с низким потолком и риском на ревью.
- Flutter даёт полный контроль над картинкой и предсказуемость, но берёт обязательство повторять системное поведение и несёт свой движок в бинарнике. React Native после перехода на JSI, Fabric и TurboModules показывает настоящие нативные вью; цена — отдельный JS-поток, длинная цепочка отладки и качество экосистемы плагинов. KMP шарит логику, оставляя UI нативным: максимум качества интерфейса и минимум риска, но самая узкая воронка найма и меньшая доля общего кода.
- Мобильный контекст не абстрагируется: батарея, сеть с провалами, оффлайн, разрешения, смерть процесса и ревью работают одинаково для всех стеков — меняется лишь то, кто с ними разбирается и как быстро.
- Считайте полную стоимость: налог на мост, сезонный налог на новые версии ОС, обслуживание плагинов, потолок возможностей и стоимость выхода. Реалистичная экономия — 25–45%, и решение обратимо только дорого: оформляйте его как ADR с критериями пересмотра, держите домен изолированным и обязательно имейте нативную экспертизу в команде.
Источники
- Flutter architectural overview и Impeller — три дерева, конвейер кадра, каналы, замена Skia.
- Pigeon и cupertino_http — типобезопасные каналы и системный HTTP-стек.
- React Native: The New Architecture, Hermes, Reanimated, Yoga, Expo — JSI, Fabric, TurboModules, движок, worklets, раскладка, инфраструктура.
- Kotlin Multiplatform, Compose Multiplatform, SKIE — expect/actual, общий UI, человеческий Swift-API поверх Kotlin.
- Sunsetting React Native (Airbnb) — самый честный разбор причин отказа.
- The (not so) hidden cost of sharing code between iOS and Android (Dropbox) — про общее ядро на C++.
- React Native is the future of mobile at Shopify — зеркальный опыт успешной ставки.
- App Store Review Guidelines и Privacy manifest files — пункты 4.2 и 4.7, интерпретируемый код, декларации приватности.
- Support 16 KB page sizes — пример требования, которое чинится только на стороне движка и плагинов.
- Xamarin support policy — документальное подтверждение риска вендора.
Что дальше
Стек выбран — дальше начинается работа, одинаковая для всех: пользователь ждёт, что приложение ведёт себя как приложение его платформы. Жесты, переходы, зоны досягаемости большого пальца, системная навигация «назад», адаптация под планшет и складное устройство — всё то, что отличает «работает» от «приятно пользоваться».
Мобильный UI и навигация: паттерны, жесты, адаптация под экраны