Как мы «пересобрали» Cordova внутри Flutter и сменили стек без даунтайма

Старая кодовая база на Cordova ограничивала наше мобильное приложение: не хватало производительности и возможностей для интеграции с нативными SDK. Мы нашли способ перейти на Flutter без остановки релизов: запустили Angular в WebView с JS-мостом, что позволило постепенно внедрять нативные экраны.

Анализ компьютерных систем — это как воспитание детей; можно нанести огромный вред, но нельзя гарантировать успех.
— Tom DeMarco

Мы хотели уйти от Cordova к Flutter, но не могли заморозить релизы на долгие месяцы. И вот оно, решение: запустить собранное Angular‑приложение в своём WebView внутри Flutter-приложения, раздавать его ассеты локальным HTTP‑сервером, эмулировать cordova.js, создав мост JS ↔ Flutter ↔ Native и постепенно начать подмешивать в этот адский коктейль нативные Flutter‑экраны с помощью JS ↔ Flutter роутера! Делимся тем, что мы пережили за это время.

А с чего всё начиналось?

Мы в YCLIENTS однажды уже меняли "фреймворк". Сначала у нас было приложение на AngularJS, которое мы запихнули в Cordova. И после этого, когда вышел Angular 2, насмотревшись статей и видео от умных людей, мы решили: Переходу быть! В то время был использован пакет, который позволял запускать и поддерживать компоненты, написанные на обоих фреймворках, в рамках одного проекта. Именно так этот переход и происходил. Приложение жило, мы постепенно переносили старые компоненты на новые рельсы и всё было здорово. В конце-концов отсекли лишнее, пересели на новый механизм сборки и, кажется, вот она, удача. Но удача была с нами недолго...

Cordova, а в то время Apache уже перестал её поддерживать, начала потихоньку умирать. Мы стали смотреть в сторону Ionic-Capacitor, но казалось, что хоть новые игрушки и поддерживали backward compability с инструментарием Cordova, но мы понимали, что для того, чтобы сделать кратный шаг вперёд, нам нужно большее. Да и в целом не было уверенности в том, что Capacitor не загнётся так же через год, как сделал его предшественник. Всё чаще появлялись задачи на различные интеграции с нативными SDK (например, в голове нашего продакта маячили: оплата через 2Can, SDK для умных браслетов и часов, общение через различные протоколы с различными умными гаджетами) и поэтому мы решили шагнуть в сторону технологии, с которой у нас был опыт в качестве хакатона - Flutter. На нём мы сделали за месяц приложение, которое через год стрельнуло на ~2 млн скачиваний в сторах.

Каков был план

План родился из двух противоречивых требований: "Избавиться от кордовы" и "не останавливать поставку". Значит, большой взрыв отпадает. Нужна эволюция.

Мы сформулировали три простых идеи:

  • Не трогаем Angular‑приложение. Оно должно «думать», что всё по‑старому: есть window.cordova, есть знакомые плагины и сигнатуры. Ничего не меняется
  • Весь платформенный слой — под управление Flutter. Разрешения, хранилища, диплинки, камеры, платежи — всё уходит в экосистему Flutter, а из него в Native
  • Навигацию делим пополам: ветка «legacy» (web) и ветка «flutter» (native). Они живут рядом, а мы решаем, куда отправить пользователя здесь и сейчас. Чтобы 2 экрана на разных технологиях могли жить в рамках одного модуля

Переводя на человеческий: мы поднимаем Flutter как «операционную систему» приложения, а внутрь — вставляем WebView с нашим собранным Angular. Angular продолжает разговаривать с неким cordova.js — это наша прослойка, которая перехватывает вызовы и отправляет их во Flutter. А мы уже внутри Flutter делаем всё как положено, без костылей. Ну и, конечно, возвращаем в том же формате, который был в оригинальном cordova.js

Первый прототип: «Заводим старый фронт в новом доме»

Первая цель была очень маленькая: увидеть наш index.html в окне webview_flutter и убедиться, что всё грузится быстро и без плясок с бубном.

Мы положили Angular‑бандл в assets и не стали читать его напрямую. В браузере есть URL‑пространство, относительные пути, заголовки. В ассетах — нет. Поэтому мы подняли внутри приложения маленький HTTP‑сервер на 127.0.0.1:порт и начали отдавать файлы как будто из обычного хоста. Это решило разом проблему относительных путей, динамических импортов и типов Content-Type.

