React Native

【React Native】Native moduleで悩む全てのひとへ【iOS.Swift編】

React Native
この記事は約18分で読めます。

React nativeを使っていてNative moduleという壁にぶつかったことはありませんでしょうか。
React native(Js)のコードはある程度書けるが、Objective-C, Swift, Java, Kotlinのコードの知見がある程度無いとNative moduleは非常に難しい部分だと思います。


がしかし、

「Native側の勉強よりは、まず仕組みと動くコードを確認したい。」というそこのあなた。”Hiden no tare”授けます。

Native moduleの概要

ざっくり「Native module」とは

まずReact nativeのアーキテクチャの話になってきますが、皆さんが書いているJavaScriptのコードがそのまま端末で表示されているわけではありません。
JsのコードはNative側にBridgeを介して渡されて、そこで処理されます。
もっというのであれば、Native側はJsのコードは読めませんので、それをYoga(FaceBook製のレイアウトエンジン)で処理してから表示されます。
YogaのコードはReact nativeプロジェクトの「node_modules/react-native/ReactCommon/yoga/Yoga.cpp」でレイアウト演算の関数が見れます。


言葉では難しいのでイラストを見てみましょう。

上イラストの左にいるのがJs君です。皆さんが書いているJSコードを擬人化いたしました。
そして真ん中にいるのがNative君です。Objective-CやJavaがお得意のようです。
右にYoga君もいますね。FlexBoxがお得意でレイアウト演算を行える数学が好物な子です。

皆さんが書いたJSコードをNative君に伝えたいですが、お互い分かる言葉が違うので理解できません。頭に?が浮かんじゃっています。
じゃあ解決策として下のイラストを確認してみましょう。

Bridgeを翻訳アプリに擬態化してみました。
Js君とNative君はお互いにBridgeを介してJsonでやりとりしてます。BridgeにJsonを送り合っており、互いには干渉しません(非同期)。
そのため、JsのコードをBridgeに渡しておけば、Native君がそれを読んで、Yoga君に渡して画面に表示できます。

そして今回着目するところは、Native君のコードを書いてBridgeに渡しておくと、Js君はそれを読むことができます。それがNative moduleです。(Native moduleがbridgeから渡されるわけではなく、Js君がNative moduleを見ることでBridgeからのデータを読むことができるという感じ。)

なぜNative moduleを使う必要があるのか

Native moduleを使う必要があるシチュエーションは様々あると思います。
・React nativeを学ぶ上で必要に感じた。
・React nativeで実際に開発していてNativeでないと実装できなさそう
・既にNativeで実装された機能をReact nativeに取り込む
・「Native module」という響きがエモい
…etc
まず端末の機能関係はまずNative絡みが多いです。(カメラや通知等々)
「そんなのNativeコード書かないでJSだけでもライブラリ使えば実装できるやん!?」ってお方。その通りです。
ただライブラリのソースコードは実はNativeコードで書かれているものも多く、実は知らずのうちにNative moduleを呼び出しているかも知れません。そう考えると親近感が湧いてきます。

例として「react-native-camera」と「react-native-reanimated」のソースコードを見るとゴリゴリにObjective-Cファイルがありますね(下記スライドショー参照)。
React nativeライブラリのソースコードで使われているのはObjective-CとJavaが多いですが、中にはSwiftとKotlinで実装されているものもあります。
ビルドに癖がある?が、高機能と巷で有名な「react-native-vision-camera」はSwiftとKotlinで実装されており非常に勉強になるかと思います。

Native module実装(基本編)

実装環境
・macOS 11.6
・Xcode 13.0
・react-native 0.66.1
・Simulator iPhone13 iOS15.0

プロジェクト作成

まずReact nativeのプロジェクトを作っていきます。
前提としてReact native の環境構築は終わっているものとします。

react-native init NativeModuleTest

上記コマンドで「NativeModuleTest」プロジェクトを作成しました。

今回は
・Jsでボタンを押すことでSwiftで実装したAlertを表示
・Swiftで実装したViewを表示して、タップするとAlertを表示
といったものを実装していきます。

Nativeファイルの作成(Objective-C, Swift)

  1. まずXcodeで作成したプロジェクトのiosを開きます。
  2. その後ルートディレクトリで右クリックし「New File」をクリック。(スライドショー1枚目)
  3. Swift Fileを選択。(スライドショー2枚目)
  4. ファイル名を入力。拡張子はいりません(スライドショー3枚目)
  5. 該当のプロジェクトで初めてファイルを作成する場合にはスライドショー4枚目のようなダイアログが表示されます。「bridging headerファイルを作りますか?」と聞かれますので、「Create Bridging Header」をクリック

備考

基本的には上記このような流れでObjective-CとSwiftのファイルを作成します。
なのでJsのコードはいつも開発の時に使うエディタで、Objective-CとSwiftはXcodeからになります。
Objective-CとSwiftを編集した際はReact nativeでJsコードを書いている時と違いホットリロードでは反映されませんので、ビルドし直す必要があります。
Xcodeの左上にあるRunボタン(再生アイコン)からビルドし直すのがいいと思います。

