Всем привет, меня зовут Эдвард, и я Middle Front-end разработчик в команде Stellar 2H Group. Недавно я начал изучение разработки нативных view / модулей под React Native и хотел бы поделиться этим опытом, потому что мне пришлось столкнуться с некоторыми трудностями, о которых я позже поведаю.

В данной статье я буду использовать Webstorm и XCode. Если статья найдёт свой отклик, то попробуем реализовать то же самое, но под android. Приятного чтения!

Небольшой экскурс для тех, кто не в теме

React Native — это кроссплатформенный фреймворк с открытым исходным кодом для разработки нативных мобильных и настольных приложений на JavaScript и TypeScript, созданный Facebook Inc. (ныне Meta*)

Нативные модули для этой технологии пишутся на нативных языках хост-платформ (ios/android/windows/mac os). Например Objective C, Swift, Kotlin, C++.

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

А что насчёт архитектуры?

На данный момент в RN реализовали новую архитектуру, которая называется Fabric, но её мы затрагивать не будем, поскольку в официальной документации сказано, что она экспериментальная и находится в активной разработке. источник

Создаём проект

Здесь всё просто. Запускаем вот эту команду, выбираем пункт native view, далее Kotlin & Swift и ждём, пока создастся темплейт проекта:

npx create-react-native-library@latest react-native-awesome-mapkit

Преднастройка проекта

  1. Устанавливаем зависимости (yarn / npm i / npm install)

  2. Добавляем зависимость в %название-вашей-либы%.podspec (4.3.1 - последняя версия на момент написания статьи)

    s.dependency "YandexMapsMobile", "4.3.1-full"

  3. cd example

  4. npx pod-install

Готово! Мы можем писать нашу библиотеку

Шаг 0: Открываем проект

Открываем example/ios/AwesomeMapkitExample.xcworkspace в XCode

Шаг 1: Устанавливаем ключ Yandex Mapkit и язык карты

В example/ios/AwesomeMapkitExample/AppDelegate.mm прописываем следующие строчки:

#import <YandexMapsMobile/YMKMapKitFactory.h>

...

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
  self.moduleName = @"AwesomeMapkitExample";
  // You can add your custom initial props in the dictionary below.
  // They will be passed down to the ViewController used by React Native.
  self.initialProps = @{};

  [YMKMapKit setApiKey:@"Ваш API ключ"];
  // необязательное действие. По дефолту язык системы
  [YMKMapKit setLocale:@"ru_RU"];
  [[YMKMapKit mapKit] onStart];

  return [super application:application didFinishLaunchingWithOptions:launchOptions];
}

Отлично. Теперь при запуске приложения у нас будет проставляться API ключ Yandex карт. Замечу, что хардкод ключа и языка карты это временная мера. В следующих статьях мы сделаем возможность проставлять этот ключ и без доступа в натив (тот же самый expo-dev-client)

Шаг 2: Создаём нативный view карт

в корне находим папку ios и создаём папку MapView, а затем два файлика внутри: MapView.m и MapView.swift target выставляем Pods-AwesomeMapkitExample

При создании .swift файла XCode предложит создать bridging header. Не делаем этого, он уже есть, так как мы выбрали тип проекта Swift + Kotlin

Сначала напишем Swift часть нашего MapView:

import Foundation
import React
import YandexMapsMobile

/*
  объявляем структуру InitialCoords, которая реализует протокол Decodable
  протокол Decodable поможет нам преобразовать тип NSDictionary
  (наш JS объект) в Swift-структуру.
*/
struct InitialCoords: Decodable {
  var lat: Double;
  var lon: Double;
  
  var zoom: Float;
  var azimuth: Float;
  var tilt: Float;
}

// функция-проверка, запускается код в симуляторе или на реальном устройстве
func isSimulator() -> Bool {
  #if targetEnvironment(simulator)
    return true
  #else
    return false
  #endif
}

// класс нативного view, который потом будет отдан в отрисовку
@objcMembers class MapView: UIView {
    // нативный View Yandex карты
    var ymkMapView: YMKMapView
    