Код сервера — на двадцать-тридцать строчек. Смысл такой: пришёл запрос на /, отдай assets/www/index.html; попросили /main.js — отдай assets/www/main.js. Пара заголовков — и WebView остался счастлив.

«Мы всё ещё Cordova». Или как мы притворились старым рантаймом

Дальше — магия совместимости. Мы написали собственный cordova.js, который снаружи выглядит как старый: тот же window.cordova, те же методы. Внутри он не делает «настоящих» кордовских вызовов — он шлёт сообщение через JavaScriptChannel во Flutter и ждёт ответа.

Схема такая:

  • Angular вызывает функцию из window.cordova ровно так же, как делал этот раньше 
  • Наш cordova.js пакует это в { id, service, action, args } и отправляет через window.CordovaBridge.postMessage(...).
  • Во Flutter мы принимаем сообщение, делаем работу (через плагины или натив), формируем ответ { status: 'OK'|'ERROR', result|error }.
  • Возвращаем в JS, где cordova.js резолвит промис, адаптируя его под тот же формат, который возвращался в cordova. И хоп — и Angular счастлив

Мы специально сохранили всё так, как было раньше, в плагинах. Было интересно, получится ли взять и создать такую Flutter-среду, в которую можно было бы поместить любое приложение, написанное под Cordova, а оно заработает на новых рельсах из коробки.

Что это дало: мы можем один за другим переносить плагины с кордовской логики на Flutter‑плагины, не трогая веб‑код. Angular живёт в иллюзии, что он по‑прежнему в Cordova.

Навигация: два стека, один пользователь

Когда появлялись первые нативные экраны, стало понятно: нам какое-то время придётся жить в двух мирах. Поэтому мы завели две параллельные ветки в GoRoutercordova — это «вебовый стек», flutter/* — нативный. Если перейти на роут Cordova - то откроется наш WebView. И дальше с помощью ещё одного механизма можно передать внутрь Angular информацию о том, на каком роуте его хочет видеть Flutter. Обе ветки держим в памяти, переключение между ними — почти мгновенное.

Самый тонкий момент — кнопка Back на Android. Если пользователь сейчас «внутри веба», мы сначала спрашиваем у Angular, может ли он шагнуть назад? Был ли в нашей "общей истории" Angular-экран на прошлой позиции? Может — отлично, остаёмся в legacy. Не может — закрываем legacy‑экран и уходим в предыдущий Flutter‑роут. То есть не система диктует, а «общая история», которую ведёт маленький сервис с обеих сторон. Например,

Диплинки — по тому же принципу. Все ссылки приходят во Flutter. Дальше мы решаем: эта фича уже переехала в натив? Тогда ведём на flutter/*. Ещё живёт в вебе? Значит, открываем cordova с правильным путём внутри Angular. Это позволяет мигрировать экраны по одному, не ломая маршруты.

Грабли, в которые влезли сами (чтобы вы не повторяли)

  • iOS ATS и «локальный http». Никаких «разрешить всё». Только точечные исключения для 127.0.0.1/localhost. Аналогично на Android — только loopback в networkSecurityConfig
  • Пересоздание WebView. Если каждый раз дропать контроллер или менять порт — теряется состояние, сессия, кеш. Держим один WebViewController, аккуратно управляем жизненным циклом
  • Большие полезные нагрузки через мост. Очень тяжело передавать, например, фото в Base64. Перешли на URI/файлы — жизнь стала легче
  • Странные старые плагины. Где‑то пришлось не просто «проксировать», а менять UX: например, фоновые загрузки мы сделали нативными и показали нормальные системные уведомления
  • На старте были некие проблемы с совместимостью на Android. Из-за особенностей энергосбережения некоторые смартфоны "убивали" WebView, делали что-то ещё, и, юзер оставался с белым экраном. Flutter в этом плане более простой и удобный, но WebView создаётся в системе отдельным Activity, а значит и правила на него действуют его собственные

Что получилось

Мы перенесли центр тяжести на Flutter, не остановив релизы. Angular живёт в WebView, думает, что под ним по‑прежнему Cordova, а на самом деле — наш мост JS ↔ Flutter ↔ Native. Новые экраны — сразу нативные, старые — переезжают постепенно. Пользователь этого почти не замечает, а команде не нужно бросать всё и переписывать мир.

Подписаться на новые выпуски блога

Не пропустите последние обновления.
i.ivanov@yandex.ru
Подписаться