スタディサプリ Product Team Blog

株式会社リクルートが開発するスタディサプリのプロダクトチームのブログです

React Nativeハイブリッドアプリへの挑戦 ~ Part2: 導入/Bridge ~

本エントリは3部作のPart2となっております。


モバイルエンジニアの@hotchemiです。

Part1からすっかり時間が空いてしまい恐縮ですが引き続き弊社のReact Nativeハイブリッドアプリの取り組みについて語っていければと思います。

段階的なインテグレーション

前回のエントリでmonorepoへの移行とCIの整備について語りましたが、今回は実際にどの様にインテグレーションを進めていったかについて説明していきます。

新しい技術を採用するに辺り、ビッグバンリリースを避け段階的に導入を試みていくアプローチの方がリスクを避け柔軟に対応できると考えています。ですので一気に再構築プロジェクトを発令するのではなく、ある機能をリニューアルする際にその画面のみReact Nativeで書いてみる、という進め方を採用しました。

具体的には、下図にあるプロフィール機能の部分から導入を進めていきました。枠線で囲まれた部分がReact Native、それ以外の部分はSwift/Kotlinで書かれているという状態です。

以下、OS毎に具体的な実装の話をしていきます。

お時間のある方は読み進める前に公式ドキュメントのIntegration with Existing Appsを一読して頂ければより理解しやすいと思います。

iOS

React NativeがAPIとして提供しているRCTRootViewを活用します。これは、RCTBridgeと呼ばれるBridgeモジュールを通してJavascriptのメモリ領域にアクセスし、紐付けられたReact Componentを実際にNativeのViewとして描画するコンポーネントです。

我々はこのViewをラップしたReactViewControllerを用意しておりViewController単位での導入を実現しています。

import UIKit
import React

public typealias ReactProps = [String : AnyObject]

class ReactViewController<BridgeModule: ReactNativeProxyBridgeModule>: UIViewController {
    var screenName: String?
    var props: ReactProps?
    var bridgeModule: BridgeModule!

    convenience init(screenName: String, props: ReactProps? = nil) {
        self.init(nibName: nil, bundle: nil)
        self.screenName = screenName
        self.props = props
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        bridgeModule.register(forTarget: self)
        setTitle()
        populateReactView()
    }

    private func populateReactView() {
        guard let reactView = RCTRootView(bridge: ReactNative.shared.bridge, moduleName: screenName, initialProperties: nil) else { return }
        view.addSubview(reactView)
        reactView.translatesAutoresizingMaskIntoConstraints = false
        if #available(iOS 11.0, *) {
            let guide = self.view.safeAreaLayoutGuide
            reactView.trailingAnchor.constraint(equalTo: guide.trailingAnchor).isActive = true
            reactView.leadingAnchor.constraint(equalTo: guide.leadingAnchor).isActive = true
            reactView.topAnchor.constraint(equalTo: guide.topAnchor).isActive = true
            reactView.bottomAnchor.constraint(equalTo: guide.bottomAnchor).isActive = true
        } else {
            NSLayoutConstraint.activate([
                reactView.topAnchor.constraint(equalTo: view.topAnchor, constant: 0),
                reactView.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: 0),
                reactView.leftAnchor.constraint(equalTo: view.leftAnchor, constant: 0),
                reactView.rightAnchor.constraint(equalTo: view.rightAnchor, constant: 0)
                ])
        }
    }

    private func setTitle() {
        let item = props?["item"] as? NSDictionary
        let title = item?["name"] as? String
        self.title = title
    }
}

一部を抜粋するとこの様な実装になっています。screenNamepropsをinjectableにする事でJavaScript側からどの画面を描画するかやNavigationBarの基本的な設定をする事を可能にしています。ReactNativeProxyBridgeModuleに関しては後述します。

このViewControllerを描画したい領域に組み込み、対応するReact Componentを用意します。

class ProfileContainer extends Component<Props> {
  render() {
    return (
      <SectionList
        ListHeaderComponent={() => {
          return (
            <ListHeader/>
          );
        }}
        renderItem={({ item }) => {
          return <SectionListItem item={item} />;
        }}
        renderSectionHeader={({ section }) => {
          return <SectionListHeader section={section} />;
        }}
        sections={profileSections()}
        stickySectionHeadersEnabled={true}
      />
    );
  }
}

