Начало работы с гибридными приложениями на React Native

Начало работы с гибридными приложениями на React Native

1. Введение

a. Почему гибридная структура?

Первый вопрос, с которым сталкиваются разработчики веб-приложений React при первом взаимодействии с React Native, — это “насколько можно повторно использовать существующий веб-код?”. Создание мобильного приложения не обязательно означает, что каждый экран и бизнес-логика должны быть переписаны на React Native. Если в существующем веб-приложении React уже настроены маршрутизация, обработка прав, управление состоянием и интеграция API, то гибридная структура, выполняющаяся в WebView внутри приложения React Native, становится реальным выбором.

Эта структура разделяет роли веба и приложения. Веб-приложение поддерживает существующие экраны и доменную логику, а приложение React Native выполняет роль нативной оболочки, обертывающей WebView. Нативная оболочка отвечает за функции, близкие к мобильной среде, такие как хранение учетных данных, экран загрузки, определение состояния сети, обработка кнопки назад на Android, и управление безопасной областью.

Тем не менее, использование WebView не означает, что достаточно просто отобразить веб-адрес в приложении. Веб и нативное приложение работают в разных средах выполнения. Веб работает на основе браузерного времени выполнения, а React Native работает на нативном времени выполнения, близком к мобильной ОС. Поэтому необходимо отдельно проектировать синхронизацию состояний между двумя областями, способы передачи сообщений, порядок инициализации, а также обработку ошибок.

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

b. Объем этой статьи

Объем этой статьи охватывает первоначальное проектирование структуры гибридного приложения. В частности, сфокусирован на следующих пунктах.

  • Разделение ролей между веб-приложением React и приложением React Native

  • Структура оболочки WebView

  • Контракт связи между вебом и нативным приложением

  • Синхронизация первоначальных учетных данных

  • Связь между экраном загрузки и состоянием готовности веба

  • Обработка кнопки назад на Android

  • Передача состояния сети

  • Пункты безопасности и проверки, которые необходимо доработать на этапе эксплуатации

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

2. Основная структура

a. WebView оболочка

Точка отсчета для гибридного приложения на основе WebView — это единая оболочка WebView. Приложение React Native выполняет роль оболочки приложения, которое устанавливает пользователь, а реальные экраны сервиса обрабатываются веб-приложением React, выполняющимся внутри WebView.

В этой структуре приложение React Native имеет следующие обязанности.

  • Загрузка WebView

  • Обработка безопасной области вверху/внизу приложения

  • Управление нативным сплэш-экраном

  • Доступ к нативному хранилищу, такому как SecureStore

  • Обработка нажатия кнопки «Назад» на аппаратном уровне Android

  • Проверка состояния сети на основе NetInfo

  • Пересылка сообщений между вебом и нативным приложением

Веб-приложение обрабатывает интерфейс и бизнес-логики так же, как и прежде, с использованием React, React Router, React Query, инструментов управления состоянием и т. д. Пользователь чувствует, что использует одно приложение, но внутри нативная оболочка и веб-приложение работают, разделяя свои роли.

Важно при первоначальной настройке структуры рассматривать React Native не как «новый фронтенд, заменяющий веб», а как «оболочку для запуска веба в мобильной среде приложения». Таким образом, можно сохранить существующую веб-логику, подключая минимум необходимых нативных функций для мобильного приложения.

b. Разделение ролей

В гибридной структуре важно избегать смешивания ответственности веба и нативного приложения. Если веб-проект напрямую зависит от React Native, это усложнит его самостоятельное выполнение и тестирование. С другой стороны, если в нативное приложение излишне внедрена логика веб-домена, это приведет к потере преимуществ существующей веб-архитектуры.

Поэтому лучше установить основные направления следующим образом.

  • Веб владеет стеком веб-технологий и логикой домена.

  • Нативное приложение владеет Expo, React Native, WebView, SecureStore и нативными настройками.

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

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

Например, если вебу нужно изменить заголовок нативного приложения, вебу не обязательно знать о React Native компонентах. Веб просто отправляет сообщение, например UPDATE_HEADER_TITLE, через мост. Нативное приложение получает это сообщение и изменяет состояние своего заголовка. Таким образом, веб знает только о том, что он «запросил изменение заголовка у нативного приложения», и не зависит от фактической реализации пользовательского интерфейса нативного приложения.

c. Монорепозиторий и общий пакет

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

Пример структуры выглядит следующим образом.

packages/
브리지/

apps(episodes)/
web-app/
mobile-shell/

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

