Интеграция 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 мы игнорируем.

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

"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, если он доступен. И на этом спецификация модуля окончена.

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

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

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

Здесь нас интересуют два ключевых для интеграции файла: 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

© Habrahabr.ru