Интеграция SwiftUI и Realm в React Native на новой архитектуре

от автора

Всем привет! В конце весны 2024 новая архитектура React Native вышла в бета‑версию. Хотя команда React Native пока не рекомендует использовать её в продакшн‑приложениях, многие библиотеки уже адаптированы для работы с ней или находятся на пути к полноценной интеграции. React Native всегда предоставлял возможность интеграции с нативным кодом, а новая архитектура делает этот процесс ещё более эффективным и гибким.

В этой статье я хочу поделиться своим опытом интеграции SwiftUI компонента с использованием Fabric и базы данных Realm с помощью Turbo Modules. Всё это я реализовал на примере iOS‑приложения, которое показывает список популярных фильмов, позволяет добавлять их в избранное, просматривать список избранного и удалять из него.

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

Термины

  • Fabric — новая система рендеринга в React Native, подробнее можно прочитать здесь.

  • Turbo Module — это следующая эволюция нативных модулей в React Native, которая предоставляет дополнительные преимущества. В частности, Turbo Modules используют JSI (JavaScript Interface), интерфейс для нативного кода, который обеспечивает более эффективное взаимодействие между нативным и JavaScript кодом по сравнению мостом (Bridge).

  • Codegen — это инструмент, используемый в новой архитектуре React Native для автоматической генерации кода на основе определённых с помощью TypeScript / Flow интерфейсов.

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

Функционал приложения

Демо: https://vimeo.com/1013777959?share=copy

  1. Главный экран — мы загружаем фильмы и показываем их пользователю в виде бесконечно скролящегося списка. Запрос выполняется на стороне JS, мы передаём статус загрузки и полученные данные в компонент MovieListView, который реализован на SwiftUI.

    При нажатии на фильм мы можем перейти на экран с более подробной информацией о фильме, который полностью реализован нативно, но данные для этого мы всё равно запрашиваем на стороне JS, а затем передаём в тот же компонент. Также на главном экране мы используем функционал нативного модуля favourite-movies-storage, который отвечает за запись и чтение в базу данных Realm. Вся коммуникация также происходит через JS слой.

  2. Экран списка избранных фильмов — это самый обычный Flatlist, но данные для него мы берем из базы данных Realm, используя всё тот же модуль favourite-movies-storage.

Есть несколько способов создать нативный модуль на новой архитектуре. Мы можем пойти и вручную написать конфигурацию для Codegen, как описано здесь для Fabric компонента, или здесь для Turbo Module.

Также мы можем использовать инструмент react-native-builder-bob, который создаст нам примитивный компонент или турбомодуль, который мы потом можем использовать как отправную точку для реализации своего функционала.

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

npx create-react-native-library@latest favourite-movies-storage

После этого нужно ввести данные о конфигурации нашей библиотеки. Я создавал нативные модули без обратной совместимости со старой архитектурой.

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

В итоге я получил следующую структуру:

Структура файлов нативного модуля

Структура файлов нативного модуля

Папку Android мы игнорируем.

Основные файлы конфигурации

  • package.json — представляет собой описание нашей библиотеки. Здесь стоит обратить внимание на поле codegenConfig:

"codegenConfig": {     "name": "RNMovieList",     "type": "all",     "jsSrcsDir": "src" }

Здесь мы описываем название нашего модуля, папку, где лежит наш JS код, и тип модуля.
Если мы хотим иметь Fabric компонент и Turbo Module в одном месте, то он должен быть all.

  • react-native-movie-list.podspec — конфигурация CocoaPods модуля. Здесь есть несколько ключевых моментов:

    • Файлы исходного кода:

      s.source_files = "ios/**/*.{h,m,mm,swift}"

      По умолчанию там нет Swift, поэтому это нужно добавить вручную.

    • Здесь же мы добавляем зависимости для нашего модуля. Помимо стандартных, я добавил Realm и SwiftUIIntrospect:

      s.dependency "SwiftUIIntrospect" s.dependency "RealmSwift", '~> 10'

Интеграция нативных компонентов

