React Native vs Flutter: наш опыт разработки приложения для 2 миллионов пользователей

Контекст проекта

«КвикДоставка» — сервис доставки еды, работающий в 18 городах. На момент обращения к нам у компании были два отдельных нативных приложения: Swift для iOS и Kotlin для Android. Обе кодовые базы развивались параллельно, фичи появлялись с лагом в 2–4 недели, а команда из 12 мобильных разработчиков не успевала закрывать бэклог.

Бизнес-требования были чёткими:

  • Единая кодовая база для iOS и Android
  • Поддержка 60 fps анимаций в ленте ресторанов и корзине
  • Интеграция с нативными модулями: Apple Pay, Google Pay, push-уведомления, геолокация
  • OTA-обновления без прохождения ревью в сторах
  • Время холодного старта не более 2 секунд на устройствах среднего сегмента

Почему рассматривали именно эти два фреймворка

Рынок кроссплатформенной разработки в 2025 году фактически сводится к двум конкурентам: React Native и Flutter. Мы быстро отсеяли Kotlin Multiplatform (не покрывает UI-слой) и .NET MAUI (слабая экосистема). Оставались два реальных кандидата.

В нашей команде были специалисты по обоим стекам: 4 React Native-разработчика с опытом от 3 лет и 2 Flutter-разработчика, перешедших из нативной Android-разработки. Мы решили провести честный эксперимент — построить прототип одного и того же экрана на обоих фреймворках.

Прототипы: что реализовали

Для сравнения мы выбрали самый нагруженный экран приложения — ленту ресторанов с фильтрами, анимированными карточками и бесконечной прокруткой. Прототип включал:

  • Список из 500+ карточек ресторанов с ленивой подгрузкой
  • Анимации при скролле (параллакс-эффект на обложках)
  • Фильтрация с анимированным переключением
  • Навигация на экран детальной информации с shared element transition

React Native: ключевые решения

Для React Native мы использовали New Architecture (Fabric + TurboModules), FlashList вместо FlatList и Reanimated 3 для анимаций:

// RestaurantFeed.tsx
import { FlashList } from '@shopify/flash-list';
import Animated, {
  useAnimatedScrollHandler,
  useSharedValue,
  useAnimatedStyle,
  interpolate,
} from 'react-native-reanimated';

const AnimatedFlashList = Animated.createAnimatedComponent(FlashList);

export function RestaurantFeed({ restaurants }) {
  const scrollY = useSharedValue(0);

  const onScroll = useAnimatedScrollHandler({
    onScroll: (event) => {
      scrollY.value = event.contentOffset.y;
    },
  });

  const renderItem = ({ item, index }) => (
    <RestaurantCard
      restaurant={item}
      index={index}
      scrollY={scrollY}
    />
  );

  return (
    <AnimatedFlashList
      data={restaurants}
      renderItem={renderItem}
      estimatedItemSize={280}
      onScroll={onScroll}
      scrollEventThrottle={16}
      drawDistance={500}
    />
  );
}

// RestaurantCard.tsx — параллакс-анимация
function RestaurantCard({ restaurant, index, scrollY }) {
  const cardStyle = useAnimatedStyle(() => {
    const inputRange = [
      (index - 1) * 280,
      index * 280,
      (index + 1) * 280,
    ];
    const scale = interpolate(
      scrollY.value,
      inputRange,
      [0.95, 1, 0.95],
      'clamp'
    );
    const opacity = interpolate(
      scrollY.value,
      inputRange,
      [0.7, 1, 0.7],
      'clamp'
    );
    return { transform: [{ scale }], opacity };
  });

  return (
    <Animated.View style={[styles.card, cardStyle]}>
      <FastImage source={{ uri: restaurant.coverUrl }} />
      <Text>{restaurant.name}</Text>
    </Animated.View>
  );
}

Flutter: ключевые решения

Для Flutter использовали Riverpod для состояния и стандартный CustomScrollView с SliverList:

// restaurant_feed.dart
class RestaurantFeed extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final restaurants = ref.watch(restaurantListProvider);

    return CustomScrollView(
      slivers: [
        SliverAppBar(
          expandedHeight: 200,
          flexibleSpace: const FlexibleSpaceBar(
            title: Text('Рестораны'),
          ),
        ),
        restaurants.when(
          data: (list) => SliverList(
            delegate: SliverChildBuilderDelegate(
              (context, index) => RestaurantCard(
                restaurant: list[index],
                index: index,
              ),
              childCount: list.length,
            ),
          ),
          loading: () => const SliverFillRemaining(
            child: Center(child: CircularProgressIndicator()),
          ),
          error: (e, _) => SliverFillRemaining(
            child: Center(child: Text('Ошибка: $e')),
          ),
        ),
      ],
    );
  }
}

