16
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

iOSアプリにReactNativeを部分導入する方法

Last updated at Posted at 2018-12-22

※ Retty Advent Calendar 2018 22日目の記事です。
昨日は @WooNoo さんの記事で、 GraphQL でフロントに優しい API を作ろう でした。
ちょうど今、Retty Backend開発チームで新しいマイクロサービスの開発で絶賛構築しているBFF層もGraphQLを使っており、またどこかで運用経験も含めて記事が書かれると思います。

はじめに

少し前に、Retty Advent Calendar 2018 8日目の記事でRettyのiOSアプリで ReactNativeを利用したOTAの仕組みについて 紹介があったが、本日その記事の続きとして実際ReactNativeを部分導入した実装の話について書きたいと思います。

フルReactNative vs 部分ReactNative

ReactNativeには興味があるけど、実際NativeアプリをReactNativeフルで実装しようとするとだいぶ不安がありますよね。なぜなら、未だに、ReactNativeのバージョンがまだ v0.57 です。
こちら、去年導入した時に書いた記事 - How to build an "infinite" list in ReactNative、ReactNativeで大きなリストビューを実装する際に、まだNativeのUITableViewUICollectionViewをリプレースできるコンポーネントが存在します。
あと、アニメーションなどマルチスレード関連の機能も、JSスレッドでどうしても厳しかったりします。
個人的に、ReactNative v1.0 がリリースされる(その日が来るのか?)前に、既存のNativeアプリへの部分導入をお勧めします。

ReactNative部分導入

ReactNative公式のページにも紹介されているように - Integration with Existing Apps、Objective-C/Swiftで書かれているiOS NativeアプリにReactNativeを部分導入する方法があります。
RettyのiOSアプリは2017の秋にリニュアルされて、当時Swift 4.0がリリースされて、Swift 4.0を採用して開発を行なっていました。なので、この記事はObjective-Cではなく、NativeアプリをSwiftで書かれた時の話になります。

SwiftからRCTRootViewを利用する2つの形式

UIViewControllerのself.viewを丸ごとリプレースする

ReactNatieを導入する最初の頃、我々が考えたのは画面毎にReactNativeを採用するやり方です。いわゆるUIViewControllerのself.viewに一個のReactNativeビュー乗せることです。
実際のUIViewControllerのextensionとして実装しました。

UIViewController+ReactNative.swift
import UIKit
import React

private var rootReactViewAssociationKey = 0

extension UIViewController {
    var rootReactView: RCTRootView? {
        get {
            return objc_getAssociatedObject(self, &rootReactViewAssociationKey) as? RCTRootView
        }
        set {
            objc_setAssociatedObject(self, &rootReactViewAssociationKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
        }
    }

    func setReactParameter(parameter: [String: Any]) {
        let typeName = NSStringFromClass(type(of: self)).components(separatedBy: ".").last ?? ""
        let typeNameShort = typeName.replacingOccurrences(of: "ViewController", with: "")

        if let rootReactView = self.rootReactView {
            if rootReactView.bridge.isLoading {
                DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.1) {
                    self.setReactParameter(parameter: parameter, on: view)
                }
                return
            }

            self.rootReactView.appProperties = parameter
        } else {
            let reactView: RCTRootView = RCTRootView(
                bundleURL: ReactNativeBundleManager.shared.bundleUrl,
                moduleName: typeNameShort,
                initialProperties: parameter as [NSObject: AnyObject],
                launchOptions: nil
            )

            self.view.addSubview(reactView)

            reactView.translatesAutoresizingMaskIntoConstraints = false
            NSLayoutConstraint.activate([
                reactView.topAnchor.constraint(equalTo: self.topLayoutGuide.bottomAnchor),
                reactView.bottomAnchor.constraint(equalTo: self.bottomLayoutGuide.topAnchor),
                reactView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor),
                reactView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor)
                ])

            self.rootReactView = reactView
        }
    }
}