Fabric компонент списка фильмов

Сначала идём в index.tsx. Здесь нас интересуют две строки, которые экспортируют наш компонент и связанные с ним типы за пределы модуля:

export {default as MovieListView} from './MovieListViewNativeComponent'; export * from './MovieListViewNativeComponent';

Конфигурация нашего компонента происходит в файле MovieListViewNativeComponent.ts.

import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent'; import type {ViewProps} from 'react-native'; import {   Double,   WithDefault,   DirectEventHandler,   Int32, } from 'react-native/Libraries/Types/CodegenTypes';  type Movie = {   readonly id: Int32;   readonly title: string;   readonly url: string;   readonly movieDescription: string;   readonly rating: Double; };  type Genre = {   id: Int32;   name: string; };  type MovieDetails = {   readonly id: Int32;   readonly title: string;   readonly posterURL: string;   readonly overview: string;   readonly genres: Genre[];   readonly rating: Double;   readonly isFavourite: boolean; };  export type OnMoviePressEventData = {   readonly movieID: Int32; };  export type OnMovieAddedToFavorites = OnMoviePressEventData;  export type OnMovieRemovedFromFavorites = OnMovieAddedToFavorites;  export type OnMovieInteractionCallback =   DirectEventHandler<OnMoviePressEventData>;  type NetworkStatus = WithDefault<'loading' | 'success' | 'error', 'loading'>;  interface NativeProps extends ViewProps {   readonly movies: Movie[];   readonly onMoviePress: DirectEventHandler<OnMoviePressEventData>;   readonly onMovieAddedToFavorites: DirectEventHandler<OnMovieAddedToFavorites>;   readonly onMovieRemovedFromFavorites: DirectEventHandler<OnMovieRemovedFromFavorites>;   readonly movieListStatus?: NetworkStatus;   readonly movieDetailsStatus?: NetworkStatus;   readonly movieDetails?: MovieDetails;   readonly onMoreMoviesRequested: DirectEventHandler<null>; }  export default codegenNativeComponent<NativeProps>('MovieListView');

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

На этом конфигурация нашего модуля на стороне JS завершена, всё оказалось достаточно просто. Теперь перейдём к нативной части.

Структура файлов нативной реализации компонента

Структура файлов нативной реализации компонента

Нас интересуют следующие файлы: MovieListView.h, MovieListView.mm, MovieListViewManager.mm.

  • MovieListView.h — заголовочный файл, определяющий интерфейс для нашего компонента. Мы могли бы добавить здесь методы, которые можем вызвать на вью, но в нашем случае он пуст. Помимо этого здесь мы импортируем файл заголовка react_native_movie_list-Swift.h, который содержит интерфейсы для Swift кода, доступного нам в Objective-C.

// This guard prevents this file from being compiled in the old architecture. #ifdef RCT_NEW_ARCH_ENABLED #import <React/RCTViewComponentView.h> #import <UIKit/UIKit.h> #import "react_native_movie_list-Swift.h"  #ifndef MovieListViewNativeComponent_h #define MovieListViewNativeComponent_h  NS_ASSUME_NONNULL_BEGIN  @interface MovieListView : RCTViewComponentView @end  NS_ASSUME_NONNULL_END  #endif /* MovieListViewNativeComponent_h */ #endif /* RCT_NEW_ARCH_ENABLED */ 
  • MovieListViewManager.mm — это менеджер нашего компонента, React Native использует его во время выполнения, чтобы зарегистрировать модуль, доступный в JS. Самым важным здесь является вызов метода RCT_EXPORT_MODULE, который и регистрирует наш модуль.

#import <React/RCTViewManager.h> #import <React/RCTUIManager.h> #import "RCTBridge.h"  @interface MovieListViewManager : RCTViewManager @end  @implementation MovieListViewManager  RCT_EXPORT_MODULE(MovieListView)  @end 
  • MovieListView.mm — файл реализации нашего компонента, здесь происходит основная работа по созданию компонента. Сам файл достаточно объёмный и содержит много вспомогательного кода, поэтому я затрону лишь основные методы, которые отвечают за интеграцию.

