Интеграция 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
Главный экран — мы загружаем фильмы и показываем их пользователю в виде бесконечно скролящегося списка. Запрос выполняется на стороне JS, мы передаём статус загрузки и полученные данные в компонент MovieListView, который реализован на SwiftUI.
При нажатии на фильм мы можем перейти на экран с более подробной информацией о фильме, который полностью реализован нативно, но данные для этого мы всё равно запрашиваем на стороне JS, а затем передаём в тот же компонент. Также на главном экране мы используем функционал нативного модуля favourite-movies-storage, который отвечает за запись и чтение в базу данных Realm. Вся коммуникация также происходит через JS слой.
Экран списка избранных фильмов — это самый обычный Flatlist, но данные для него мы берем из базы данных Realm, используя всё тот же модуль favourite-movies-storage.
Есть несколько способов создать нативный модуль на новой архитектуре. Мы можем пойти и вручную написать конфигурацию для Codegen, как описано здесь для Fabric компонента, или здесь для Turbo Module.
Также мы можем использовать инструмент react-native-builder-bob, который создаст нам примитивный компонент или турбомодуль, который мы потом можем использовать как отправную точку для реализации своего функционала.
Я использовал последний подход. С помощью Bob мы можем создать как локальный модуль, так и библиотеку. В моем случае это был локальный модуль. Для этого в корне проекта я выполнил следующую команду:
npx create-react-native-library@latest favourite-movies-storage
После этого нужно ввести данные о конфигурации нашей библиотеки. Я создавал нативные модули без обратной совместимости со старой архитектурой.
Данную команду нужно выполнить дважды: первый раз для компонента, второй для турбомодуля. Но я хотел иметь весь нативный функционал, касающийся списка фильмов, в одном модуле, поэтому после создания я произвёл ряд манипуляций, чтобы объединить их в один модуль.
В итоге я получил следующую структуру:
Структура файлов нативного модуля
Папку Android мы игнорируем.
Основные файлы конфигурации
"codegenConfig": {
"name": "RNMovieList",
"type": "all",
"jsSrcsDir": "src"
}
Здесь мы описываем название нашего модуля, папку, где лежит наш JS код, и тип модуля.
Если мы хотим иметь Fabric компонент и Turbo Module в одном месте, то он должен быть all.
Интеграция нативных компонентов
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;
type NetworkStatus = WithDefault<'loading' | 'success' | 'error', 'loading'>;
interface NativeProps extends ViewProps {
readonly movies: Movie[];
readonly onMoviePress: DirectEventHandler;
readonly onMovieAddedToFavorites: DirectEventHandler;
readonly onMovieRemovedFromFavorites: DirectEventHandler;
readonly movieListStatus?: NetworkStatus;
readonly movieDetailsStatus?: NetworkStatus;
readonly movieDetails?: MovieDetails;
readonly onMoreMoviesRequested: DirectEventHandler;
}
export default codegenNativeComponent('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
#import
#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
#import
#import "RCTBridge.h"
@interface MovieListViewManager : RCTViewManager
@end
@implementation MovieListViewManager
RCT_EXPORT_MODULE(MovieListView)
@end
MovieListView.mm
— файл реализации нашего компонента, здесь происходит основная работа по созданию компонента. Сам файл достаточно объёмный и содержит много вспомогательного кода, поэтому я затрону лишь основные методы, которые отвечают за интеграцию.
#import "MovieListView.h"
#import
#import
#import
#import
#import "RCTFabricComponentsPlugins.h"
#import "React/RCTConversions.h"
using namespace facebook::react;
@interface MovieListView ()
@end
@implementation MovieListView {
MovieListViewController *_movieListViewController;
UIView *_view;
}
+ (ComponentDescriptorProvider)componentDescriptorProvider {
return concreteComponentDescriptorProvider();
}
- (instancetype)initWithFrame:(CGRect)frame {
self = [super initWithFrame:frame];
if (self) {
static const auto defaultProps = std::make_shared();
_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(_props);
const auto &newViewProps = *std::static_pointer_cast(props);
[self updateMovieListAndStatusIfNeeded:oldViewProps newProps:newViewProps];
[self updateMovieDetailsStatusAndMovieDetilsIfNeeded:oldViewProps newProps:newViewProps];
[self setupEventHandlers];
[super updateProps:props oldProps:oldProps];
}
Class 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(_eventEmitter);
if (emitter) {
emitter->onMovieAddedToFavorites(facebook::react::MovieListViewEventEmitter::OnMovieAddedToFavorites{static_cast(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;
removeFavouriteMovie(movieId: number): Promise;
removeAllFavouriteMovies(): Promise;
}
export default TurboModuleRegistry.getEnforcing('FavouriteMoviesStorage');
Сначала мы должны создать интерфейс для нашего модуля, который должен наследоваться от интерфейса TurboModule и называться Spec. Здесь мы описываем 4 метода, которые мы хотим реализовать. Примечательно, что метод getFavouriteMovies
является синхронным. Это было возможно и в старой архитектуре, но имело свои недостатки и было не рекомендовано для использования.
В конце мы вызываем
TurboModuleRegistry.getEnforcing
Мы делаем это, для того чтобы получить нативный модуль 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
@end
#endif
#import "FavouriteMoviesStorage.h"
#import "react_native_movie_list-Swift.h"
@implementation FavouriteMoviesStorage
RCT_EXPORT_MODULE()
- (std::shared_ptr)getTurboModule:(const facebook::react::ObjCTurboModule::InitParams &)params {
return std::make_shared(params);
}
......
@end
Самым важным здесь является вызов RCT_EXPORT_MODULE()
, который делает наш модуль доступным на стороне JS.
Метод getTurboModule
получает экземпляр нашего Turbo Module, чтобы его методы могли вызываться со стороны JS. Этот метод определён и обязателен в файле FavouriteMoviesStorageSpec.h, который был сгенерирован ранее с помощью Codegen.
Далее рассмотрим примеры реализации методов работы с базой данных.
- (NSArray *)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