Native module実装(関数編)

まずはSwiftファイルを作成し、呼ばれるとAlertが表示される関数を実装します。

NativeModuleAlert.swift
import Foundation
import UIKit

@objc(NativeModuleAlert)
class NativeModuleAlert: NSObject, RCTBridgeModule{
  
  static func moduleName() -> String!{
    return "NativeModuleAlert";
  }
  
  static func requireMainQueueSetup () -> Bool {
    return true;
  }
  
  
  @objc
  func ShowAlert(_ message:NSString, duration:Double) -> Void {
    let alert = UIAlertController(title:nil, message: message as String, preferredStyle: .alert);
    let seconds:Double = duration;
    alert.view.backgroundColor = .blue
    alert.view.alpha = 0.5
    alert.view.layer.cornerRadius = 14
    
    DispatchQueue.main.async {
      (UIApplication.shared.delegate as? AppDelegate)?.window.rootViewController?.present(alert, animated: true, completion: nil);
    }
    
    DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + seconds, execute: {
      alert.dismiss(animated: true, completion: nil);
    })
  }
}

まずSwiftをReact nativeで動かすには、SwiftファイルをObjective-Cから参照してexportする必要があります。
そのためObjective-Cから参照される部分には「@objc」を付ける必要があります。

static func requireMainQueueSetup
ここの記述は、クラスの初期化をメインスレッドかバックグラウンドスレッドのどちらで行うかを指定するためのものであって、記述しなくても動きますがWarningが出ます。

NativeModuleAlert というModuleNameで ShowAlert という関数を定義しました。

次に上記の NativeModuleAlert.swift をJs側から呼べるようObjective-Cファイルを作成します。


NativeModuleAlert.m
#import <Foundation/Foundation.h>
#import <React/RCTBridgeModule.h>

@interface RCT_EXTERN_MODULE(NativeModuleAlert, NSObject)
RCT_EXTERN_METHOD(ShowAlert:(NSString *)message duration:(double *)duration)
@end

#import <React/RCTBridgeModule.h>
こちらはBridgeを介してデータのやり取りをするのに必要になります。その正体は https://github.com/facebook/react-native/blob/main/React/Base/RCTBridgeModule.h こちらになります。ご自身のReact nativeプロジェクトのnode_module内からも確認できます。

RCT_EXTERN_MODULE
こちらは先程のSwiftファイルのModule(クラス)を第一引数に、第二引数はクラスの型を入れます。型はView を持つコンポーネントである場合、RCTViewManager、ネイティブの機能を呼び出すだけの場合、NSObjectを入れます。

RCT_EXTERN_METHOD
こちらは先程のSwiftファイルの関数(メソッド)と関数で受け取る引数と型を入れます。


次に自動でXcodeが作成した {プロジェクト名}-Bridging-Header.h に必要なファイルをインポートします。


NativeModuleTest-Bridging-Header.h
#import "AppDelegate.h"
#import <React/RCTBridgeModule.h>
#import <React/RCTViewManager.h>

Bridgeを介すのに必要なファイルをimportします。
#import <React/RCTViewManager.h> 
こちらに関してはViewを持つコンポーネントを今回のAlert実装ではexportしていないので不必要ですが、次題のView実装で必要ですので記述しています。l

これでNative側の実装は終了です!
あとはJsコードでNativeModuleを呼んで実装しましょう。


App.js
import React from 'react';
import {Button, View, NativeModules} from 'react-native';

const App = () => {
  return (
    <View
      style={{flex: 1, alignItems: 'center', justifyContent: 'center'}}>
      <Button
        onPress={() =>
          NativeModules.NativeModuleAlert.ShowAlert('Hello NativeModule!', 1)
        }
        title="NativeModuleAlert"
      />
    </View>
  );
};

export default App;

import {Button, View, NativeModules} from 'react-native';
react-nativeからNativeModuleをimportして、Buttonをタップすると
NativeModuleAlert というModuleNameの ShowAlert という関数を呼び、引数に表示する文字列と秒数を渡しています。

以上で実装は終了です。
実際のビルドしたSimulatorの画面は当記事の最後に載せています。

Native module実装(View編)

お次はSwiftのUIViewをReact nativeのコンポーネントとして呼んでみましょう。
まずSwiftファイルから。

NativeModuleView.swift

import UIKit

class NativeModuleView: UIView {

  @objc var status = false {
    didSet {
      self.setupView()
    }
  }

  @objc var onClick: RCTBubblingEventBlock?

  override init(frame: CGRect) {
    super.init(frame: frame)
    setupView()
  }

  required init?(coder aDecoder: NSCoder) {
    super.init(coder: aDecoder)
    setupView()
  }

  private func setupView() {
    self.backgroundColor = self.status ? .green : .red

    self.isUserInteractionEnabled = true
  }

  override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
    guard let onClick = self.onClick else { return }

    let params: [String : Any] = ["swift":"NativeModule Test!",]
    onClick(params)
  }

}