    // функция, которая принимает JS объект, передаваемый в пропс initialRegion
    func setInitialRegion(_ initialRegion: NSDictionary) {
        /*
          Декодируем объект в swift структуру. Если в одном из if-ов
          try словит ошибку, то вернётся пустой объект и if не отработает,
          следовательно, настройки при неверной схеме объекта не применятся
        */
        if let json = try? JSONSerialization.data(withJSONObject: initialRegion, options: []) {
          if let region: InitialCoords = try? JSONDecoder().decode(InitialCoords.self, from: json) {
            // создаём точку, которая будет являться центром камеры
            let cameraPoint = YMKPoint(latitude: region.lat, longitude: region.lon)
            // создаём камеру
            let cameraPosition = YMKCameraPosition(target: cameraPoint, zoom: region.zoom, azimuth: region.azimuth, tilt: region.tilt)
        
            // передвигаем обзор карты на нужную точку без анимации
            ymkMapView.mapWindow.map.move(with: cameraPosition, animationType: YMKAnimation(type: YMKAnimationType.linear, duration: 0), cameraCallback: nil)
          }
        }
    }

    /*
      Инициализация карты яндекса.
      Указываем дефолтный нулевой фрейм для создания объекта,
      потом добавляем в subview и делаем clipsToBounds = true,
      чтобы карта растягивалась на весь родительский view
    */
    override init(frame: CGRect) {
      ymkMapView = YMKMapView(frame: CGRect.zero, vulkanPreferred: isSimulator())
      super.init(frame: frame)
        
      clipsToBounds = true
      addSubview(ymkMapView)
    }
       
    required init?(coder aDecoder: NSCoder) {
      ymkMapView = YMKMapView(frame: CGRect.zero, vulkanPreferred: isSimulator())
      super.init(coder: aDecoder)
        
      clipsToBounds = true
      addSubview(ymkMapView)
    }
}

/*
  Создаём класс менеджера нашего View.
  Менеджер - это класс, который производит первую настройку нативного
  компонента (requiresMainQueueSetup) и отдаёт нативный view для отрисовки.

  Затем этот класс будет использован для прокидывания в РН с помощью макросов
  в Objective-C (RCT_EXTERN_MODULE)
*/
@objc(MapViewManager)
class MapViewManager: RCTViewManager {
    /*
      Этот метод вызывается только при инициализации,
      если ваш метод инициализации вызывает пользовательский интерфейс
      или вы переопределяете сonstantToExport, то ставим true
    */
    override static func requiresMainQueueSetup() -> Bool {
        true
    }
  
    override func view() -> UIView! {
        return MapView()
    }
}

Те люди, которые уже давно разрабатывают нативные модули на Objective C могут спросить, зачем я передаю в Swift NSDictionary, а не преобразовываю его в структуру с помощью RCTConvert внутри Objective C? Ответ простой:

коротко о причине, почему я потратил целых 2 дня впустую
коротко о причине, почему я потратил целых 2 дня впустую

Итак, со Swift-частью мы разобрались, теперь осталось написать Objective C экспорты, подправить JS сторону и пойти проверять данное дело в симуляторе:

// MapView.m
#import <Foundation/Foundation.h>
#import <React/RCTViewManager.h>
#import <React/RCTBridgeModule.h>

// экспортируем наш MapViewManager, реализация которого находится в MapView.swift
@interface RCT_EXTERN_MODULE(MapViewManager, RCTViewManager)

// экспортируем пропс initialRegion
RCT_EXPORT_VIEW_PROPERTY(initialRegion, NSDictionary)

@end

Пишем JS-сторону нашего модуля, экспортируем нативный View

// src/index.tsx
import {
  Platform,
  requireNativeComponent,
  UIManager,
  ViewStyle,
} from 'react-native';

// Пропсы нативного View
type MapViewProps = {
  style?: ViewStyle;
  initialRegion: {
    lat: number;
    lon: number;
    zoom: number;
    azimuth: number;
    tilt: number;
  };
};

const LINKING_ERROR =
  `The package 'react-native-awesome-mapkit' doesn't seem to be linked. Make sure: \n\n` +
  Platform.select({ ios: "- You have run 'pod install'\n", default: '' }) +
  '- You rebuilt the app after installing the package\n' +
  '- You are not using Expo Go\n';

const ComponentName = 'MapView';

/*
  Получаем config нативного view, если он пустой, то выдаём ошибку.
  Это значит, что наш view не экспортнулся по какой-то причине
  Если же конфиг не пустой, значит импортируем нативный view и отдаём его
*/
export const MapView =
  UIManager.getViewManagerConfig(ComponentName) != null
    ? requireNativeComponent<MapViewProps>(ComponentName)
    : () => {
        throw new Error(LINKING_ERROR);
      };