Хорошими элементами для включения в общий пакет являются следующие.

  • Имена мостовых действий

  • Типы полезной нагрузки для каждого действия

  • Функция создания сообщения

  • Функция разбора сообщения

  • Функция отправки на веб с нативного

  • Генератор функции отправки с нативного на веб

  • Ключ хранилища, общий для веба и нативного

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

3. Проектирование моста

a. Способы связи между вебом и нативным

Веб внутри WebView и приложение React Native не могут напрямую ссылаться на свои состояния. Между ними нужен явный канал сообщений. Этот канал и есть мост.

При отправке сообщений с веба на нативный используется window.ReactNativeWebView.postMessage(), который встраивается в среду WebView. Нативный получает это сообщение через onMessage WebView.

Напротив, при отправке сообщений с нативного на веб используется WebView.injectJavaScript(). Нативный встраивает JavaScript код внутри WebView, а веб получает это значение через слушателя события message.

Если упростить поток, то это выглядит следующим образом.

Web → Native
window.ReactNativeWebView.postMessage(JSON.stringify(message))

Native → Web
webView.injectJavaScript(...)
window.dispatchEvent(new MessageEvent('message', { data }))

Эта связь, по сути, основана на строках JSON. Поэтому сторона, отправляющая сообщение, и сторона, получающая его, должны делить одинаковые названия действий и структуру полезной нагрузки.

b. Контракт сообщений на основе типов

Сообщения моста основаны на строках, поэтому возможны ошибки. Например, если веб отправляет REQUEST_USER_CONTEXT, а нативный обрабатывает REQUEST_CONTEXT, сообщение может быть доставлено, но функциональность не сработает. Может также возникнуть проблема, когда структура полезной нагрузки изменяется, но изменяется только одна сторона.

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

Пример следующий.

interface NativeToWebEventMap {
  SYNC_USER_CONTEXT: SyncUserContext페이로드;
  NETWORK_STATUS_CHANGED: {
    isConnected: boolean;
    isInternetReachable?: boolean | null;
    type?: string;
  };
}

interface WebToNativeEventMap {
  REQUEST_USER_CONTEXT: void;
  SYNC_USER_CONTEXT: SyncUserContext페이로드;
  CLEAR_USER_CONTEXT: void;
  WEB_APP_READY: void;
  UPDATE_HEADER_TITLE: { title: string };
  REQUEST_NETWORK_STATUS: void;
}

В этой структуре можно различать действия, требующие полезной нагрузки, и действия, которые её не требуют, по типу. Действия без полезной нагрузки определяются как void, а использование условных типов позволяет предотвратить передачу неверных аргументов на этапе компиляции.

Кроме того, на основе карты событий можно создать тип сообщения в форме дискриминируемого объединения, что позволит безопасно обращаться с типом полезной нагрузки для каждого действия на стороне приемника.

Однако типы TypeScript — это безопасность на этапе компиляции. В реальном времени структура парсинга входящей JSON-строки из внешних источников, поэтому на этапе эксплуатации также можно рассмотреть добавление инструментов для валидации схем, таких как Zod.

4. Поток инициализации

a. Руководство по аутентификации

Одной из самых важных вещей, на которую нужно обратить внимание в приложении WebView, является передача начальной аутентификационной информации. У нативного приложения есть token и role, сохраненные в SecureStore, а веб-приложение React в WebView должно получить эту информацию для определения начального экрана.

Самый простой способ — это отправить токен с нативного приложения в веб на момент onLoadEnd WebView. Однако этот способ ненадёжен. onLoadEnd ближе к значению, когда WebView завершила загрузку HTML. Это не означает, что React-приложение уже смонтировано, и listener для получения сообщений моста зарегистрирован.

То есть, нативный считает, что «веб загружен», и отправляет сообщение, но веб ещё может быть не готов к его получению. В этом случае сообщение с токеном может потеряться, и пользователь может увидеть экран, на котором он выглядит как незалогиненный, несмотря на наличие токена.

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

Поток следующий.

  1. Самый верхний компонент веба регистрирует listener SYNC_USER_CONTEXT.

  2. После регистрации listener веб отправляет REQUEST_USER_CONTEXT.

  3. Нативный запрашивает token и role у SecureStore.

  4. Нативный отвечает через SYNC_USER_CONTEXT.

  5. Веб отражает token и role в хранилище и состоянии.

  6. После завершения синхронизации фактический экран будет отрисован.

Ключевое в этом подходе заключается в том, что завершение загрузки WebView и готовность слушателя React не рассматриваются как одно и то же. Запрос осуществляется после того, как веб готов к получению, и такая структура ответа от нативного уменьшает вероятность потери первоначального сообщения.

