React Nativeハイブリッドアプリへの挑戦 ~Part1: Monorepo/CI~

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


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

数週間前にReact Native at Airbnb(非公式の日本語訳)が世間を賑わせましたが、皆様いかがお過ごしでしょうか。

弊社でもここ数ヶ月Nativeで書かれたスタディサプリのiOS/AndroidアプリにReact Nativeを部分的に導入していく、いわゆるハイブリッドアプリ開発体制に挑戦しており、そこで得られた知見を何回かに分けて公開していければと思います。

Goals

まず、なぜハイブリッドアプリという選択をしたのかについて、我々が目指していたゴールは以下の様なものです。

  • モバイルエンジニア不足の解消
    • Quipperは元々Webエンジニアの数が多い一方でモバイルエンジニアの人数は数人という状況が続いており、開発に関われる人数を増やしたかった
  • 開発とリリースの高速化
    • iOS/Androidアプリのビルドはコードの規模が拡大していくにあたってコンパイル時間が伸びる傾向にあり、Live Reload等の機能を使う事で開発を高速化したかった。また、業務アプリでのCodePush活用がうまくいったので高速なリリースを行えるのではないかと考えた
  • Webフロントエンドとの設計の統一、コードの共用
    • Quipperでは現在WebフロントエンドのReact/TypeScript化を進めており、ロジック層やReduxのモジュールを共通化できるのではないかと考えた

ちなみに、一気に書き直すのではなく部分的に導入を決めた理由は「書き直したい」 をグッと抑えて小さな改善を積み重ねようで触れられている通りにまずは小さく始める事で解決すべき課題を明確にしていきたいと考えた為です。

Monorepoへの移行

移行の第一歩として、まずはリポジトリを整理する所から始めました。

当初はJavaScriptのモジュールのみを管理するリポジトリを作り既存のiOS/AndroidリポジトリにGitの subtreeとして繋いで管理する方式を取っていたのですが、同期を取る難しさやビルド時のdependencyをどう管理するか等、様々な問題が発生し破綻したのですぐにMonorepoで管理する方向に切り替えました。

現在は以下の様なディレクトリ構成にリポジトリを統合しdependencyを管理しています。この辺は公式ドキュメントの通りに実装していけば特に詰まる事はないと思います。

├── ios # 既存のiOSリポジトリ
│   └── Podfile # root/node_modules/react-nativeを参照しRNのdependencyを管理している
├── android # 既存のAndroidリポジトリ
│   └── app
│     └── build.gradle # root/node_modules/react-nativeを参照しRNのdependencyを管理している
├── src # JS module
├── node_modules
│   └── react-native
│     ├── React.podspec # for iOS
│     └── react.gradle # for Android
└──  package.json

React Nativeとは直接関係ありませんがMonorepoにして副次的に良かった事の1つはiOS/Androidのメンバーがお互いの動きを把握しやすくなった事です。

iOS/Androidで同じ機能を実装する事は多いですが、そういう場合にロジックのレビューや議論をPull Request上でカジュアルに実施しやすくなったと感じています。

CI

リポジトリを統合する際に大きな問題となったのがContinuous Integrationです。iOS/Androidアプリのビルドフェーズを統合するのに加えてJavaScriptをビルドしアプリのバイナリに含めないといけない為(CodePushを使えば別ですが更に複雑になるので一旦後回しにしました)、workflowの複雑化やビルド時間の更なる長期化が予想された為です。

この問題を解決する為に、CircleCI 2.0のworkflowsを用いた処理の並列化、caching、それから各moduleに変更があった時のみテストを流すようにする事でビルド時間の短縮を図っています。

2018/07/02時点では以下の様なワークフローを採用しています。今の所全てのモジュールに変更を加えたとしてもCIの待ち時間は10~15分以内で済んでいます。

──react_native_dependencies─┐
                            ├── ios_test
                            ├── android_test
                            └── rn_test

react_native_dependencies後のios_testandroid_buildrn_testは並列で動かしています。簡単に各jobについて説明できればと思います。

react_native_dependencies