class RestaurantCard extends StatelessWidget {
  final Restaurant restaurant;
  final int index;

  const RestaurantCard({
    required this.restaurant,
    required this.index,
  });

  @override
  Widget build(BuildContext context) {
    return AnimatedContainer(
      duration: const Duration(milliseconds: 300),
      margin: const EdgeInsets.symmetric(
        horizontal: 16, vertical: 8,
      ),
      child: Hero(
        tag: 'restaurant_${restaurant.id}',
        child: Card(
          clipBehavior: Clip.antiAlias,
          child: Column(
            children: [
              CachedNetworkImage(
                imageUrl: restaurant.coverUrl,
                height: 180,
                fit: BoxFit.cover,
              ),
              Padding(
                padding: const EdgeInsets.all(12),
                child: Text(restaurant.name,
                  style: Theme.of(context).textTheme.titleMedium),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

Бенчмарки: цифры решают

Мы проводили замеры на трёх устройствах: iPhone 14 Pro, Samsung Galaxy A54 (средний сегмент) и Xiaomi Redmi Note 11 (бюджетный). Все тесты повторялись 10 раз, результаты усреднялись.

МетрикаReact Native (New Arch)Flutter
Холодный старт, iPhone 14 Pro1.1 с0.9 с
Холодный старт, Galaxy A541.8 с1.4 с
Холодный старт, Redmi Note 112.3 с1.9 с
FPS при скролле ленты (500 элементов)58–6059–60
FPS при анимации перехода55–6058–60
Потребление RAM (лента)142 МБ118 МБ
Размер APK (release)28 МБ18 МБ
Jank frames (% за сессию)2.1%1.4%

Flutter выиграл почти по всем техническим метрикам. Холодный старт быстрее на 15–20%, меньше потребление памяти, размер бандла компактнее. Однако разница в FPS при скролле оказалась минимальной — оба фреймворка уверенно держали 60 fps на флагманах.

Developer Experience: где Flutter проигрывает

Несмотря на превосходство по производительности, Flutter создал ряд проблем в опыте разработки:

Dart-экосистема. Количество пакетов на pub.dev значительно меньше, чем в npm. Для нескольких задач (интеграция с конкретными SDK платёжных систем, кастомная аналитика) пришлось писать platform channels — мосты к нативному коду. Это съело выигрыш по производительности разработки.

Найм. Dart-разработчиков на рынке в разы меньше, чем JavaScript/TypeScript-специалистов. Для «КвикДоставки» с планами масштабирования команды это был критический фактор.

Переиспользование кода. У клиента уже был веб-интерфейс на React. С React Native мы могли шарить бизнес-логику, типы, утилиты и даже некоторые хуки между веб- и мобильной версией. С Flutter такой возможности не было.

Интеграция с нативными модулями

Мы столкнулись со сложностями в обоих фреймворках, но характер проблем различался:

Apple Pay / Google Pay. В React Native библиотека react-native-payments потребовала допиливания, но мы использовали TurboModules для прямого вызова нативного API. Во Flutter пакет pay работал стабильно из коробки — здесь Flutter выиграл.

Push-уведомления. Firebase Messaging работал одинаково в обоих случаях. Нативная конфигурация идентична.

Геолокация в фоне. Критичная функция для курьерской части приложения. React Native с react-native-background-geolocation потребовал тонкой настройки для Android 14+, но в итоге работал надёжно. Во Flutter аналогичный пакет имел баг с Android Doze mode, который мы обнаружили только через неделю тестирования.

CI/CD: Fastlane + CodePush

Одним из решающих факторов стала возможность OTA-обновлений. CodePush (теперь часть App Center) позволяет обновлять JavaScript-бандл без прохождения ревью в сторах. Для сервиса доставки, где критические баги нужно чинить за часы, а не дни — это must-have.

# fastlane/Fastfile
platform :ios do
  lane :deploy_production do
    increment_build_number
    build_app(
      scheme: 'QuickDelivery',
      export_method: 'app-store',
      xcargs: '-allowProvisioningUpdates'
    )
    upload_to_app_store(
      skip_metadata: true,
      skip_screenshots: true,
      precheck_include_in_app_purchases: false
    )
  end

  lane :codepush_release do
    sh('npx appcenter codepush release-react ' \
       '-a QuickDelivery/iOS ' \
       '-d Production ' \
       '--mandatory ' \
       '--description "#{ENV["RELEASE_NOTES"]}"')
  end
end

platform :android do
  lane :deploy_production do
    gradle(
      task: 'bundle',
      build_type: 'Release',
      project_dir: 'android/'
    )
    upload_to_play_store(
      track: 'production',
      aab: 'android/app/build/outputs/bundle/release/app-release.aab'
    )
  end
end

Для Flutter существует Shorebird — аналог CodePush, но на момент начала проекта он был в стадии beta и не поддерживал обязательные обновления. Это добавило ещё один аргумент в пользу React Native.

Наш CI/CD пайплайн на GitHub Actions выглядел так:

# .github/workflows/mobile-ci.yml
name: Mobile CI/CD
on:
  push:
    branches: [main, release/*]
  pull_request:
    branches: [main]

jobs:
  lint-and-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: yarn
      - run: yarn install --frozen-lockfile
      - run: yarn lint
      - run: yarn test --coverage
      - uses: codecov/codecov-action@v4

  build-android:
    needs: lint-and-test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: ruby/setup-ruby@v1
        with:
          ruby-version: 3.2
          bundler-cache: true
      - run: yarn install --frozen-lockfile
      - run: bundle exec fastlane android deploy_production
        env:
          PLAY_STORE_JSON_KEY: ${{ secrets.PLAY_STORE_KEY }}

  build-ios:
    needs: lint-and-test
    runs-on: macos-14
    steps:
      - uses: actions/checkout@v4
      - run: yarn install --frozen-lockfile
      - run: cd ios && pod install
      - run: bundle exec fastlane ios deploy_production
        env:
          APP_STORE_CONNECT_API_KEY: ${{ secrets.ASC_KEY }}

Финальный выбор: React Native

После 6 недель прототипирования мы собрали команду и представили результаты. Решение было принято в пользу React Native по совокупности факторов:

  1. Переиспользование кода — до 35% бизнес-логики шарится с веб-версией
  2. OTA-обновления — CodePush позволяет выкатывать хотфиксы за минуты
  3. Кадровый рынок — доступность JS/TS-разработчиков на порядок выше
  4. Экосистема npm — готовые решения для 90% задач
  5. New Architecture — Fabric и TurboModules закрыли исторический гэп по производительности

Flutter выиграл по «сырой» производительности, но проиграл по экосистеме и стратегическим факторам. Для проекта, где команда должна расти с 6 до 15 человек за год, возможность найма — не менее важный фактор, чем миллисекунды холодного старта.

Продакшн-метрики через год

Приложение вышло в продакшн в декабре 2024 года. Спустя 10 месяцев — цифры:

  • 2.1 млн активных пользователей в месяц (MAU)
  • Crash-free rate: 99.7% на iOS, 99.4% на Android
  • Средний холодный старт: 1.3 с (iPhone), 1.9 с (Android mid-tier)
  • Общая кодовая база: 94% шаринга между платформами
  • Время релиза: сократилось с 4 недель до 1 недели для store-обновлений, хотфиксы через CodePush — в тот же день
  • Команда: выросла до 14 мобильных разработчиков, 12 из которых пришли с веб-фронтенда
  • Рейтинг: 4.7 в App Store, 4.5 в Google Play

Уроки и рекомендации

Если вы стоите перед аналогичным выбором, вот наши рекомендации:

Выбирайте Flutter, если: у вас нет существующей веб-кодовой базы на React, команда уже знает Dart, OTA-обновления не критичны, и вы строите приложение с тяжёлой графикой (игры, кастомные UI).

Выбирайте React Native, если: у вас есть веб на React, важен найм из широкого пула JS-разработчиков, критичны OTA-обновления, и вы готовы инвестировать в New Architecture.

Универсального ответа нет. Оба фреймворка в 2025 году — зрелые, продакшн-готовые инструменты. Но контекст проекта, команды и бизнеса определяет выбор не меньше, чем технические бенчмарки.

Нужна помощь с проектом?

Специалисты АйТи Фреш помогут с архитектурой, DevOps, безопасностью и разработкой — 15+ лет опыта

📞 Связаться с нами
#apple pay / google pay.#cicd#codepush#dart-экосистема.#developer#devops#experience#fastlane
Комментарии 0

Оставить комментарий

загрузка...