b. Защита начального экрана

Даже если информация для авторизации поступает по методу рукопожатия, между запросом и ответом существует краткий асинхронный интервал. Если в этот момент веб-приложение будет отрисовано первым, оно может показать экран без входа, пока token еще не отражен. После поступления token снова произойдет переключение на главный экран, и экран будет мигать.

В мобильном приложении такое мигание особенно кажется неестественным. Пользователь не знает внутренней структуры WebView и воспринимает это как одно приложение. Поэтому необходимо защитить отрисовку фактического маршрута до завершения начальной синхронизации авторизации.

Подходящим является способ, при котором в верхней области веба имеется состояние isSynced, и до завершения синхронизации отображается только белый фон или область загрузки. В этом состоянии не отрисовывается ни одна из экранов: страница входа, страница выбора роли или главная страница. Экран определяется после получения информации для авторизации от нативного или ответа о том, что информации для авторизации нет.

Однако нужно готовиться и к ситуации, когда ответ от моста не приходит. Если ответ теряется из-за ошибок нативного, ошибок разборки сообщений или различий в среде WebView, приложение может бесконечно оставаться на пустом экране. Для предотвращения этого можно установить таймер обратного отсчета, который принудительно переключит состояние через определенное время.

Нормальный путь — это ответ от моста. Аномальный путь — это таймаут. В проекте инициализации безопасно предусмотреть оба этих пути.

c. Интеграция сплэш-экрана

В приложениях React Native/Expo можно настроить сплэш-экран. Но более важным в приложении WebView является, когда закрыть сплэш.

Если закрыть сплэш сразу в onLoadEnd WebView, это может показаться, что экран появляется быстрее. Но завершение загрузки HTML не означает, что инициализация React приложения, синхронизация информации для авторизации и определение маршрутизации закончены. Если на этом этапе закрыть сплэш, пользователь может увидеть еще не готовый белый экран или мигание экрана входа.

Поэтому момент закрытия сплэша следует связать с фактическим состоянием готовности веба. После завершения синхронизации авторизации и определения начального экрана веб-приложение отправляет сообщение WEB_APP_READY нативному, и нативный закрывает сплэш в момент получения этого сообщения.

Поток выглядит следующим образом.

  1. Запуск приложения

  2. Статический сплэш Expo

  3. Загрузка WebView в React Native shell

  4. Регистрация слушателя бриджа веба

  5. Запрос веба на учетные данные

  6. Ответ нативного приложения на учетные данные

  7. Определение начального экрана вебом

  8. Отправка WEB_APP_READY вебом

  9. Завершение сплэша нативным приложением

Здесь также нужен резервный вариант. Если в вебе возникнет ошибка или не поступит сообщение WEB_APP_READY, сплэш может остаться. Поэтому безопасно использовать таймер, который будет принудительно закрывать сплэш через определенное время после загрузки WebView, и отключать таймер при получении нормального сигнала.

d. Синхронизация хранилища

В приложении WebView хранилище разделяется на несколько уровней. Веб может использовать sessionStorage, localStorage, cookie, IndexedDB, Cache Storage и т. д., а нативное приложение может использовать отдельное хранилище, такое как SecureStore. При работе с учетными данными это хранилище должно быть спроектировано так, чтобы не конфликтовать друг с другом.

При первоначальном входе дочитываются token и role из SecureStore нативного приложения и передаются в веб. Веб отражает это в хранилище и состоянии, которые могут быть использованы в существующем потоке аутентификации. В свою очередь, если role изменяется в вебе, необходимо также уведомить нативное приложение о выбранной role, чтобы можно было восстановить последнее выбранное состояние при повторном запуске приложения.

Выход из системы требует особого внимания. Если просто удалить token в веб-хранилище, в SecureStore нативного приложения могут остаться учетные данные. В этом случае при повторном запуске приложения нативное приложение снова передаст token вебу, что может привести к тому, что состояние выхода из системы не будет восстановлено.

Поэтому при выходе из системы необходимо спроектировать поток, который очистит хранилища и веба, и нативного приложения. В вебе можно очистить sessionStorage, localStorage, cookie, Cache Storage, IndexedDB и т. д., а для нативного приложения можно отправить сообщение CLEAR_USER_CONTEXT для удаления SecureStore.

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

5. Условия для мобильной среды

a. Кнопка «Назад» для Android

Функция аппаратной кнопки «Назад» на Android в приложении WebView - это то, что обязательно следует учитывать. В веб-браузере кнопка «Назад» естественным образом связана с историей. Однако в приложении React Native событие нажатия кнопки «Назад» сначала передается нативному слою.

