Привет, Хабр.

WebView-приложения — это боль. Тормоза, убогий UX, мгновенный реджект от Apple по пункту 4.2 («Minimum Functionality»). Обычно это просто браузер без адресной строки, за который стыдно брать деньги.

Я решил не делать очередную "обертку", а подойти к задаче инженерно. Моя цель: платформа, где WebView — лишь контентный слот, обернутый в полноценный нативный UI на Flutter.

В этой статье:

  1. Архитектура: Как связать JS и Dart через двусторонний мост.

  2. Server-Driven UI: Как менять нативные элементы управления (табы, шторки) без пересборки.

  3. 5 платформ из одной кодовой базы: iOS, Android, macOS, Windows, Linux.

  4. Bypass Apple Review: Как мы проходим модерацию, отдавая нативные пермишены и экраны.

Никакой воды, только архитектура и код.

1. Почему Flutter, а не React Native или KMP?

Когда встала задача сделать «конвейер» по сборке приложений, главными требованиями были:

  1. Производительность: Оболочка не должна лагать.

  2. Мультиплатформенность: Заказчики хотят не только мобилку, но часто и десктопные версии (для корпоративных порталов, киосков и т.д.).

Почему Flutter победил:

  • Единая кодовая база для 5 платформ. React Native хорош для мобилок, но с десктопом там все сложнее (хотя подвижки есть). Flutter позволил мне написать один движок, который собирается в .apk.ipa.exe.dmg и Linux-бинарник.

  • Контроль над пикселями. Flutter рисует интерфейс сам (через Skia/Impeller). Это значит, что мои нативные меню, шторки и диалоги выглядят идентично везде.

  • Platform Channels. Удобная связь с нативной частью для специфических функций (например, оплаты или сложные пуши).

2. Архитектура «Умного WebView»

Обычный веб-враппер — это виджет WebView на весь экран. Мой подход — это «слоеный пирог».

      +---------------------------------------------------+
      |          LAYER 1: FLUTTER NATIVE SHELL            |
      | +-----------------------------------------------+ |
      | |  [=] App Bar             Bottom Nav Bar [..]  | |
      | |  Native Loaders          Native Permissions   | |
      | +-----------------------------------------------+ |
      +------------------------+--------------------------+
                               |
                   (Commands / Haptic / Push)
                               |
      +------------------------v--------------------------+
      |              LAYER 2: JS BRIDGE                   |
      |   [ Dart Code ]  <==========>  [ JavaScript ]     |
      +------------------------+--------------------------+
                               |
                    (Events / Auth Tokens)
                               |
      +------------------------v--------------------------+
      |               LAYER 3: WEBVIEW                    |
      | +-----------------------------------------------+ |
      | |  <html>                                       | |
      | |    Your Website (SPA/SSR)                     | |
      | |    React / Vue / Angular / WordPress          | |
      | |  </html>                                      | |
      | +-----------------------------------------------+ |
      +---------------------------------------------------+

Слой 1: Нативная оболочка (Flutter)

Пользователь не должен чувствовать, что он в браузере. Поэтому вся навигация вынесена из HTML в нативный код:

  • Bottom Navigation Bar — это нативный Flutter-виджет. Он переключает табы мгновенно, с правильной анимацией.

  • AppBar / Drawers — тоже нативные.

  • Loaders & Errors — если интернет пропал, мы показываем красивый нативный экран «Нет сети» с кнопкой «Повторить», а не стандартную ошибку браузера.

Слой 2: Мост (JS Bridge)

Чтобы сайт и приложение общались, я использую двусторонний канал связи.

Сценарий: Пользователь нажимает «Оформить заказ» на сайте. Как это работает:

  1. JS на сайте отправляет событие: AppLikeWeb.postMessage(JSON.stringify({event: 'purchase', value: 100}));

  2. Dart-код ловит это сообщение через JavascriptChannel.

  3. Приложение парсит событие и запускает нативные действия:

    • Вибрация телефона (Haptic Feedback).

    • Отправка события в AppsFlyer (через нативный SDK, а не через JS-пиксель, что надежнее).

    • Запрос оценки приложения (In-App Review).