ぱっと見複雑なので、少しコードを解説します。

  1. UIViewControllerにrootReactViewというAssociatedObjectを持たせます。
  2. UIViewControllerのsetReactParameterというメソッドがはじめて呼ばれる時に、rootReactViewが存在しないため、RCTRootViewを一個作って、self.viewに載せます。
    • RCTRootView: UIViewの拡張classでReactNativeのJSコンポーネントを読み込んでrenderしてくれます。
    • RCTRootViewのconstructorが bundleURL moduleName initialProperties launchOptions 4つのパラメーターがあります。
      • bundleURL: ReactNativeBundleManager.shared.bundleUrlを渡していますが、ReactNativeBundleManagerはOTAの仕組みを実現しているclassで、自動的にReactNativeのjsbundleファイルの場所を返してくれます。(e.g. http://localhost:8080/index.ios.bundle?platform=ios)
      • moduleName: ReactNativeのjsbundleの中にあるmoduleの名前です。ここでUIViewControllerのclassName(コード中のtypeNameShort)を使って自動的に決めています。(e.g. HomeViewControllerが自動的にHomeというmoduleをロードします。)
      • initialProperties: ReactNativeのmoduleに渡すpropertiesです。Native → ReactNativeへコミュニケートするメインの手段になります。ここでsetReactParameterのパラメーターを渡します。
      • launchOptions: 同じくpropertiesを渡したりするオプションです。initialPropertiesを利用しているため、nilを渡します。
    • 作ったRCTRootViewをself.viewのsubViewとして追加します。
    • self.viewと完全に被るようにLayout Constraintを設定します。
    • RCTRootViewをrootReactViewに代入します。
  3. setReactParameterが2回以降に呼ばれる時に、パラメーターをrootReactView.appPropertiesに直接代入します。RCTRootViewのappPropertiesが更新されると、ReactNative moduleのcomponentWillReceivePropsが呼ばれて、新しいpropertiesを受け取ることができます。(ReactNative側の話については、今回詳しく説明しません。)
    • ここで1つ注意する必要があるところは、RCTRootViewのbridge.isLoadingというpropertyがtrueになっていると、moduleがまだローディング中で、新しいpropertiesを受け取ることができないため、bridge.isLoadingがfalseになるまで少し待ってあげる必要があります。

UIViewをContainerとしてRCTRootViewを載せる

実は、2つ目の形式として紹介するんですが、仕組みとしてはほぼ同じです。
RCTRootViewを普通のUIViewとしてNativeのUIViewのsubViewとして使うと、何が変わるかというと、
Auto Layoutを利用して、subViewのサイズによて親ビューのサイズも変わるような場合、ReactNative側のコンポーネントのサイズによって、RCTRootViewのサイズを自動的に変わってくれません。いわゆるRCTRootViewのLayoutはAuto Layoutのシステムに自動的にのってくれないことです。
ReactNative側のコンポーネントサイズの変化を検知する必要があるということで実装します。

ReactNativeContainerView.swift
import UIKit
import React

protocol ReactNativeContainerViewDelegate: class {
    func intrinsicInnerViewSizeDidChange(_ view: ReactNativeContainerView)
}

class ReactNativeContainerView: UIView {
    private var rootReactView: RCTRootView!
    weak var delegate: ReactNativeContainerViewDelegate?
    ...
    private func initializeRootReactView(parameter: [String: Any]) {
        let reactView: RCTRootView = RCTRootView(
            bundleURL: ReactNativeBundleManager.shared.bundleUrl,
            moduleName: self.componentName,
            initialProperties: parameter as [NSObject : AnyObject],
            launchOptions: nil
        )
        reactView.sizeFlexibility = .widthAndHeight
        reactView.delegate = self
        reactView.isHidden = true

        self.addSubview(reactView)

        reactView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            reactView.topAnchor.constraint(equalTo: self.topAnchor),
            reactView.bottomAnchor.constraint(equalTo: self.bottomAnchor),
            reactView.leadingAnchor.constraint(equalTo: self.leadingAnchor),
            reactView.trailingAnchor.constraint(equalTo: self.trailingAnchor)
            ])

        self.rootReactView = reactView
    }
    ...
}