Нативное приложение не знает автоматически о состоянии React Router внутри WebView. Если пользователь нажимает кнопку «Назад», чтобы вернуться к списку с детального экрана, и приложение сразу закрывается, это создает очень неловкий опыт.

Чтобы избежать этого, необходима структура, отслеживающая canGoBack в состоянии навигации WebView. Когда происходит событие аппаратной кнопки «Назад» и canGoBack равно true, вызывается webView.goBack(), чтобы предотвратить закрытие приложения. В противном случае, если в истории WebView больше нет записей, разрешается стандартное поведение Android.

Эта обработка проста, но оказывает большое влияние на качество приложения. Пользователь ожидает, что, независимо от того, основано ли приложение на WebView или нет, нажатие кнопки «Назад» приведет к возврату на предыдущий экран. Важно удовлетворить это ожидание даже в гибридной структуре.

b. Состояние сети

В мобильной среде состояние сети часто меняется. В метро, лифте, во время смены Wi-Fi на ходу или в переходных зонах связи запросы могут проваливаться или задерживаться. В вебе обычно используются navigator.onLine или события online, offline браузера, но для приложений WebView этого может быть недостаточно.

В React Native состояние сетевого подключения устройства можно проверить более напрямую с помощью NetInfo. Передав это через мост для веба, веб-приложение может использовать состояние сети в соответствии с нативными критериями.

Например, когда нативная часть отправляет сообщение NETWORK_STATUS_CHANGED, веб может связать это значение с onlineManager в React Query. Когда соединение прервано, запрос или мутация не должны произвольно проваливаться, а когда соединение восстанавливается, можно продолжить прерванную мутацию или сделать запрос, чтобы снова получить актуальные данные.

Этот метод представляет собой более надежную структуру, чем просто отображение экрана оффлайн-уведомления. Приложение может вернуться к нормальному потоку, даже если пользователь не обновит страницу после восстановления сети.

Однако полностью исключать события online/offline браузера не следует. Рекомендуется использовать их как резервный вариант в средах, где мост нативной части не работает, или в средах с автономным выполнением для веба.

c. Операционные дополнения

Мост WebView удобен, но в рабочих условиях необходимо также учитывать аспекты безопасности. В процессе разработки может быть целесообразно широко разрешить origin для быстрого тестирования или использовать postMessage('*') в тестах iframe. Однако в рабочей среде лучше четко ограничить origin для обмена сообщениями.

Более того, сообщения моста являются строками JSON, поэтому они могут не удастся при разборе. Типы TypeScript повышают безопасность на этапе разработки, но нет гарантии, что значения, поступающие во время выполнения, действительно соответствуют этому типу. Поэтому безопаснее добавлять проверку схемы во время выполнения для важных действий.

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

  • Проверяем, что сообщение пришло с разрешённого источника.

  • Проверяем, определено ли действие в списке.

  • Проверяем, соответствует ли структура полезной нагрузки ожидаемой.

  • Не просто игнорируем ошибки разбора, а ведём их учёт или отслеживаем.

  • Передаем конфиденциальную информацию только в необходимом объёме.

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

Заключение

Для разработчиков React важно, что при первом опыте с гибридным приложением на базе React Native WebView не так важно, сколько нативных экранов будет реализовано. Более важно определить, какие ответственности будут делиться между вебом и нативным, как они будут общаться и в какой момент будет согласовано начальное состояние.

Структура оболочки WebView является практическим способом предоставления услуг в форме мобильного приложения, сохраняя преимущества существующих веб-приложений на React. Однако основное в этой структуре состоит не в том, чтобы вставить веб-адрес в WebView, а в том, чтобы спроектировать границы между вебом и нативным.

На этапе начального проектирования структуры желательно сначала определить следующие пункты.

  • Разделение обязанностей между вебом и нативным

  • Контракт действия моста и тип полезной нагрузки

  • Поток рукопожатия учетных данных

  • Ожидание рендеринга начального экрана и резервирования

  • Момент завершения сплэш-экрана

  • Синхронизация SecureStore с веб-хранилищем

  • Обработка нажатия кнопки «назад» на Android

  • Передача состояния сети

  • Ограничение по происхождению и проверка времени выполнения в рабочей среде

Если эта основа уже заложена, то при дальнейшем расширении функций структура не будет сильно колебаться. Для разработчиков React, впервые работающих с React Native, гибридные приложения на основе WebView могут быть незнакомой областью, но разделяя роли веба и приложения и управляя мостом через четкие контракты, можно создать структуру, к которой можно обратиться на основе имеющегося веб-опыта.

Hazel

Site footer