// example/src/App.tsx

import * as React from 'react';

import { MapView } from 'react-native-awesome-mapkit';

// Добавляем нативный view и передаём пропсы, которые мы указали в нативном модуле
export default function App() {
  return (
    <MapView
      style={{ flex: 1 }}
      initialRegion={{
        lat: 55.751574,
        lon: 37.573856,
        zoom: 15,
        azimuth: 0,
        tilt: 0,
      }}
    />
  );
}

Шаг 3: Радуемся жизни

Запускаем проект так:

yarn example start

yarn example ios

Если вы увидели примерно такую картину, то поздравляю, вы всё сделали правильно! Ура!

Ура, победа
Ура, победа

В следующей части я покажу, как контролировать children views, которые передаётся в нативный компонент, а это значит, что мы будем делать маркеры для нашей нативной карты)

Деятельность экстремистской организации (признана такой 21 марта 2022 года) Meta Platforms или Meta* запрещена в России. Компания владеет социальными сетями Facebook** и Instagram.

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


  1. 2Grey
    18.05.2023 17:35
    +2

    Так много вопросов к коду:

    1. enum CodingKeys у InitialCoords не нужен;

    2. Метод `init(from decoder) у InitialCoords тоже не нужен;

    3. Зачем var ymkMapView: YMKMapView!, если можно просто var ymkMapView: YMKMapView (без восклицательного знака). И потом в init до вызова super.init присвоить значение ymkMapView

    4. try! плохая практика, что будет, если придет JSON, который не будет соответствовать структуре?

    5. mapView = MapView(): каждый раз при вызове метода view() будет создаваться новый MapView, можно просто объявить let mapView = MapView()


    1. movpushmov Автор
      18.05.2023 17:35

      Здравствуйте! Спасибо за замечания, учту! В свифте новичок)


      1. V1tol
        18.05.2023 17:35

        Ещё резанула взгляд конвертация в JSON и обратно. NSDictionary это и есть эквивалент жсного объекта, из него можно по ключам надёргать данные и положить в свою структуру. Тогда даже Decodable не понадобится.


    1. movpushmov Автор
      18.05.2023 17:35

      так-с, я скоро немножко исправлю код в статье, но есть некоторые нюансы:

      1, 2) действительно, работает и без этого

      3) Поменял методы init у MapView:

      override init(frame: CGRect) {
        ymkMapView = YMKMapView(frame: CGRect.zero, vulkanPreferred: isSimulator())
        super.init(frame: frame)
          
        clipsToBounds = true
        addSubview(ymkMapView)
      }
         
      required init?(coder aDecoder: NSCoder) {
        ymkMapView = YMKMapView(frame: CGRect.zero, vulkanPreferred: isSimulator())
        super.init(coder: aDecoder)
          
        clipsToBounds = true
        addSubview(ymkMapView)
      }

      4) Исправил на вот такую конструкцию:

      if let json = try? JSONSerialization.data(withJSONObject: initialRegion, options: []) {
        if let region: InitialCoords = try? JSONDecoder().decode(InitialCoords.self, from: json) {
          let cameraPoint = YMKPoint(latitude: region.lat, longitude: region.lon)
          let cameraPosition = YMKCameraPosition(target: cameraPoint, zoom: region.zoom, azimuth: region.azimuth, tilt: region.tilt)
          
          ymkMapView.mapWindow.map.move(with: cameraPosition, animationType: YMKAnimation(type: YMKAnimationType.smooth, duration: 0), cameraCallback: nil)
        }
      }

      Если схема объекта неверная, то настройки просто не применятся.

      5) Убрал переменную mapView из MapViewManager, но оставил создание MapView при каждом вызове view(), потому что иначе приложение будет крашится (в рн есть основной поток (UI) и побочный поток, в котором по дефолту делаются любые действия нативного модуля). Получилось как-то так:

      @objc(MapViewManager)
      class MapViewManager: RCTViewManager {
        override static func requiresMainQueueSetup() -> Bool {
          true
        }
        
        override func view() -> UIView! {
          return MapView()
        }
      }

      Примеры кода для модуля взял тут