extension ReactNativeContainerView: RCTRootViewDelegate {
    func rootViewDidChangeIntrinsicSize(_ rootView: RCTRootView!) {
        DispatchQueue.main.async {
            self.intrinsicInnerViewSize = rootView.intrinsicContentSize
            self.delegate?.intrinsicInnerViewSizeDidChange(self)
            self.rootReactView.isHidden = false
        }
    }
}
  • ここで一番重要なものはRCTRootViewDelegateです。
    RCTRootViewDelegateはRCTRootViewのdelegate protocolで、rootViewDidChangeIntrinsicSizeという唯一のメソッドを持っています。
    名前の通り、RCTRootViewのIntrinsicSizeが変わる時に呼ばれるので、その中で、更新されたサイズをReactNativeContainerViewにお知らせすることができます。

  • もう1つ概念として、RCTRootViewがsizeFlexibilityというpropertyを持っています。このpropertyはRCTRootViewのIntrinsicSizeが変わる時に、rootViewDidChangeIntrinsicSizeが呼ばれるかどうかをコントロールすることができます。代入できる値は以下の4つになっています。

    • .none: サイズの変化を知らせない
    • .width: 横のサイズ変化だけを知らせる
    • .height: 縦のサイズ変化だけを知らせる
    • .widthAndHeight: 横、縦両方のサイズ変化を知らせる

    ここで、横、縦両方のサイズ変化を検知したいので.widthAndHeightにします。

  • あと、サイズが決まるまでにRCTRootViewが見えると、ビューサイズが変わってる様子が見れてしまうので、isHiddenpropertyを使って、サイズが決まるまでにRCTRootViewを非表示にさせています。

ここまで紹介した2つの方法を利用すれば、Swiftコードから簡単にReactNativeのmoduleを使うことができます。

ReactNativeからNativeのUIコンポーネントを使う

SwiftコードからRCTRootViewを使ってReactNativeのmoduleを使う方法がありましたが、自然に、ReactNativeからSwiftのUIコンポーネントを利用する方法が欲しくなりますよね。
オフィシャルサイド - Native UI Componentsの紹介がすでに物足りると思うので、冗長に説明しません。我々のアプリ中のサンプルコードだけ貼っておきます。

FollowButtonManager.swift
import UIKit
import React

@objc(FollowButtonManager)
class FollowButtonManager: RCTViewManager {
    override func view() -> UIView! {
        return FollowButton()
    }
}

RCTViewManagerを継承したmanager classを定義してObjective-Cへ名前をexportします。
RCTViewManagerのview()メソッドをoverrideし、NativeのUI classのinstanceを返します。

FollowButtonManagerBridge.m
#import <React/RCTBridgeModule.h>
#import <React/RCTViewManager.h>

@interface RCT_EXTERN_MODULE(FollowButtonManager, RCTViewManager)

RCT_EXPORT_VIEW_PROPERTY(userId, int)
RCT_EXPORT_VIEW_PROPERTY(hasFollow, BOOL)

@end

Objective-Cの.mファイルを作成し、ReactNativeへのbridge処理を書きます。

  • RCT_EXTERN_MODULE: Objective-Cのmanager classをReactNativeにexportするmacroです。
  • RCT_EXPORT_VIEW_PROPERTY: NativeのUI classのpropertiesをReactNativeにexportするmacroです。
FollowButton.js
...
import { requireNativeComponent } from 'react-native';
...

export default function FollowButton(props) {
  return <NativeFollowButton {...props} />;
}

FollowButton.propTypes = {
  userId: PropTypes.number.isRequired,
  hasFollow: PropTypes.bool.isRequired,
  ...View.propTypes,
};

const NativeFollowButton = requireNativeComponent('FollowButton', FollowButton);