#import "MovieListView.h" #import <react/renderer/components/RNMovieList/ComponentDescriptors.h> #import <react/renderer/components/RNMovieList/EventEmitters.h> #import <react/renderer/components/RNMovieList/Props.h> #import <react/renderer/components/RNMovieList/RCTComponentViewHelpers.h> #import "RCTFabricComponentsPlugins.h" #import "React/RCTConversions.h"  using namespace facebook::react;  @interface MovieListView () <RCTMovieListViewViewProtocol> @end  @implementation MovieListView {     MovieListViewController *_movieListViewController;     UIView *_view; }  + (ComponentDescriptorProvider)componentDescriptorProvider {     return concreteComponentDescriptorProvider<MovieListViewComponentDescriptor>(); }  - (instancetype)initWithFrame:(CGRect)frame {     self = [super initWithFrame:frame];     if (self) {         static const auto defaultProps = std::make_shared<const MovieListViewProps>();         _props = defaultProps;          _movieListViewController = [MovieListViewController createViewController];     }     return self; }  - (void)didMoveToWindow {     [super didMoveToWindow];     if (self.window) {         [self setupView];     } }  - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &)oldProps {     const auto &oldViewProps = *std::static_pointer_cast<MovieListViewProps const>(_props);     const auto &newViewProps = *std::static_pointer_cast<MovieListViewProps const>(props);      [self updateMovieListAndStatusIfNeeded:oldViewProps newProps:newViewProps];     [self updateMovieDetailsStatusAndMovieDetilsIfNeeded:oldViewProps newProps:newViewProps];     [self setupEventHandlers];      [super updateProps:props oldProps:oldProps]; }  Class<RCTComponentViewProtocol> MovieListViewCls(void) {     return MovieListView.class; }

Сначала отметим, что компонент должен реализовывать протокол RCTMovieListViewViewProtocol, который был сгенерирован с помощью Codegen.

(ComponentDescriptorProvider)componentDescriptorProvider — метод, который используется Fabric для получения дескриптора, необходимого для создания экземпляра нашего компонента.

Также стоит обратить внимание на объявление переменной экземпляра:

@implementation MovieListView {     MovieListViewController *_movieListViewController; }

Этот контроллер отвечает за создание, взаимодействие и обмен данными с нашим SwiftUI вью.

