Как стать автором
Поиск
Написать публикацию
Обновить

Как мы переписали мобильное приложение с React Native на Flutter

Уровень сложностиПростой
Время на прочтение8 мин
Количество просмотров9.4K

Когда мы только начинали разработку мобильного приложения, выбор пал на React Native — казалось, это идеальный компромисс между скоростью разработки и кроссплатформенностью. Однако, со временем мы столкнулись с рядом проблем: низкая производительность на слабых Android-устройствах, сложность поддержки MapKit SDK, нестабильная работа некоторых библиотек и отсутствие нормальных dev-tools.

Основной фишкой приложения была интерактивная карта: отображался маршрут движения для водителя и более 10 000 объектов на экране одновременно. Для этого использовалась виртуализация, а в некоторых сценариях - еще и сортировка обьектов по маршруту движения. С каждой версией функциональность карты становилась все сложнее и тяжелее - что быстро начало сказываться на производительности.

Более того, мы довольно быстро пришли к выводу, что адекватной обертки для MapKit SDK под React Native попросту нет. Единственный существующий пакет https://github.com/volga-volga/react-native-yamap не реализовывал большую часть необходимого функционала: отсутствовала кастомизация кластеров, нельзя было анимировать обьекты, не поддерживался оффлайн-режим.

Самой критичной проблемой стал баг, из-за которого при загрузке нового js-бандла через CodePush(на данный момент уже не поддерживается) полностью пропадали все локальные иконки на карте. В результате мы приняли решение форкнуть существующую библиотеку и дописать весь необходимый функционал самостоятельно - от кластеризация до кастомных иконок и анимации.

После очередного поднятия версии React Native, где появилась поддержка targetSdk 35, полностью перестала отображаться вью с той самой картой. Пожалуй, для меня это был последний гвоздь в крышку гроба этой технологии.

Почему выбрали Flutter?

В мире мобильной разработки на текущий момент видна существенная тенденция на переход в кроссплатформу. Да, хочется и рыбку съесть и ... сразу на две платформы приложение написать. А конкурентов здесь не так и много: Flutter, Kotlin Multiplatform, React Native.

Kotlin Multiplatform показался слишком сырым для полноценной разработки в небольшой команде. Да, бизнес-логику можно было бы переиспользовать, но UI по-прежнему пришлось бы писать отдельно для iOS и Android - а это сильно снижает выигрыш по времени и усилиям. Кроме того, до недавнего времени существовала проблема с фризами, связанные с работой сборщика мусора. А из-за молодого комьюнити выбор готовых библиотек оказался бы ограниченным, и многое приходилось бы реализовывать вручную.

На мой взгляд, KMP отлично подходит для готовых продуктов, которые уже написаны на Kotlin и хотят добавить поддержку iOS с минимальными затратами.

Процесс миграции

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

Уже на первых этапах я ощутил всю мощь этого инструмента - здесь все сделано с прицелом на разработчика. Удобный CLI, развитый пакетный менеджер pub.dev, встроенный DevTools с профилировщиком, инспектором и визуализатором слоев - все это реально ускоряет разработку и помогает быстро отлавливать узкие места в производительности.

Если в команде есть хороший дизайнер с проработанной дизайн системой, то скорость разработки возрастает еще больше - в проекте можно заранее определить темы, цветовые схемы, стили текста и отдельные виды компонентов (например, BottomSheet, AppBar, Switcher) - то, чего не хватало в React Native "из коробки". После этой проделанной работы компоненты будут выглядеть аккуратно, без лишней верстки и стилей.

Изначально, не очень хотелось садиться за Flutter из-за непонятного языка Dart, который больше нигде не используется. Однако уже в первый день бОльшую часть синтаксиса я все же усвоил: Dart оказался удивительно похож на JavaScript. К тому же мне сильно помог предыдущий опыт с ООП-языками - С++, C#, Kotlin.

Наложив свой прошлый опыт написания навигатора на Jetpack Compose, где архитектурные концепции и подходы во многом схожи, освоение нового фреймворка показалось довольно быстрым.

В конечном итоге команда из 3 разработчиков переписала существующее приложение на Flutter за полгода. При этом проект был далеко не маленький и точно не из простых: более 60 экранов, настроенные push-уведомления, deeplinks, реализован оффлайн-режим, активная работа с камерой, а также использование нативных модулей.