// Упрощенный пример обработки на стороне Flutter
JavascriptChannel(
  name: 'AppLikeWeb',
  onMessageReceived: (JavascriptMessage message) {
    final data = jsonDecode(message.message);
    switch (data['event']) {
      case 'purchase':
        AnalyticsService.trackPurchase(data['value']);
        HapticFeedback.mediumImpact();
        break;
      // ... другие кейсы
    }
  },
)

3. Киллер-фича: Server-Driven UI (SDUI)

Самая большая боль мобильной разработки — цикл обновлений. Заказчик говорит: «Давайте покрасим нижнее меню в красный к распродаже» или «Уберем вкладку 'Профиль'». В классике: Правка кода -> Сборка -> Заливка в Store -> Ревью (1-3 дня) -> Раскатка.

Я внедрил Server-Driven UI. При старте приложение делает легкий запрос к API конфига.

Что можно менять "на лету" без пересборки:

  • Шапка (Header): Скрыть/показать, изменить заголовок (вместо title сайта), выровнять текст (слева/центр).

  • Навигация: Добавить стрелки "Назад", "Меню" (kebab menu).

  • Цвета: Полный контроль над палитрой (фон, границы, иконки, текст).

  • Внешние интеграции: Включить AdMob, сменить ключи OneSignal/Firebase.

(Подробнее о том, как работает дизайн-конструктор, я писал в блоге компании)

Настройка дизайна приложения
Настройка дизайна приложения

Пример фрагмента конфига:

{
  "theme": {
    "primary_color": "#FF5733",
    "app_bar_style": "floating"
  },
  "navigation": {
    "type": "bottom_bar",
    "items": [
      {
        "id": "home",
        "icon": "home_outlined",
        "url": "https://mysite.com/",
        "label": "Главная"
      },
      {
        "id": "cart",
        "icon": "shopping_cart",
        "url": "https://mysite.com/cart",
        "badge_source_js": "getCartCount()" 
      }
    ]
  },
  "features": {
    "pull_to_refresh": true,
    "admob_enabled": false
  }
}

Flutter приложение, получая этот JSON, «на лету» перестраивает виджеты. Если мне нужно отключить рекламу в версии для iOS, я просто меняю флаг на бэкенде — приложение обновляется при следующем перезапуске. Zero days на ревью.

4. Борьба с Apple Guideline 4.2: Permissions и Actions

Apple ненавидит приложения, которые просто открывают сайт. Их главный аргумент: «Где здесь приложение? Это должен быть Safari».

Мы решаем эту проблему, давая пользователю контроль над Permissions (разрешениями). В панели управления владелец приложения может тонко настроить доступ:

  • Location (Foreground/Background): Критично для сервисов доставки и карт.

  • Camera & Microphone: Для видеозвонков или сканирования QR.

Все изменения применяются мгновенно. Когда модератор видит, что приложение запрашивает специфические права и имеет нативные экраны управления ими — вопрос «почему это не сайт» отпадает.

(Мы выкатили отдельный апдейт Actions & Permissions, чтобы закрыть этот вопрос раз и навсегда)

Управление разрешениями
Управление разрешениями

Кроме того, мы реализовали глубокую интеграцию нативных фич:

  1. Биометрия: FaceID/TouchID для входа.

  2. Оффлайн-режим: Красивые заглушки вместо "белого экрана".

  3. Нативная навигация: TabBar с сохранением стейта.

5. Монетизация и Вовлечение (Firebase + AdMob)

Бизнес делает приложения не ради галочки, а ради денег и удержания клиентов.

Push-уведомления (Firebase Cloud Messaging): Мы сделали интеграцию максимально простой. Вставляете конфиг Firebase -> включаете галочку -> пуши работают.

  • Поддержка Topics и сегментации аудитории.

  • Отправка маркетинговых акций или системных алертов.

(Кейс внедрения пушей описан здесь)

Firebase Push Notifications
Firebase Push Notifications

AdMob (Реклама): Можно включить показ рекламы (баннеры, межстраничные) на конкретных экранах. Это позволяет медиа-ресурсам зарабатывать на мобильном трафике, сохраняя нативный UX.

