React Native アプリの開発基盤構築

こんにちは、投稿開発部の @morishin127 です。React Native 新アプリシリーズ連載2日目ということで、この記事では React Native アプリの開発基盤の構築について書こうと思います。「クックパッド MYキッチン」というアプリは React Native 製で、iOS/Android 両プラットフォームでリリースされています。元々は一人の手で JavaScript (ES2017+) によって書かれていたアプリケーションでしたが、リリースまでの間に開発メンバーも増え、TypeScript の導入や CI の整備、また高速な検証のためにログ収集の仕組み作りや CodePush の導入などを行いました。それぞれ具体的にどのようなことをしたかを説明します。

セットアップスクリプト

npm-scripts を用いて npm run ios:setup / npm run android:setup でそれぞれのプラットフォームでアプリケーションをビルドするための依存関係をインストールできるようにしています。npm-scripts は package.json に定義していて、それぞれの定義は次のようになっています。

{
    "scripts": {
        "ios:setup": "cd ios && bundle install && bundle exec fastlane setup",
        "android:setup": "cd android && bundle install && bundle exec fastlane setup",
        ⋮
    },
    ⋮
}

ネイティブアプリのセットアップやバイナリ生成の処理には Fastlane を用いており、ここで行うセットアップの処理も ios/fastlane/Fastfile / android/fastlane/Fastfile に次のように定義しています。Fastlane は Ruby 製のタスクランナーで、ビルドやストアへのサブミット、スクリーンショットの撮影など様々なタスクを定義して自動化するためのツールです。

ios/fastlane/Fastfile

react_native_root = File.absolute_path('../../')

desc "Install dependencies"
lane :setup do
  Dir.chdir react_native_root do
    sh "yarn install && yarn run build"
  end
  cocoapods(use_bundle_exec: true, try_repo_update_on_error: true)
end

android/fastlane/Fastfile

react_native_root = File.absolute_path('../../')

desc "Install dependencies"
lane :setup do
  Dir.chdir react_native_root do
    sh "yarn install && yarn run build"
  end
end

また次のような npm-scripts でシミュレータでの実行スクリプトを定義しておくと、開発者はリポジトリをクローンしてから yarn run ios:setupyarn run startyarn run ios:run:debug を実行するだけで iOS アプリをシミュレータ上で実行することができて便利です。(Android も同様)

{
    "scripts": {
        "ios:run:debug": "node node_modules/react-native/local-cli/cli.js run-ios --simulator 'iPhone SE'",
        "android:run:debug": "cd android && ./gradlew installStagingDebug",
        ⋮
    },
    ⋮
}

TypeScript 導入

開発当初は JavaScript のみでしたが途中で TypeScript を導入しました。元々の JavaScript コードと混在することになるため tsconfig.json では "allowJs": true を指定しています。React Native 周りのライブラリは型定義が充実していたため、TypeScript の恩恵を受けながら開発することができました。既存の React Native プロジェクトに TypeScript を導入した手順は morishin/ReactNativePractice の README.md にまとめたのでそちらをご参照ください。基本的には公式の Microsoft/TypeScript-React-Native-Starter に倣った手順を踏んでいます。

github.com

参考までに、「クックパッド MYキッチン」アプリの tsconfig.json は執筆時点ではこのようになっています。ソースコードは src ディレクトリに、tsc によりトランスパイルされたコードが lib ディレクトリに配置され、アプリは lib 以下のソースを読み込みます。

{
  "compilerOptions": {
    "target": "es2017",
    "module": "es2015",
    "allowJs": true,
    "jsx": "react-native",
    "sourceMap": true,
    "outDir": "./lib",
    "strict": true,
    "skipLibCheck": true,
    "moduleResolution": "node"
  },
  "include": ["./src/"]
}

tsc によるトランスパイルを自動化するために npm-scripts に build:watch を定義し、開発中はこれを実行した状態でソースコードを編集しています。

{
    "scripts": {
        "build": "tsc",
        "build:watch": "tsc --watch",
        ⋮
    },
    ⋮
}

フォーマッタ導入

複数人で開発する際にはフォーマッタがあると便利なので、Prettier を利用しています。フォーマットルールはほぼデフォルトのままです。エディタのプラグイン等で保存時に自動フォーマットがかかるようにしておくと便利かもしれません。

CodePush 導入

「クックパッド MYキッチン」ではサービスの高速な検証のために、バンドルの配信に Microsoft 製の CodePush という仕組みを利用しています。CodePush を利用すると React Native アプリの JS バンドルのみをユーザーの端末に配信することができ、App Store / Google Play Store でアップデートを配信することなくアプリを更新することができます。この特徴はとにかく高速に仮説を検証したいサービス開発者にとって魅力的で、React Native を採用する大きな理由のひとつになると思っています。

導入手順

CodePush の導入手順については公式の通りなので割愛します。

運用

CodePush は一つのプロジェクトに対して複数のデプロイ環境を作ることができ、「クックパッド MYキッチン」では Production, Production-test, Staging の3つの環境を用意しています。Production は App Store / Google Play Store で配信されている本番のアプリが JS バンドルを取得する環境、Production-test は社内のみで配信しているアプリが参照している環境、Staging はチーム内でデザインや動作を確認をするためのアプリが参照している環境です。Production-test アプリと Staging アプリの違いとして、前者は API サーバーや DB も本番環境を参照しているに対し、後者はバックエンドが本番環境とは切り離されているため、コンテンツの投稿・公開といった動作テストを行うことができるアプリになっています。それぞれの社内配信の方法とデプロイのタイミングについては CI の項で説明します。

画像の Beta 帯アイコンのものが Production-test 環境のアプリ、Staging 帯アイコンのものが Staging 環境のアプリ、無印が Production 環境のストア版アプリです。

