tomoima525's blog

Androidとか技術とかその他気になったことを書いているブログ。世界の秘密はカレーの中にある!サンフランシスコから発信中。

React Nativeハイブリッドアプリケーション開発ことはじめ

こちらはReact Nativeアドベントカレンダー 19日目の記事になります。

ここ1、2年でReact Nativeによるアプリ開発はますます盛んになっていますが、一方でNativeと組み合わせたとハイブリッドアプリケーション開発はまだ発展途上です。 React Nativeの公式ドキュメントにもIntegrating with existing appという項目がありますが、あっさりと書かれている上に鮮度がお世辞にも高くありません。

しかしながら、FacebookAirbnbなど大企業がハイブリッドアプリケーションを積極的に導入していることや、Nativeアプリを部分的にリプレイスできる利便性から、今後も採用が増える分野と考えられます。本記事ではハイブリッドアプリを開発した自分の経験から、プロコンや実装の基本についてまとめました。

ハイブリッドアプリケーションの良い点/難しい点

そもそもハイブリッドアプリケーション開発のメリットはなんでしょうか?個人的には次にあげる点がReact Nativeのみのアプリより優れていると考えています。

  • 機能の特徴に応じてNativeとReact Nativeを使い分けができる
    例えば単純なリストの表示等であれば、マルチプラットフォームかつビルドの待ち時間ほぼ0で実装できるReact Nativeは圧倒的に速く実装できます。 一方で複雑なUIや、カメラ等プラットフォームにより内部実装が異なる機能を自前で実装する場合はNativeに寄せたほうが最終的には高い品質でリリースができます。

  • Frontend, Native開発者が共同で開発できる
    ハイブリッドアプリケーションは最初の設定部分こそNativeの知識が要求されますが、一旦仕組みが整えばReact開発経験のあるFrontendエンジニアも容易に開発に参加できます。 Native寄りの機能はNativeエンジニア、UI寄りの機能はFrontendエンジニアという形で開発体制が組めれば圧倒的スピードで開発できることが期待できます。

一方でハイブリッドアプリケーションの難しい点は、個人的な経験では次のようなものが挙げられます。

  • よりプラットフォームの知識が求められる
    create-react-native-app AwesomeProject の1行で最低限の開発環境が整うReactNativeアプリケーションと異なり、自前でReactNativeを設定する必要があります。 少なからずAndroid JavaやSwift(と、まさかのObject-C)の実装が求められます。

  • ラッキングやABテストが煩雑になる
    これは設計次第でありますが、トラッキングやABテストを行う場合、コア機能をどこに置き、どこで処理するかというのは悩みどころです。 例えばFirebaseでトラッキングする場合、SDKはNativeとReact Native向けがあります。 React Nativeで完結させる場合その分Bundle JSファイルが大きくなりますし、Native側に渡して処理する場合はブリッジを作る必要があるので、AndroidiOS両プラットフォームでの開発が必要になります。

ReactNativeのスピード感とNativeの安定感を両立させつつ、開発難易度が少なからず上がる点をどう攻略してくかがハイブリッドアプリのキモですね。

ハイブリッドアプリで考慮すべき点

React Nativeのみのアプリとハイブリッドアプリで大きく異なる点は2つあります。

  1. Nativeをまたがる画面遷移
    通常のReact Nativeアプリの場合、ひとつのView上でReactInstanceとReactViewを管理し、全ての画面遷移はReact Native上で発生します。 一方、ハイブリッドアプリの場合、状況に応じてReact Native上で遷移させるか、はたまたNativeのView上で遷移させるかを判断しなくてはなりません。 ReactInstanceのコールバックやライフサイクル管理等も含めて検討が必要になります。

  2. React Native - Native間のデータの受け渡し頻度
    例えばReact Native起動画面、必要なパラメタ、永続化したい情報などNative間とやりとりは頻繁に発生し、これらをうまく管理することが重要になります。

それでは上記の考慮すべき点を踏まえ、ハイブリッドアプリをどのように実装していくか具体的に見ていきます。

ハイブリッドアプリ実装の基本

今回は例として、以前 ReactNativeで理解しておくと良いReduxとMiddlewareのフローを理解する という記事で作ったChuck Norrisの名言を検索できるアプリChuck Norris Viewerを利用します。今回はAndroidでの実装1を扱います。

github.com

前提条件として、React Nativeにおける画面遷移はreact-native-router-fluxを利用します。React Native側の画面遷移の実装を抜粋すると以下になります。