Каков результат? Что мы получили?

  1. Стабильность

    У собственного движка отрисовки есть свои плюсы - это будет выглядеть и работать одинаково на всех платформах, с багами или без.

    Ранее, при использовании React Native, мы сталкивались с ситуациями, когда на определенных устройствах(Samsung, Oppo, Honor) происходили визуальные баги - часть контента не отображалась, но обработчики продолжали отрабатывать.

    После закрытия камеры контент предыдущий страницы исчез
    После закрытия камеры контент предыдущий страницы исчез
    Часть BottomSheet не видно
    Часть BottomSheet не видно
  2. Производительность

    Средний FPS вырос на глазах - на тестовом устройстве за 10 000 рублей - Huawei nova Y9, купленном 2.5 года назад - среднее значение при использовании приложения 85 кадров в секунду!

    Но еще больше меня удивило другое: пользователи iOS начали сами писать в чаты, что интерфейс стал заметно плавнее и отзывчивее.

    Добиться такого уровня производительности удалось в том числе благодаря выносу тяжелой логики в отдельный изолят (если упрощать - в отдельный поток).

    Одна из главных проблем React Native - это работа приложения в одном основном потоке. В результате любые ресурсоемкий задачи - например, обработка большого количество объектов для виртуализации и кластеризация или построение маршрута - могли легко заблокировать основной UI thread, приходилось использовать более оптимизированные алгоритмы и дробить операции на Promise'ы.

    Начиная с Flutter 3.7, появилась возможность выносить в отдельный изолят не только dart'овые вычисления , но и вызовы нативных модулей. Теперь мы обрабатываем данные из базы, парсим большие ответы с сервера и выполняем тяжелые вычисления вне основного потока. Результат был виден сразу - интерфейс оставался плавным и отзывчивым.

    Для удобства мы написали свой менеджер взаимодействия с изолятами, чтобы централизовать и упростить передачу данных между потоками. Ниже представлена основная часть кода.

    class WorkerManager {
      static final WorkerManager _instance = WorkerManager._internal();
    
      factory WorkerManager() => _instance;
    
      WorkerManager._internal();
    
      List<Worker> _workers = [];
      Queue<Task> _taskQueue = Queue();
    
      Map<Capability, Completer> _activeTaskCompleters = {};
    
      Future<void> turnOn({int workersCount = 2}) async {
        _workers = [];
        _taskQueue = Queue();
        _activeTaskCompleters = {};
    
        for (int i = 0; i < workersCount; i++) {
          Worker worker = Worker();
          await worker.init(onResult: _onTaskFinished);
          _workers.add(worker);
        }
      }
    
      Future<T> compute<T, Y>(T Function(Y) fn, {Y? param}) async {
        final taskCapability = Capability();
        final taskCompleter = Completer();
    
        final Task task = Task(param: param, task: fn, capability: taskCapability);
    
        _activeTaskCompleters[taskCapability] = taskCompleter;
    
        Worker? freeWorker;
        for (final worker in _workers) {
          if (worker.status == WorkerStatus.idle) {
            freeWorker = worker;
            break;
          }
        }
        if (freeWorker == null) {
          _taskQueue.add(task);
        } else {
          freeWorker.execute(task);
        }
        T result = await taskCompleter.future;
        return result;
      }
    
      Future<void> turnOff() async {
        for (Worker worker in _workers) {
          await worker.dispose();
        }
      }
    
      void _onTaskFinished(TaskResult result, Worker worker) {
        Completer? taskCompleter = _activeTaskCompleters.remove(result.capability);
        taskCompleter?.complete(result.result);
    
        if (_taskQueue.isNotEmpty) {
          final task = _taskQueue.removeFirst();
          worker.execute(task);
        }
      }
    }

    Прогнав приложение через профайлер получили следующее:

    Потребление оперативной памяти на двух технологиях практически одинаковое

    Flutter - потребление в пике 578мб
    Flutter - потребление в пике 578мб
    React Native - потребление в пике 545мб
    React Native - потребление в пике 545мб
  3. Нюансы

    Безусловно, Flutter мощный инструмент, но и у него есть свои проблемы, с которыми мы продолжаем жить.

    1. Отрисовка изображений

      Эта проблема существует с самого появления фреймворка и до сих пор вызывает дискомфорт. Из-за особенностей отрисовки ресурсов изображение не появляется сразу:

      • На карте, например, иконки через MapKit SDK могут не успеть отрисоваться вовремя — вместо маркеров пользователь видит “пустой” круг

      • На первом экране онбординга в течение первых 100-200 мс вместо фоновой картинки пользователь видит белый экран, что особенно раздражает.

        Частично это фиксится с помощью функции precacheImage, подгружая изображения заранее. Но согласитесь, если вы впервые запускаете приложение и видите "белое полотно" вместо фона - впечатление будет испорчено.

    2. Фризы при первых анимациях

      Первое с чем мы столкнулись при работе с Flutter - мелкие подтормаживания при первой отрисовке анимированных элементов.

      Почему это происходит?

      На текущий момент практически все приложения, которые написаны на Flutter, используют движок Skia, у которого есть важная особенность: шейдеры компилируются "на лету".

      То есть при первом запуске анимации, требующей определенного шейдера, движок сначала его собирает и только потом применяет. Это занимает миллисекунды - но иногда достаточно, чтобы пользователь заметил подлагивание или рывок.

      При этом есть возможность провести "фейковую" отрисовку, чтобы скомпилировать заранее соответствующий шейдер до запуска основного UI:

      void main() {
        runApp(const MyApp());
      
        // Кастомный прогрев шейдеров
        final warmUp = MyShaderWarmUp();
        warmUp.execute();
      }
      
      class MyShaderWarmUp extends DefaultShaderWarmUp {
        @override
        void warmUpOnCanvas(Canvas canvas, Size size) {
          final paint = Paint()..shader = const LinearGradient(
            colors: [Color(0xFFE91E63), Color(0xFF2196F3)],
          ).createShader(Rect.fromLTWH(0, 0, size.width, size.height));
      
          // Принудительная отрисовка сложных элементов
          final rect = Rect.fromLTWH(0, 0, size.width, size.height);
          canvas.drawRRect(
            RRect.fromRectAndRadius(rect, const Radius.circular(20)),
            paint,
          );
      
          // Можно добавить другие сложные элементы (тени, клиппинг и т.д.)
        }
      }

      Flutter активно развивает новый движок отрисовки - Impeller, призванный решить эту проблем путем предсобранных шейдеров. Однако он остается нестабилен для многих устройств Android. Мы столкнули с этим на собственном опыте во время наших тестов - дешевое устройства работало идеально, выдавая максимальную производительность без намека на подтормаживания, но запустив тот же APK файл на Samsung Galaxy A55 - тормоза были видны при открытии простого BottomSheet.

    3. Обработка Deeplink

      Мы используем разделение навигации на авторизованную и неавторизоованную части. И здесь есть подводный камень: если приложение было запущенно по deeplink'у, а потом пользователь разлогинился и роутер пересоздал, применится старый deeplink к уже неактуальному состоянию. Чтобы исправить эту ошибку, придется писать дополнительную логику и вводить проверки.