そしてこのComponentをAppRegistryというRegistryにstring keyと紐づけて登録します。

export default function registerComponents(store: Store<any>) {
  AppRegistry.registerComponent(Screens.profile, () =>
    withReduxStore(ProfileContainer, store),
  );
}

こうする事で、ReactViewControllerに渡すscreenNameregisterComponentで紐づけたkey名が一致していればComponentが描画されるようになります。

Android

AndroidにもRCTRootViewと同じくReactRootViewというCustomViewが提供されています。

ReactのComponentを紐付ける手順は同じですが、既存のアプリへの部分的導入を考える場合はFragmentとして組み込んだ方がやりやすい、という場合があります。React NativeはReactActivityというActivityベースのAPIを提供していますがFragment版はない為、hudl/react-native-android-fragmentを一部カスタムしたものを利用していました。

Fragment messagingFragment = new ReactFragment.Builder()
    .setComponentName(Screens.profile)
    .setLaunchOptions(launchOptions)
    .build();

その他にApplicationクラスにReactApplicationをimplementしReactNativeHostやNDKの初期化を実装する必要がありますがそちらは公式ドキュメントを参照下さい。

Bridge

段階的な導入を進めていくに当たって、既存のコードとうまく連携していく事はとても重要になります。

先程の例でいうと、プロフィールの画面からアプリ内課金(IAP)申し込み画面への導線が存在しています。課金処理を全部JavaScriptで書き直す…? いくら何でもちょっと怖いですよね。なのでここでは単純に既存画面への遷移のみを実装しています。

上記の例を元にBridgeを実現するに当たって必要になる実装をOS毎に説明していきます。ここではJavaScript→Native方向のBridgeを考えます。尚、こちらも公式ドキュメント(iOS, Android)を合わせて読むことを推奨します。

iOS

私達のアプリはSwiftで書かれていますから、Exporting Swiftを参考にRCT_EXTERN_MODULERCT_EXTERN_METHODを利用すれば実装自体は難しくありません。以下の様なコードを実装する事でJavaScript側からNativeModules.ProfileView.showUpSell()として呼び出す事ができます。

@objc(ProfileView)
class ProfileViewController {
    @objc func showUpSell() {}
}
#import <React/RCTBridgeModule.h>

@interface RCT_EXTERN_MODULE(ProfileView, NSObject))
RCT_EXTERN_METHOD(showUpSell)
@end

しかし、RCT_EXTERN_MODULERCT_EXTERN_METHODはViewControllerのインスタンスメソッドを呼ぶには少々都合が悪いです。マクロの実装を読むと分かるのですが、NSObjectを継承したクラスとして展開される事が期待されている為、当然の事ながらselfではViewControllerのインスタンスにアクセスできません。この問題を解決する為に、我々は独自のBrigeモジュールを開発して利用しています。

#import <React/RCTBridgeModule.h>

@interface ReactNativeProxyBridgeModule : NSObject

@property (nonatomic, weak, readonly) id target;

- (void)registerForTarge:(id)target;
- (void)deregister;

#define RN_EXTERN_MODULE(js_name) \
  js_name##BridgeModule : ReactNativeProxyBridgeModule <RCTBridgeModule>\
  @end \
  @implementation js_name##BridgeModule \
    RCT_EXPORT_MODULE(js_name)

#define RN_EXTERN_METHOD(method) \
  RCT_EXTERN_METHOD(method) \
  _RN_PROXY_METHOD(method)

#define _RN_PROXY_METHOD(method) \
  - (void)method \
  { \
    if ([NSThread isMainThread]) { \
      [self.target method]; \
    } else { \
      dispatch_sync(dispatch_get_main_queue(), ^{ \
        [self.target method]; \
      }); \
    } \
  }

@end

端的に説明すると、registerForTargetでViewControllerの参照をweakとして保持しておく事でexportしたメソッドをインスタンスメソッドとして呼び出すというアプローチです。これらのマクロを利用してBridgeファイルは以下の用に書き換える事ができます。

#import <React/RCTBridgeModule.h>
#import "ReactNativeProxyBridgeModule.h"

@interface RN_EXTERN_MODULE(ProfileView)
RN_EXTERN_METHOD(showUpSell)
@end

先程説明したReactViewControllerを継承する用に書き換えます。ProfileViewBridgeModuleというのはマクロで展開されたBridgeモジュールの名前です。