このJobは後続に続くフェーズで必要なJavaScript関係のmoduleのinstallやアプリ上で動くJSのbundleファイルを生成しています。

  • yarn install
  • Compile TypeScript
  • iOS/Android向けのjsbundle, metadataの生成
  • 成果物をartifactsに保存

また、以下の様にJavaScriptファイル(弊社ではTypeScriptを採用しています)からhash値を生成してCircleCIのcache keyに含める事でTypeScriptの変更がない場合はbundleの生成処理をskipしてcacheを使うようにしています。

#!/usr/bin/env bash

set -eu

create_md5() {
    if type "md5sum" >/dev/null 2>&1; then
        md5sum "$1" 2>/dev/null|awk '$0=$1'
    else
        md5 "$1"|awk '$0=$4'
    fi
}

cd "$(git rev-parse --show-toplevel)"

rm ts_hash.raw || true

while read ts_file; do
    create_md5 $ts_file >> ts_hash.raw
done < <(find src -name "*.ts")

while read ts_file; do
    create_md5 $ts_file >> ts_hash.raw
done < <(find src -name "*.tsx"|sort)

cat ts_hash.raw|sort > ts_hash

rn_test

単純にJS moduleのtestを実行しています。

  • yarn test

ios_test

Fastlaneを用いてiOS moduleのinstall、build、testを実行しています。

  • bundle install
  • bundle exec pod install
  • fastlaneを使ってbuild, test
  • IPAのdeploygateへのアップロード

android_test

Gradleを用いてAndroid moduleのcompile、testを実行しています。

  • ./gradlew assembly
  • ./gradlew test
  • apkのdeploygateへのアップロード

変更があった時のみテストを流す

キャッシュ戦略以外にビルド時間を短縮する試みとして、iOS関連のmoduleはios/Androidandroid/、JS関係はsrc/とフォルダが別れている為、それぞれのdirectory内に変更があった時のみunit testを流すようにしています。

仕組みとしては単純で、GitHub APIを叩いてPull Requestのbaseブランチを取得し、git diff "$CIRCLE_BRANCH" "origin/$(base_branch_name)" -- "ios|android\src")でgitのdiffを検出し差分が存在するようであればテストを実行しています。

#!/usr/bin/env bash

set -eu

base_branch_name() {
    (
        cd $(git rev-parse --show-toplevel)
        bundle check || bundle install
    ) >/dev/null

    (
        cd "$(git rev-parse --show-toplevel)/scripts/ci"
        bundle exec ruby base_branch_name.rb
    )
}

skip_execution() {
    echo "Skip $@"
    exit 0
}

if [[ "${REQUEST_DEPLOYGATE:-false}" == "true" ]]; then
    # for deploygate
    eval "$@"
elif [[ "$CIRCLE_BRANCH" == "master" || "$CIRCLE_BRANCH" =~ release/* ]]; then
    # for master and release branches
    eval "$@"
elif [[ -n "${CIRCLE_PULL_REQUEST:-}" ]]; then
    pushd $(git rev-parse --show-toplevel || echo ~/repo)
    if [[ -n $(command git diff "$CIRCLE_BRANCH" "origin/$(base_branch_name)" -- "$ROOT_SRC_DIR") ]]; then
        # otherwise, check whether or not platform directory was changed.
        popd
        eval "$@"
    else
        popd
        skip_execution "$@ as sources are not changed"
    fi
else
    skip_execution "$@ cuz a PR is not found"
fi

まとめ

今回はMonorepoへの移行やCI環境の構築について説明しました。

特にCI環境に関しては@jmatsuが大部分を実装しておりこの場を借りて感謝の意を表したいと思います。ありがとうございました。

次回はiOS/Androidのbridgeの実装や設計方針に関して説明できればと思います。


7/19と7/20に開催されるStudySapuri Product MeetupでもReact Nativeの話をします。更に突っ込んだ話が聞きたい方はぜひご応募下さい。

  1. StudySapuri Product Meetup #1 〜脱スタートアップフェーズにおける開発チームの現在と未来。少数精鋭の運用体制からデザイン・アーキテクチャまで~
  2. StudySapuri Data Meetup #1 〜未来の教育を創り出すデータ組織、全部お見せします!〜