Итоги

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

Такой переход ускорил Time To Market - теперь команда сфокусирована на разработке новых фичей, а не на исправление багов.

Отдельно стоит сказать про выбор технологии с учетом реальной картины на рынке.

Flutter на сегодня самый актуальный и популярный инструмент для разработки кроссплатформенных мобильных приложений в СНГ. Почему это важно?

  • Больше разработчиков на рынке - в отличии от React Native, где часто встречаются frontend-разработчики, переехавшие из web, без понимания мобильной специфики

  • Больше библиотек и SDK с официальной поддержкой - многие SDK(например, Yandex, 2GIS, VK SDK, платежный шлюзы) поддерживают только Flutter-обертки

  • Стабильность. С каждым разом Meta выпиливает из ядра RN ключевые части, из-за чего на каждый чих приходится залазить на NPM и искать библиотеку, за поддержку которой никто не отвечает. Например, для нормальной анимации нужно подтягивать react-native-reanimated, а для корректной работы с safeArea - react-native-safe-area. Во Flutter же дела обстоят иначе - необходимые инструменты уже встроены в SDK, крупные библиотеки поддерживаются самим Google, а для публикации пакета на pub.dev автор обязан пройти валидацию и соблюсти определенные стандарты качества

  • Процесс обновления проще. Еще один небольшой субъективный плюс - практически безболезненные обновления. С начала разработки мы поэтапно обновляли Flutter SDK с версии 3.22 до актуальной 3.32, и за все это время не столкнулись ни с одной критичной проблемой: никакие ключевые модули не ломались, библиотеки поддерживались(кроме HMS PushKit, пришлось удалить deprecated API).
    Для сравнения: в проекте React Native мы так и не смогли корректно перейти выше версии 0.74 с отключенной новой архитектурой.

Если вы стоите перед выбором технологий — не бойтесь перемен. Иногда смена инструмента способна вдохнуть в проект вторую жизнь.

Теги:
Хабы:
+19
Комментарии43

Публикации

Ближайшие события