class ProfileViewController: ReactViewController<ProfileViewBridgeModule> {
    @objc func showUpSell() {}
}

そして、最後に展開されたモジュールをRCTBridgeDelegateextraModulesに登録します。

// MARK: - RCTBridgeDelegate
extension ReactNative: RCTBridgeDelegate {
    func extraModules(for bridge: RCTBridge!) -> [RCTBridgeModule]! {
        return [ProfileViewBridgeModule()]
    }
}

これでViewControllerのインスタンスメソッドとして利用できるようになりました!

Android

Androidの場合は公式ドキュメントの通りに実装すれば問題ありません。まずReactContextBaseJavaModuleを継承したModuleを実装します。ここではcurrent activityを取得できますのでそれを利用したりEventBusを利用して必要な画面にイベントを通知するだけです。

public final class ProfileModule extends ReactContextBaseJavaModule {
    ProfileModule(ReactApplicationContext reactContext) {
        super(reactContext);
    }
    @ReactMethod
    public void showUpSell() {
        Activity activity = getCurrentActivity();
        if (activity != null) {
            IntentHelper.openPaymentIntentPage(activity);
        }
    }
    @Override
    public String getName() {
        return "ProfileView";
    }
}

作成したModuleはPackageとして登録し、ReactNativeHostに登録するだけです。簡単ですね。

public final class ProfileReactPackage implements ReactPackage {
    @Override
    public List<ViewManager> createViewManagers(ReactApplicationContext reactContext) {
        return Collections.emptyList();
    }
     @Override
    public List<NativeModule> createNativeModules(ReactApplicationContext reactContext) {
        return Collections.singletonList(new ProfileModule(reactContext));
    }
}
private final ReactNativeHost reactNativeHost = new ReactNativeHost(this) {
    @Override
    protected List<ReactPackage> getPackages() {
        return Arrays.asList(new MainReactPackage(), new ProfileReactPackage();
   }
};

Native→JavaScript

これまでJavaScript→NativeへのBridgeの実装を見てきましたが今度は逆方向を考えます。基本的な考えとしては用意されているEventBus機構を利用してイベントの通知やデータの送付を行います。

公式ドキュメントにはiOSNativeEventEmitterAndroidRCTDeviceEventEmitterを使えと記載がありますが実はiOSでもRCTDeviceEventEmitterを利用する事が可能です(この辺はairbnb/native-navigation/の実装を参考にしました)。

final class ReactNative: NSObject {
    enum EventType: String {
        case onCancelPress
    }

    var bridge: RCTBridge?

    private override init() {
        super.init()
    }

    func enqueueEvent(name: EventType, with: Any! = []) {
        let args: [Any] = [name.rawValue as Any, with]
        bridge?.enqueueJSCall("RCTDeviceEventEmitter.emit", args: args)
    }
}
public final class ReactNativeUtils {
     private ReactNativeUtils() {
    }
    private static void maybeEmitEvent(@Nullable ReactContext context, @ReactNativeEvent String name, Object data) {
        if (context == null || !context.hasActiveCatalystInstance()) {
            return;
        }
        try {
            context.getJSModule(RCTDeviceEventEmitter.class).emit(name, data);
        } catch (RuntimeException e) {
            // the JS bundle hasn't finished executing, so this call is going to be lost.
            // In the future, we could maybe set something up to queue the call, and then pass them through once
            // the bundle has finished getting parsed, but for now I am going to just swallow the error.
        }
    }
}

発火したイベントはReact側でDeviceEventEmitterを利用してlistenする事が可能です。

class ProfileContainer extends Component<Props, State> {
  private subscription: EmitterSubscription;
  constructor(props: Props) {
    super(props);
    this.subscription = DeviceEventEmitter.addListener(EventType.onCancelPress, (data) => console.log(data));
  }

  componentWillUnmount() {
    this.subscription.remove());
  }
}

ベストプラクティス

上記のインスラストラクチャを元にインテグレーションを進めていった結果、現在ではNative:75%, ReactNative:25%程のコード比率となっています。

ここまで来るに辺りいくつか設計で躓いたポイントや考え方があるのですが、汎用的に役に立ちそうなものを2つ共有させて頂きます。

Dataの同期戦略