Монетизация
Монетизация

6. Инфраструктура: Как собирать сотни приложений?

Ручная сборка в Xcode/Android Studio для каждого клиента — это путь в ад. Процесс в AppLikeWeb полностью автоматизирован.

После того как клиент нажал "Create App", запускается CI/CD пайплайн. В конце процесса в разделе Actions появляются ссылки на скачивание всех артефактов:

  • Android: APK, AAB (для Google Play), исходный код.

  • iOS: IPA файл.

  • Desktop: DMG (macOS), ZIP (Windows), AppImage/DEB/RPM (Linux).

Это позволяет клиенту получить готовый бинарник под любую платформу за 10-15 минут без настройки окружения.

Генерация APK
Генерация APK

Пример того, как это выглядит "под капотом" (Bash + Fastlane):

# 1. Генерируем native assets (иконки и сплеши)
flutter pub run flutter_launcher_icons:main
flutter pub run flutter_native_splash:create

# 2. Подменяем Bundle ID / Package Name
dart run rename_app.dart --bundleId "com.client.$APP_ID" --appName "$APP_NAME"

# 3. Сборка и подпись (iOS)
flutter build ipa --release --export-options-plist=ExportOptions.plist
fastlane pilot upload --ipa build/ios/ipa/*.ipa

7. Технические грабли (Подводные камни)

Конечно, не все было гладко. Вот ТОП-3 проблемы, с которыми я столкнулся при скрещивании ежа (Flutter) и ужа (WebView):

Проблема №1: OAuth и "Disallowed User Agent"

Google запрещает авторизацию через WebView из соображений безопасности. Если пользователь нажмет "Войти через Google" на сайте внутри приложения, он увидит ошибку 403. Решение: Пришлось внедрять подмену User-Agent на лету, маскируясь под обычный мобильный браузер, либо (в идеале) перехватывать ссылку на OAuth и открывать её в SFSafariViewController / ChromeCustomTabs, где авторизация разрешена, а куки потом прокидывать обратно.

Проблема №2: Загрузка файлов на Android

На iOS <input type="file"> работает из коробки. На Android WebView по умолчанию игнорирует клики на загрузку файлов. Решение: Нужно переопределять onShowFileChooser в WebChromeClient и вручную вызывать нативный пикер файлов Flutter'а, а затем передавать результат обратно в WebView.

Проблема №3: Синхронизация Cookies

Если пользователь залогинился в нативной части (например, через нативный экран логина), WebView об этом не знает.

Решение: Использование CookieManager. При старте приложения я беру токен из SecureStorage и инжектю его в куки WebView до того, как загрузится первая страница.

Future<void> syncCookies(String url) async {
  final cookieManager = WebViewCookieManager();
  final token = await _storage.read(key: 'auth_token');
  
  if (token != null) {
    await cookieManager.setCookie(
      WebViewCookie(
        name: 'access_token',
        value: token,
        domain: Uri.parse(url).host,
        path: '/',
      ),
    );
  }
}
// Вызываем это строго перед controller.loadRequest(uri)

Заключение

Проект AppLikeWeb — это попытка сделать инструмент, который экономит месяцы разработки для контентных проектов, E-commerce и медиа. Это не замена нативной разработке для сложного банкинга или игр, но для 90% бизнесов, у которых уже есть хороший мобильный сайт — это самый быстрый способ попасть в стор с качественным UX.

Сейчас я активно допиливаю интеграции с аналитикой и улучшаю поддержку десктопных ОС.

Буду рад вашему фидбеку: какие нативные фичи вы считаете критически важными для гибридных приложений? И был ли у вас опыт (успешный или нет) прохождения ревью Apple с подобными решениями?

Статья написана при поддержке моих друзей:

  • MegaV.app — быстрый и безопасный VPN и не только.

  • PinVPS — надежные облачные серверы (NVMe, Ryzen) для хостинга ваших проектов и бэкенда.

(Ссылка на сам сервис из статьи: https://applikeweb.com/)

Комментарии (3)