initWithFrame — метод, объявленный в интерфейсе UIView, который является базовым классом для всех вью‑компонентов в UIKit. Он инициализирует новый экземпляр UIView с указанным размером и положением (передаваемым в параметре CGRect frame. В нашем случае, в методе initWithFrame происходит не только инициализация вью с заданными размерами, но и создание MovieListViewController, который управляет SwiftUI компонентом. Помимо этого, здесь мы создаем и устанавливаем пропсы по умолчанию.

didMoveToWindow — это метод жизненного цикла UIView. Он вызывается, когда наше вью добавляется в иерархию, прикреплённую к окну, когда удаляется из него и когда перемещается в другое окно. Когда вью удаляется, то self.window будет равен nil. Также данный метод вызывает setupView, который в свою очередь устанавливает constraints для вью, содержащего наш SwiftUI компонент. Также нам важно добавить *_movieListViewController* в иерархию вью контроллеров, так как из нашего SwiftUI компонента мы можем перейти на новый экран в виде модального окна (Sheet), в котором можно увидеть больше деталей о выбранном фильме.

updateProps — это метод, который вызывается Fabric каждый раз, когда в JavaScript изменяется любой из пропсов. Этот метод обеспечивает синхронизацию состояния между JavaScript и нативным кодом, передавая обновленные значения свойств в нативную часть компонента. Здесь переданные параметры приводятся к нужному типу, соответствующему ожидаемым пропсам компонента (в нашем случае это MovieListViewProps). Затем эти параметры используются для обновления нативного компонента при необходимости. Важно отметить, что метод суперкласса [super updateProps] должен быть вызван в самом конце метода updateProps. Если этот вызов сделать раньше или не сделать вовсе, структуры props и oldProps будут содержать одни и те же значения, что лишит возможности сравнить старые и новые значения свойств. Помимо этого здесь вызывается метод setupEventHandlers, который отвечает за создание коллбеков, которые позже передаются в SwiftUI компонент.

Разберём один из них:

- (void)onMovieAddedToFavorites:(NSInteger)movieId {     if (_eventEmitter != nullptr) {         auto emitter = std::dynamic_pointer_cast<const facebook::react::MovieListViewEventEmitter>(_eventEmitter);         if (emitter) {             emitter->onMovieAddedToFavorites(facebook::react::MovieListViewEventEmitter::OnMovieAddedToFavorites{static_cast<int>(movieId)});         }     } }

Здесь происходит отправка события в JS, когда фильм добавляется в избранное. Сначала проверяется, существует ли объект _eventEmitter, который отвечает за отправку событий. Затем выполняется приведение _eventEmitter к типу MovieListViewEventEmitter, который был сгенерирован Codegen на основе типов, о которых мы говорили в начале секции. Если приведение успешно, создается событие с идентификатором добавленного фильма, которое отправляется в React Native через вызов метода onMovieAddedToFavorites. Если всё прошло успешно, то будет вызван нужный коллбек на стороне JS.

MovieListViewCls — это статический метод, используемый для получения правильного экземпляра класса MovieListView во время выполнения, что позволяет React Native корректно идентифицировать и рендерить этот нативный компонент.

Это были основные моменты, касающиеся интеграции Fabric компонента. Реализацию самого нативного компонента мы рассматривать не будем.

Модуль работы с базой данных Realm

Далее рассмотрим основные моменты интеграции Turbo Module на примере нашего модуля работы с базой данных.

Здесь всё также начинается с описания нашего модуля на TypeScript, чтобы Codegen смог сгенерировать нативные интерфейсы для нас.

import type {TurboModule} from 'react-native'; import {TurboModuleRegistry} from 'react-native';  export interface FavouriteMovie {   id: number;   url: string;   title: string;   rating: string; }  export interface Spec extends TurboModule {   getFavouriteMovies(): FavouriteMovie[];   addFavouriteMovie(movie: FavouriteMovie): Promise<FavouriteMovie[]>;   removeFavouriteMovie(movieId: number): Promise<FavouriteMovie[]>;   removeAllFavouriteMovies(): Promise<void>; }  export default TurboModuleRegistry.getEnforcing<Spec>('FavouriteMoviesStorage'); 

Сначала мы должны создать интерфейс для нашего модуля, который должен наследоваться от интерфейса TurboModule и называться Spec. Здесь мы описываем 4 метода, которые мы хотим реализовать. Примечательно, что метод getFavouriteMovies является синхронным. Это было возможно и в старой архитектуре, но имело свои недостатки и было не рекомендовано для использования.

В конце мы вызываем

TurboModuleRegistry.getEnforcing<Spec>('FavouriteMoviesStorage')

Мы делаем это, для того чтобы получить нативный модуль FavouriteMoviesStorage, если он доступен. И на этом спецификация модуля окончена.

Далее разберёмся с тем, что у нас происходит в нативной части.

Структура файлов нативной реализации модуля БД

Структура файлов нативной реализации модуля БД

Здесь нас интересуют два ключевых для интеграции файла: FavouriteMoviesStorage.h и FavouriteMoviesStorage.mm.

  • FavouriteMoviesStorage.h — здесь мы объявляем интерфейс, который наследуется от NSObject и реализует протокол NativeFavouriteMoviesStorageSpec, сгенерированный для нас с помощью Codegen. Если новая архитектура не включена, то данный код скомпилирован не будет.

#ifdef RCT_NEW_ARCH_ENABLED #import "RNMovieList/RNMovieList.h" #import "react_native_movie_list-Swift.h"  @interface FavouriteMoviesStorage : NSObject <NativeFavouriteMoviesStorageSpec>  @end #endif 
  • FavouriteMoviesStorage.mm — именно в этом файле происходит реализация всей логики нашего модуля базы данных.

#import "FavouriteMoviesStorage.h" #import "react_native_movie_list-Swift.h"  @implementation FavouriteMoviesStorage RCT_EXPORT_MODULE()  - (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:(const facebook::react::ObjCTurboModule::InitParams &)params {     return std::make_shared<facebook::react::NativeFavouriteMoviesStorageSpecJSI>(params); } ...... @end

Самым важным здесь является вызов RCT_EXPORT_MODULE(), который делает наш модуль доступным на стороне JS.

Метод getTurboModule получает экземпляр нашего Turbo Module, чтобы его методы могли вызываться со стороны JS. Этот метод определён и обязателен в файле FavouriteMoviesStorageSpec.h, который был сгенерирован ранее с помощью Codegen.

Далее рассмотрим примеры реализации методов работы с базой данных.

- (NSArray<NSDictionary *> *)getFavouriteMovies {     NSArray *movies = [[FavouriteMoviesManager shared] fetchAllFavouriteMoviesAsList];     NSMutableArray *result = [NSMutableArray array];     for (IntermediateFavouriteMovie *movie in movies) {         [result addObject:[self dictionaryFromFavouriteMovie:movie]];     }     return result; }

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

Теперь рассмотрим метод для удаления всех фильмов из избранного — removeAllFavouriteMovies.

- (void)removeAllFavouriteMovies:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {     [[FavouriteMoviesManager shared] removeAllFavouriteMoviesOnSuccess:^{         resolve(@YES);     } onError:^(NSError * _Nonnull error) {         reject(@"remove_all_favourite_movies_error", error.localizedDescription, error);     }]; }

Этот метод принимает два параметра: resolve и reject, так как в спецификации мы указали, что данный метод возвращает Promise. Когда вызывается removeAllFavouriteMovies, он передает два блока — onSuccess и onError — в метод removeAllFavouriteMoviesOnSuccess класса FavouriteMoviesManager. Если операция удаления всех избранных фильмов проходит успешно, вызывается блок onSuccess, который активирует resolve с параметром @YES, завершая промис успешно. Если же происходит ошибка, вызывается блок onError, который активирует reject с описанием ошибки, что резолвит промис с ошибкой.

Остальные методы работают по схожему принципу, поэтому рассматривать их подробно смысла нет, так как ничего нового мы там не увидим.

Это основные моменты, касающиеся интеграции нативного модуля базы данных с использованием Turbo Modules в новой архитектуре React Native. Как я упоминал ранее, мы не будем углубляться в детали нативной реализации, поскольку основная цель статьи — показать процесс интеграции.

Итоги

Использование новой архитектуры для реализации нативных модулей, как показано на примере этого приложения, — это вполне выполнимая задача. Конечно, потребуется некоторое время, чтобы привыкнуть к синтаксису C++, разобраться в нюансах сборки и особенностях работы, особенно если у вас не было опыта с этим языком. Однако эти усилия оправданы. Новая архитектура предлагает множество преимуществ, особенно при передаче больших объёмов данных между JavaScript и нативным кодом. С турбомодулями мы можем использовать синхронные методы, например, для доступа к данным. Кроме того, новая архитектура позволяет эффективно применять нативные UI‑компоненты. Например, в моём случае список фильмов на SwiftUI работал гораздо лучше «из коробки», чем FlatList, встроенный в RN. Даже при том, что моя реализация далека от оптимальной, так как происходит достаточно много копирования и создания новых объектов, для того, чтобы конвертировать данные для работы со SwiftUI. Мы могли бы использовать UIKit и UITableView, что могло бы решить некоторые из проблем. Но это выходит за рамки данной статьи

Это всё, чем я хотел поделиться. Надеюсь, данная статья была вам полезна. Спасибо за внимание!

Ссылка на репозиторий: https://github.com/tikhonNikita/movieApp


ссылка на оригинал статьи https://habr.com/ru/articles/847768/