f:id:morishin127:20180416150414p:plain

ちなみにこのアイコンの帯は fastlane-plugin-badge を用いて社内配信の CI ジョブ内で付加しています。

github.com

iOS/Android アプリから CodePush のデプロイ環境を読み分ける

Production, Production-test, Staging アプリで参照する CodePush の環境を分けていると述べましたが、iOS アプリでは Build Configuration 毎に CodePush の deployment key を切り替えることで、またAndroid アプリでは Build Type / Product Flavor 毎に deployment key を切り替えることでデプロイ環境を読み分けています。React Native アプリの Xcode プロジェクトに Build Configuration を追加するとビルドが通らなくなって苦戦したのでその解決の記録を貼っておきます。同じ問題に遭われた方の参考になれば幸いです。

qiita.com

CI 環境構築

CI マシンではプルリクエストを出したときに実行されるジョブと、プルリクエストを master にマージしたときに実行するジョブがあります。前者のジョブはアプリケーションのテストコードを実行しています。後者の master にマージしたときに実行されるジョブでは iOS/Android アプリのバイナリ生成と社内配信、CodePush の Production-test 環境への JS バンドルのデプロイを行っています。CodePush の Staging 環境へのデプロイは開発者が手元でビルドしたバンドルを手動でデプロイします。そうすることで master にマージする前のプルリクエスト段階のコードもチームメンバーの Staging アプリに配信することができ、デザイナとのコミュニケーションに有用です。実際にユーザーさんが触れる CodePush の Production 環境へのデプロイは Production-test 環境にデプロイされたバンドルを Production 環境へコピーする(promote)という形で行われます。promote の実行はチャットボットを介して Rundeck 上のデプロイジョブを実行することで行っています。アプリバイナリのデプロイと CodePush のデプロイの構造をそれぞれ図1, 図2にしました。

▼図1: アプリバイナリのデプロイ

f:id:morishin127:20180416154243p:plain

▼図2: CodePush のデプロイ

f:id:morishin127:20180416154227p:plain

ログ収集

クックパッドには複数のモバイルアプリで共通して利用しているログ収集基盤があり、「クックパッド MYキッチン」のユーザーのイベントログ等の情報もそこへ送っています。ログ収集基盤というのはクックパッドのデータ活用基盤 - クックパッド開発者ブログで触れられている基盤のことで、モバイルアプリは Logend と呼ばれる社内のログ送信用エンドポイントへログを送り、Logend は fluentd を介して Amazon S3 にログを蓄積しています。S3 に蓄積されたログは社内のデータウェアハウスにロードされ、開発者はそこで分析を行います。データ活用の基盤に関して詳しくは上述の記事をご覧ください。

アプリからログを送信するに当たってバッファリングやリトライの機構が必要になりますが、これまでのクックパッドのモバイルアプリでは Puree というライブラリがその機構を担っていました。Puree には iOS/Android 両方のライブラリがありましたが、React Native から利用できる JavaScript 版は存在しなかったため、「クックパッド MYキッチン」の開発に際して作られました。

github.com

Puree に関して詳しくは過去の記事をご覧ください。

おわりに

いかがでしたでしょうか。これから React Native でやっていこうとしているやっていき手の皆さんの参考になれば幸いです。明日は@101kazさんから React Native プロジェクトの Android 環境整備のお話です、お楽しみに!

クックパッドでは毎日の料理を楽しみにするために、より良い技術を選択し、より速くユーザーさんに価値を届けられるサービス開発エンジニアを募集しています。興味を持っていただけましたら是非気軽にご連絡ください。話をしてみたいけど応募はちょっとという方は@morishin127にDMしていただいても大丈夫です🙆

👋

/* */ @import "/css/theme/report/report.css"; /* */ /* */ body{ background-image: url('http://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527163350.png'); background-repeat: repeat-x; background-color:transparent; background-attachment: scroll; background-position: left top;} /* */ body{ border-top: 3px solid orange; color: #3c3c3c; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; line-height: 1.8; font-size: 16px; } a { text-decoration: underline; color: #693e1c; } a:hover { color: #80400e; text-decoration: underline; } .entry-title a{ color: rgb(176, 108, 28); cursor: auto; display: inline; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; font-size: 30px; font-weight: bold; height: auto; line-height: 40.5px; text-decoration: underline solid rgb(176, 108, 28); width: auto; line-height: 1.35; } .date a { color: #9b8b6c; font-size: 14px; text-decoration: none; font-weight: normal; } .urllist-title-link { font-size: 14px; } /* Recent Entries */ .recent-entries a{ color: #693e1c; } .recent-entries a:visited { color: #4d2200; text-decoration: none; } .hatena-module-recent-entries li { padding-bottom: 8px; border-bottom-width: 0px; } /*Widget*/ .hatena-module-body li { list-style-type: circle; } .hatena-module-body a{ text-decoration: none; } .hatena-module-body a:hover{ text-decoration: underline; } /* Widget name */ .hatena-module-title, .hatena-module-title a{ color: #b06c1c; margin-top: 20px; margin-bottom: 7px; } /* work frame*/ #container { width: 970px; text-align: center; margin: 0 auto; background: transparent; padding: 0 30px; } #wrapper { float: left; overflow: hidden; width: 660px; } #box2 { width: 240px; float: right; font-size: 14px; word-wrap: break-word; } /*#blog-title-inner{*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-position: left 0px;*/ /*}*/ /*.header-image-only #blog-title-inner {*/ /*background-repeat: no-repeat;*/ /*position: relative;*/ /*height: 200px;*/ /*display: none;*/ /*}*/ /*#blog-title {*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-image: url('http://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527172848.png');*/ /*background-repeat: no-repeat;*/ /*background-position: left 0px;*/ /*}*/