ReactNativeが提供するrequireNativeComponentメソッドを使えば、Nativeコンポーネントをimportすることができます。そして、Objective-CでexportしたpropertiesをReactNativeのコンポーネントでも宣言してmappingさせておけば、あとで他のReactNativeコンポーネントで簡単に使えます。

HogeCell.js
...
import FollowButton from './FollowButton';
...

class HogeCell extends React.Component {
  render() {
    ...
      <FollowButton userId={...} hasFollow={...} />
    ...
  }
}

Swiftで定義したUIコンポーネントでもとても簡単にReactNativeにexportして使えるでしょう。こうしてNativeとReactNativeで共通のUIパーツを使って、UIを統一することもできます。

ReactNativeからNativeのメソッドを呼び出す

先ほどでSwiftからRCTRootViewにpropertiesを渡せる話しましたが、逆に方向で、ReactNativeのmoduleからSwift側のメソッドを呼び出す方法も紹介します。

NativeModules

オフィシャルサイト - NativeModulesでも紹介していますが、ReactNativeがNativeModulesというモジュール提供しています。実際はObjective-Cで利用した場合の説明資料はネット上にたくさんある一方、Swiftで利用した際の資料はほとんど見つかりません。まあ、SwiftのclassをObjective-Cへexportする"簡単"な話だからですかね。

Localization.swift
import Foundation
import React

@objc(Localization)
class Localization: NSObject {
    @objc(localize:withArguments:resolve:reject:)
    func localize(text: String, args: [Any], resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) {
        resolve(String(format: text.localized(), arguments: args.map { $0 as? CVarArg ?? "" }))
    }
}
LocalizationBridge.m
#import <Foundation/Foundation.h>
#import <React/RCTBridgeModule.h>

@interface RCT_EXTERN_MODULE(Localization, NSObject)

RCT_EXTERN_METHOD(localize:(NSString *)text withArguments:(NSArray *)args resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject)

@end
  1. SwiftのNSObjectを継承したclassを作成してObjective-Cへ名前をexportします。
  2. メソッドを定義して、同じくObjective-Cへexportします。
    • ここでRCTPromiseResolveBlockRCTPromiseRejectBlock2つタイプのパラメーターがあるけど、NativeのメソッドからReactNativeに値を返すためのコールバックです。
      • RCTPromiseResolveBlock: 正常の値を返す。(あとでReactNative側の受け取り方についても説明があります。)
      • RCTPromiseRejectBlock: エラーが発生したら、エラーを返す。
  3. .mファイルを作成して、ReactNativeへのbridge処理を書きます。
    • RCT_EXTERN_MODULE: Objective-CのobjectをReactNativeにexportするmacroです。
    • RCT_EXTERN_METHOD: Objective-C objectのメソッドをReactNativeにexportするmacroです。(exportしなければObjective-Cのobjectがメソッドを持っていてもReactNativeからは見えません。)
localize.js
...
import { NativeModules } from 'react-native';
...

const { Localization } = NativeModules;

export default function localize(text, args) {
    ...
    Localization.localize(text, args)
        .then(localizedText => ...)
        .catch(err => ...);
    ...
}

上のコードを見るとわかるが、実際exportされたメソッドが返しているのはJS側のpromiseになっています。

  • RCTPromiseResolveBlockに渡す値はthenブロックで受け取る
  • RCTPromiseRejectBlockに渡すエラーはcatchブロックで受け取る

ここまでReactNativeからSwiftで定義したメソッドの呼び出し方について説明したが、かなりシンプリではあると思います。

最後に

ReactNativeのバージョンはまだv0.57ではあるが、我々今RettyのiOSアプリに導入して使っている分は全然問題なく動いてくれています。現在ReactNativeで作られたコンポーネントがOTAの仕組みによって、日々UI/UXの微調整ができたりして、より良いアプリを作り上げていっています。
そして、ReactNative v1.0版がリリースされる日が来るように願います。

16
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
16
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?