// scenes/index.js
const RouterWithRedux = connect()(Router);
const Scenes = (props, context) => (
  <RouterWithRedux>
    <Scene key="root">
      { map(scenes, (component, name) => (
        <Scene
          key={name}
          title={name}
          component={component}
        />
      ))}
    </Scene>
  </RouterWithRedux>

これをベースに

Nativeでボタン押下 -> ReactNative画面 -> ReactNative画面へ遷移 -> Nativeに戻る

という次の図のようなフローをもったハイブリッドアプリを作っていきます。

f:id:tomoima525:20171219165615p:plain:w400

React Nativeの起動

まずはReact Native用のActivityを用意します。AndroidIntegration with Existing Appsを参考すれば問題ない2です。target SDK等は適宜最新の設定に変えます。
さて、ひとまずReact Nativeの画面が起動できるようになりました。ここで、起動する画面を指定したいので、ReactInstanceManager の引数にinitialSceneというパラメタを渡してあげます。

// MyReactActivity.java
@Override
protected void onCreate(Bundle savedInstanceState) {
    ...
    reactRootView = findViewById(R.id.rootView);

    Bundle bundle = new Bundle();
    bundle.putString("initialScene", "Search"); // 開きたいComponent名
    ...
    reactRootView.startReactApplication(reactInstanceManager, "ChuckNorrisViewer", bundle);
}

React Nativeではこの値はエントリポイントのindex.jspropsとして受け取ることが可能です。このデータを子ビューコンポーネントで使いたい場合はgetChildContext()を使います。

// index.js
class ChuckNorrisViewer extends Component {
  getChildContext() {
    const { initialScene, data } = this.props;
    if (data && typeof (data) === 'object') {
      return { data, initialScene };
    }
}
  render() {
    return (
      <Provider store={store}>
        <App/>
      </Provider>
    );
  }
}

こうすることで、scene/index.jsで受け取れるようになります。以下のようにinitialSceneとcomponentのnameが一致した場合、initialtrueにしてあげれば、その画面が起動するようになるわけです。

// scenes/index.js
const initialSceneProps = { initial: true };  // 起動する画面
const defaultSceneProps = { initial: false };
const Scenes = (props, context) => (
  <RouterWithRedux>
    <Scene key="root">
      { map(scenes, (component, name) => (
        <Scene
          ...
          component={component}
          hideNavBar={false}
          {...(context.initialScene === name ? initialSceneProps : defaultSceneProps)}
        />
      ))}
    </Scene>
  </RouterWithRedux>
);
Scenes.contextTypes = {
  initialScene: PropTypes.string.isRequired,
};

バックアローをつける

さて、これで起動してみると、最初に開くReact Nativeの上部バーにバックアローがないことに気づきます。

f:id:tomoima525:20171219170130p:plain:w400

Nativeの画面に戻るためには、バックアローが必要です。routerにはrenderBackButton renderLeftButtonというパラメタがあるので、それを利用します。BackButtonはReact NativeのHeaderBackButtonをカスタマイズしたものを使います。

// scenes/index.js
const initialSceneProps = {
  initial: true,
  renderLeftButton: () => <BackButton backTitle='back' onPress={ } />,
};

const defaultSceneProps = {
  initial: false,
  renderLeftButton: () => {null},
};

<Scene
  ...
  renderBackButton={() => <BackButton backTitle='back' onPress={() => Actions.pop() }/>}
  {...(context.initialScene === name ? initialSceneProps : defaultSceneProps)}
/>

これでバックアローが表示されるようになりました。
f:id:tomoima525:20171219170217p:plain:w400

バック機能を実装する

このバックアロー、実際にタップしても何も起きません。押されたことをNative側に伝えて画面を閉じる処理が必要になります。 ここでReact NativeとNativeを橋渡しするNativeModuleの登場です。
NativeModuleについては公式ドキュメントをご覧ください。

ここではEventHookというNativeModuleを作ります。

// ReactEventHook.java
public class ReactEventHook extends ReactContextBaseJavaModule {
    private final ReactEventCallback callback;
    public ReactEventHook(ReactApplicationContext reactContext, ReactEventCallback callback) {
        super(reactContext);
        this.callback = callback;
    }
    @Override
    public String getName() {
        return "EventHook";
    }
    @ReactMethod
    public void sendEvent(String name, @Nullable ReadableMap data) {
        // receive parameters from react module
        callback.onEventSent(name, data);
    }
}

React Native側ではEventHook.sendEvent()と書くことでNative側にeventを送ることができます。

// eventHook.js
import { NativeModules } from 'react-native';
export const nativeBack = () => {
  NativeModules.EventHook.sendEvent('nativeBack', {});
};
// scenes/index.js
const initialSceneProps = {
  initial: true,
  renderLeftButton: () => <BackButton backTitle='back' onPress={ nativeBack } />,
};

Native側ではcallbackを受け取ってハンドルしてあげます。

// MyReactActivity.java
@Override
protected void onCreate(Bundle savedInstanceState) {
    ...
    reactEventCallback = this::handleEvent;
    reactInstanceManager = ReactInstanceManager.builder()...
            .addPackage(new MyReactPackage(reactEventCallback))...
}

private void handleEvent(String name, @Nullable ReadableMap map) {
    switch (name) {
        case ReactConst.NATIVE_BACK:
            finish();
            break;
    }
}
// Eventhook.js
export const itemSelected = (item) => {
  NativeModules.EventHook.sendEvent('selected', { item }); 
}
// MyReactActivity.java
private void handleEvent(String name, @Nullable ReadableMap map) {
    switch (name) {
      case ReactConst.SELECTED:
              Intent data = new Intent();
              if(map != null) {
                 //Mapファイルから受け取ったデータを取り出す
                  data.putExtra(ReactConst.ITEM_SELECTED, map.getString(ReactConst.ITEM));
              }
              setResult(RESULT_OK, data);
              finish(); // 閉じる
              break;
    }
}

まとめ

簡単な画面遷移を題材にハイブリッドアプリの実装を見てみました。一つ一つの技術要素はすでに公式ドキュメントにある内容で実装可能であるということがわかったのではないでしょうか。

さらに発展的なテーマとして

  • React Native関連クラスのモジュール化
  • Javascript Bundleファイルの管理方法
  • React Nativeのアップグレード

などがありますが、今後機会があればまとめたいです。以下のスライドではCI等も含めた全体的な設計を説明しているので、参考になれば幸いです。

speakerdeck.com


  1. iOSの実装は https://github.com/tomoima525/react-native-hybrid-app/tree/master/ios2 にあります

  2. iOS版はPodFileに必要なものが記載されていなかったりして参考にならないので注意