他の多くのプロジェクトと同じ様に我々もReactとReduxを組み合わせて使っています。Reduxの思想としてSingle Storeという考え方があるのになんで同期戦略を考えるの?と思った方もいるかもしれません。ここがハイブリッド開発のややこしい所で、多くのケースでNative側にも既存のインフラストラクチャ層が存在している為実際にはSingle Storeにする事はできないのです。図で表すと以下の様になります。

ここで出てくるのが、どうやってデータを同期しよう?という問題です。この問題に対する我々の解としては「できる限りデータは同期しない。どうしても必要なものに関しては単一方向のデータフローを作成せよ」です。初期段階では双方向に同期する事を考えていたのですが、あまりにも複雑な状態になり破綻した為そもそも同期自体がバッドパターンである、と考えるようになりました。ただどうしても双方で必要なData(userId等)もあるので、その場合は単一方向のデータフロー(我々の場合はNative→JS)を遵守するようにしています。

iOSの実装ベースで説明すると、UserデータのAPIをcallする処理の中でシリアライズしたデータを先程のBridgeを利用してReact Native側に通知します。

func activate() -> Promise<Qlearn.Bootstrap> {
    return API.Qlearn.Bootstrap
        .bootstrap()
        .then { bootstrap -> Promise<Qlearn.Bootstrap> in
            ReactNative.shared.enqueueEvent(name: .sendUserData, with: bootstrap.toJSONString())
            let initializer = Initializer()
            initializer.execute(user: user, context: userSessionContext)
            return Promise<Qlearn.Bootstrap>(resolved: bootstrap)
        }
}

React Native側では通知されたデータをデシリアライズしてreduxのstoreにdispatchします。このlistenerはコンポーネントのライフサイクルとは関係なく維持したい為、reduxのenhancerもしくはグローバルな領域でobserveします。

import { DeviceEventEmitter } from 'react-native';
import { Store } from 'redux';
import { BootStrap, updateBootStrap } from '../actions/bootstrapAction';
import { clearStore } from '../auth/authAction';
import { EventType } from '../constants/eventType';

export const observeSendUserDataEvent = (store: Store<any>) => {
  DeviceEventEmitter.addListener(EventType.sendUserData, (payload: string) => {
    const bootstrap: BootStrap = JSON.parse(payload);
    store.dispatch(updateBootStrap(bootstrap));
  });
};

こうする事でNative側でユーザー情報を更新する度に自動でReact Native側にも更新が同期される仕組みを実現しています。

ちなみにシリアライズ/デシリアライズしているのはBridge間でやり取りできるデータ型には制限(NSString, NSInteger, float, double, CGFloat, NSNumber, BOOL, NSNumber, NSArray , NSDictionary, RCTResponseSenderBlockのみ)があり複雑なデータ構造を渡すには一度NSDictionaryに変換する必要がある為、面倒なのでStringにしているだけです。

UIの一貫性

React Nativeを導入していくに辺り、UI面で最も悩んだのが「Navigationをどう実装するか?」という事でした。結論から言うと、我々はViewController/Activityをベースとして考えておりその中にReact NativeのViewを載せる、という設計思想に則っています。つまり、ベースはNativeとして考えReact Nativeは「JavaScriptで書けるCustomView」として捉えています。

初期段階ではReact Navigationなど幾つかのJSベースのライブラリを調査・検討しましたが、やはり既存画面の遷移と100%同じにする事はできず我々のquality barを満たすものではありませんでした。私が個人的に大好きなreact-native-navigationはハイブリッド向けのAPIは提供しておらず、Airbnbnative-navigationは正にという感じだったのですが組み込んでみた所なぜか動かず結果として自前の実装を持つことにしました。

結論として、この方針は少なくとも現段階ではうまくworkしており、既存画面とのインテグレーションのしやすさやiPhoneX用のSafeArea等新しいAPIの変更にも柔軟に対応する事ができています。欠点としてはReactを書くエンジニアもある程度仕組みを理解しないといけない事ですがそこは割り切ってチーム内で知識を共有するようにしています。

また、iOSであればHuman Interface GuidelinesAndroidであればMaterial Designの原則を理解しておく事は、Native/React Native関係なく重要です。こちらも開発チーム内で最低限の知識は共有していくように努めています。

まとめ

本稿では主にNative側に焦点を当て、React Nativeの部分的な導入の進め方やBridgeの実装について説明しました。

Part3では半年程利用してきた振り返りや今後について共有させて頂ければと思います。