基本的にはSwiftのコードでViewと、タッチイベントの実装をしただけで、Native moduleと関わる部分はあまりありません。
@objs をJs側で参照したい部分につけるだけです。
こちらのコードを次作成するSwiftファイルで、Bridgeに投げてあげるイメージです。


では次にManagerFileを作成します。
命名規則に従って、「Js側で呼び出したいSwiftファイル + Manager」で作成します。

NativeModuleViewManager.swift
@objc (NativeModuleViewManager)
class NativeModuleViewManager: RCTViewManager {
  override static func requiresMainQueueSetup() -> Bool {
    return true
  }

  override func view() -> UIView! {
    return NativeModuleView()
  }
}

@objc (NativeModuleViewManager)
こちらはAlert実装と同様に、ModuleNameを定義してあげます。

class NativeModuleViewManager: RCTViewManager {
ここで、RCTViewManagerを継承したクラスを定義します。

override static func requiresMainQueueSetup() -> Bool { 
こちらに関してはAlertを実装した時同様です。

override func view() -> UIView! { return NativeModuleView() }
こちらは、UIViewを返す関数で、先程の作成したNativeModuleView を返します。

流れ的には
1. UIViewを継承したクラスを作成する
2. RCTViewManagerを継承したクラスを作成し、その中は1をリターンするだけ。
なぜクラスを分けるのかと言いますと、RCTViewManagerを継承しなければJs側で呼べない為です。

次に ~~Manager,swiftをBirdgeに投げるObjective-Cファイルを作成していきます。


NativeModuleViewManager.m
#import <React/RCTViewManager.h>

@interface RCT_EXTERN_MODULE(NativeModuleViewManager, RCTViewManager)
RCT_EXPORT_VIEW_PROPERTY(status, BOOL)
RCT_EXPORT_VIEW_PROPERTY(onClick, RCTBubblingEventBlock)
@end

RCTViewManagerは React nativeでSwiftのUIView表示するのに必要です。
ソースはこちらですね。
https://github.com/facebook/react-native/blob/main/React/Views/RCTViewManager.h

NativeModuleViewManager というModuleNameでexportしますが、Js側でimportするときは NativeModuleView を指定します。

RCT_EXPORT_VIEW_PROPERTY はSwift側のViewに渡すプロパティです。クリックされた時のイベントと、クリックされたかどうかのステータスを定義しました。

以上でNative側の実装は終了です!
次にJsを書いていきましょう。


NativeModuleComponent.js
import React from 'react';
import {requireNativeComponent} from 'react-native';

const NativeModuleView = requireNativeComponent('NativeModuleView');

export const NativeModuleComponent = props => {
  const onClick = event => {
    props.onClick(event.nativeEvent);
  };
  return <NativeModuleView {...props} onClick={onClick} />;
};

まずSwiftの実装したViewを呼び、コンポーネント化します。
Viewを呼んで画面に表示する場合は、requireNativeComponent を使用してコンポーネント化します。

const NativeModuleView = requireNativeComponent('NativeModuleView');
NativeModuleViewというコンポーネント名で、NativeModuleViewを指定します。
先程、NativeModuleViewManager というModuleNameでexportしましたが、requireNativeComponentで指定するときは、Managerを書かずに指定します。

基本的に、requireNativeComponentを使用して実装する際は、該当のViewを定義してそれをreturnするだけのコンポーネントにした方がいいです。
該当のファイルをコーディング中に保存したりして、ホットリロードが走るとエラーになりますので頻繁に編集するファイルにrequireNativeComponentで定義するのは避けましょう。


App.js
import React, {useState} from 'react';
import {Button, View, NativeModules,  Alert} from 'react-native';
import {NativeModuleComponent} from './src/NativeModuleComponent';

const App = () => {
  const [status, setStatus] = useState(false);
  const onClick = event => {
    Alert.alert('event object : ' + JSON.stringify(event));
    setStatus(!status);
  };
  return (
    <View
      style={{flex: 1, alignItems: 'center', justifyContent: 'space-around'}}>
      <NativeModuleComponent
        status={status}
        onClick={onClick}
        style={{width: 100, height: 100}}
      />
    </View>
  );
};

export default App;

先程の作成したコンポーネントをimportして表示します。
引数に関しては、それだけです。
クリックすると走る処理に関してはreact-native標準のAlertを出してみます。

以上で実装終了です!!
Simulatorでどのような動きをするか最後に確認していきましょう。

Native module実装(動作確認)

スライドショー1枚目は、関数とView両方を実装した状態でApp.jsを表示してます。
上の赤い四角がSwiftのUIViewで実装したものです。
タップすると色が変わってReact nativeのAlertが表示されるはずです。

スライドショー2枚目は、Viewをタップしたものです。
色も変わってAlertも出てます。

スライドショー3枚目は、”NativeModuleAlert”ボタンをタップしたものです。
Swiftで実装したAlertが問題なく表示されてます。

スライドショー4枚目は、requireNativeComponentを定義しているファイルでホットリロードが走ってでたエラーです。
Minimizeで消せば問題ないですが、コンポーネントとして切り出すのが無難です。

参考にした記事・Git