React Nativeで複数のアプリを共存させられるか試してみた

こんにちは、エンジニアの中間です。
普段はKARTEのネイティブアプリ向けSDKの開発をしています。

React Nativeで遊んでみたことを紹介します。
こちらの投稿はPLAID Advent Calendar 2018の6日目の記事でもあります。

KARTEではWebサイトやアプリを通してユーザ一人一人に合わせたコミュニケーションをリアルタイムに取ることを大事にしています。
例えばKARTEのSDKは、管理画面でHTML/CSS/JavaScriptで記述されたアクションをアプリ内メッセージ(ポップアップやバナー、アンケート等)として表示する機能を持っています。
アプリ内メッセージの見た目や挙動を柔軟にカスタマイズできるので、様々な訴求を行えます。

今回は、よりリッチでネイティブライクな見た目のメッセージを出せないか、という可能性を探るために、React Nativeを使いアプリ内メッセージのような機能の実装・実験をします。
ポイントは以下の点です。

  • 主となるアプリ上に、アプリ内メッセージを模した第二のアプリを読み込み表示できるか
  • 第二のアプリをリアルタイムに(アプリアップデートの必要なく)変更できるかどうか

概要

以下の構成のサンプルアプリを作り正常に起動・表示できるかを試します。

  • メインのReact Nativeアプリの上にサブのReact Nativeアプリを表示する。サブのアプリではサーバからfetchしたbundleを使う(プロダクションを想定し、DeveloperSupportは使わない)

通常React Nativeのアプリをリリース用にビルドするとassetsにbundleファイルが配置されますが、ReactInstanceManager.setBundleLoaderによりbundleの読み込み方法は自由に指定できるようです。

メインのアプリは通常通りassetsに配置し、サブのアプリはサーバからInternal Storageにダウンロードしたbundleを使います。

今回はAndroidのみで試しました。

React Nativeプロジェクト

以下のようにプロジェクトを用意し、index.jsをメインのアプリ、index.sub.jsをサブのアプリとします。

├── android/ (Androidのプロジェクト)
├── index.js
├── index.sub.js
├── node_modules/
└── package.json

index.jsはMain Appの文字列を中央に表示するだけのアプリです。index.sub.jsも同じように、少しだけ見た目を変えたものを用意します。

class HelloWorld extends React.Component {
  render() {
    return (
      <View style={styles.container}>
        <Text style={styles.hello}>Main App</Text>
      </View>);
  }
}

var styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'center',
  },
  hello:{
    fontSize: 30,
    textAlign: 'center',
  },
});

設定

build.gradleproject.ext.react.entryFileindex.jsを指定します。これによりindex.jsのビルドファイルがassets/下にindex.android.bundleとして配置されます。

project.ext.react = [  
        entryFile: "index.js",  
        bundleInDebug: true,  
        bundleAssetName: "index.android.bundle"
]  

index.sub.jsは手動でビルドし、サーバから配布します。ビルドファイルはindex.sub.bundle.jsという名前にします。

> react-native bundle --platform android --entry-file index.sub.js --bundle-output index.sub.bundle.js --dev false
> http-server ./  #https://www.npmjs.com/package/http-server

Androidアプリの実装

Androidアプリは単一のActivityを持ち、Activity上に二つのReactRootViewReactInstanceManagerを保持して、二つのReactNativeアプリを読み込む構成にします。サブアプリはメインアプリの上に小さく重ねて表示させます。

public class TopActivity extends AppCompatActivity {
  public static final String SUB_BUNDLE_URL = "http://172.16.1.58:8080/index.sub.bundle.js";

  private ReactRootView mainReactRootView;
  private ReactRootView subReactRootView;

  private ReactInstanceManager mainReactInstanceManager;
  private ReactInstanceManager subReactInstanceManager;

  @Override
  protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_top);
    final FrameLayout rootLayout = findViewById(R.id.root_layout);

    //mainReactInstanceManagerのセットアップ
    mainReactRootView = new ReactRootView(this);
    mainReactInstanceManager = ReactInstanceManager.builder()
      .setApplication(getApplication())
      .setBundleAssetName("index.android.bundle")
      .addPackage(new MainReactPackage())
      .setCurrentActivity(this)
      .setInitialLifecycleState(LifecycleState.RESUMED)
      .build();

    mainReactRootView.startReactApplication(mainReactInstanceManager, "MainReactNativeApp");
    //メインAppは全画面表示
    rootLayout.addView(mainReactRootView, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));


    //index.sub.bundle.jsをダウンロードし、subReactInstanceManagerをセットアップ
    new AsyncTask<Void, Void, Boolean>() {

      @Override
      protected Boolean doInBackground(Void... voids) {
        return fetchSubBundle();
      }

      @Override
      protected void onPostExecute(Boolean isSuccessful) {
        if (!isSuccessful) return;

        subReactInstanceManager = ReactInstanceManager.builder()
          .setApplication(getApplication())
          .setJSBundleFile(getSubAppBundleFile().getAbsolutePath())
          .addPackage(new MainReactPackage())
          .setCurrentActivity(TopActivity.this)
          .setInitialLifecycleState(LifecycleState.RESUMED)
          .build();

        //サブAppは画面上部に小さく表示
        FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 600);
        params.gravity = Gravity.TOP;
        params.setMargins(30, 30, 30, 0);

        subReactRootView = new ReactRootView(TopActivity.this);
        subReactRootView.startReactApplication(subReactInstanceManager, "SubReactNativeApp");
        subReactRootView.setLayoutParams(params);

        rootLayout.addView(subReactRootView);
      }
    }.execute();
  }

  private File getSubAppBundleFile(){
    return new File(TopActivity.this.getFilesDir(), "index.android.sub.bundle");
  }

  private boolean fetchSubBundle() {
    try {      
      ResponseBody body = new OkHttpClient()
        .newCall(new Request.Builder().url(SUB_BUNDLE_URL).build())
        .execute()
        .body();
      if (body == null) return false;

      BufferedInputStream input = new BufferedInputStream(body.byteStream());
      OutputStream output = new FileOutputStream(getSubAppBundleFile());

      byte[] data = new byte[1024];
      int count;
      while ((count = input.read(data)) != -1) {
        output.write(data, 0, count);
      }

      output.flush();
      output.close();
      input.close();
    } catch (IOException e) {
      return false;
    }
    return true;
  }

  @Override
  protected void onPause() {
    super.onPause();
    mainReactInstanceManager.onHostPause(this);
    if (subReactInstanceManager != null) {
      subReactInstanceManager.onHostPause(this);
    }
  }

  //onResume, onDestroyも同様に両方のmanagerにdispatchする

アプリを起動すると、正しくメインアプリの上にサブアプリが重なって表示されるのを確認できました。

ReactInstanceManagerのインスタンス毎にJSのコンテキストが作られるようで、実際、index.jsでglobalに定義した変数をindex.sub.jsからは参照できませんでした。

実際に活用するためには考えるべきことがまだまだ沢山ありますが、複数のReactNative アプリの共存を動かして試すことができました!

今回作ったサンプルアプリは以下にあげました。
https://github.com/takaaki7/MultiReactNativeApp

最後に

CX(顧客体験)プラットフォーム「KARTE」を運営するプレイドでは、KARTEを使ってこんなアプリケーションが作りたい! KARTE自体の開発に興味がある!というエンジニア(インターンも!)を募集しています。
詳しくは弊社採用ページまたはWantedlyをご覧ください。 もしくはお気軽に、下記の「話を聞きに行きたい」ボタンを押してください!