React Native開発を1年ぐらいやって得たことのまとめ（プロジェクトの始め方・追加でやっておいたほうが良いこと）

# React Nativeのプロジェクトを新しく始める

## TypeScriptテンプレートを利用する

- 新規で始めるのであれば、TypeScriptで始めるのが絶対に良い

https://github.com/react-native-community/react-native-template-typescript

```sh
npx react-native init MyApp --template react-native-template-typescript
```

## 単体テスト用のライブラリを入れる

- [jest](https://jestjs.io/ja/)と[enzyme](https://github.com/airbnb/enzyme)の組み合わせが一番無難だと思う
- とにかくユーザー数が多いライブラリを選択しておくとハマったときに困らないことが多い

## ナビゲーションライブラリを入れる

- [React Navigation](https://reactnavigation.org/)と[React Native Navigation](https://github.com/wix/react-native-navigation)の2つがメジャー
- こだわりが無ければReact Navigationを使う
- React Native Navigationを使うと、ナビゲーション部分がネイティブの仕組みに置き換わるので、アニメーションが滑らかになる。そのかわり、自由なナビゲーションがしづらくなる
    - また、React Native Navigationはドキュメントが貧弱


## UIライブラリを入れる

- React NativeのUIライブラリ（[NativeBase](https://nativebase.io/)、[React Native Elements](https://react-native-elements.github.io/react-native-elements/)、他）は、基本的にJSのみのライブラリで、React Nativeが提供している部品をラップしてるだけのものが多い
- NativeBaseが一番部品が多く、かつTypeScriptにもきっちり対応しているが、結局アプリで使う部品があるかないかの話なので、TypeScriptの型定義があるUIライブラリであれば何でも良いかも
    - [Shoutem UI](https://github.com/shoutem/ui)とかはデザインは良さそうだが、TypeScriptの型定義がない…
- iOS13のダークモードとかにデフォルトで対応してくれるUIライブラリが出てくれば、それに乗っかるのが一番楽になると思う

## 例外ハンドラを入れる

- [react-native-exception-handler](https://github.com/master-atul/react-native-exception-handler)を入れる
- 他にも色々やり方はあると思うが（ReactのcomponentDidCatchとか）、上記のライブラリならネイティブの例外もキャッチできるのでオススメ

## フォントサイズを考えておく

- アプリ開発で最終的に困るポイントが、フォントサイズである
- 開発時はスペックの良い（＝画面が広い）端末でずっと開発していたから、いざQAフェーズで小さい画面で確認するとレイアウトが崩れまくる、というのは大いに起こりうる

### OSのフォントスケーリングを無効にする

- OSのフォントスケーリングは厄介なので、ちゃんと対応する気力がないのであれば無視する設定にしておいたほうが無難だと思う
- index.ts(js)に下記のコードを書いておく

```ts
// OSのフォントスケーリングを無効化する //
Text.defaultProps = Text.defaultProps || {};
Text.defaultProps.allowFontScaling = false;
```

### レスポンシブライブラリを入れる

- [react-native-responsive-fontsize](https://github.com/heyman333/react-native-responsive-fontSize)のような画面サイズに応じてサイズ値を修正してくれるようなライブラリを入れる
- 大きい画面の端末・小さい画面の端末でも見え方がそこそこ同じになる

## 再起動用のライブラリを入れる

- React Nativeは、ネイティブ的な再起動ではなく、React Nativeの再起動を行うことができる（具体的にはJSバンドルをリロードする）
- [react-native-restart](https://github.com/avishayil/react-native-restart)を使うと、React Nativeの再起動ができる

## 余力があればやること

### Detoxの導入

アプリケーションを実際に動かしてテストを行うライブラリとして、[Detox](https://github.com/wix/Detox)がある。Detoxは実行しているマシン上で実際にシミュレーター(エミュレーター)を起動し、その中でアプリを実行する。

![action-sequence.mmd.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/213719/3a2f6d22-f260-7139-248e-d6fcc1c6821e.png)

引用元: [How Detox Works#architecture - Github wix/Detox](https://github.com/wix/Detox/blob/v12.7.0/docs/Introduction.HowDetoxWorks.md#architecture)

上のアーキテクチャの図はiOSのものだが、Detoxの構造がよくわかる。シミュレーター上では[EarlGrey](https://github.com/google/EarlGrey)というGoogleが開発したiOS用のUI自動テストフレームワークが実際には動いていて、Detoxはそれを中継して動かしている。Androidの方もだいたい同じ仕組みだと思われる。

この仕組みによって、DetoxはReact Native固有のライブラリではなく、汎用的にネイティブアプリのテストに使えるライブラリとなっている。

Detoxを導入するメリット・デメリットは下記の項目があると思う。

- メリット
    - iOSに関しては、CircleCI/BitriseでDetoxのテストを実行することが可能
    - 実際にアプリがシミュレーターできっちり想定通り動いているのを担保できるのは非常に安心できる
    - 特にReact Native自体が頻繁にアップグレードしていくので、Detoxで基本的なアプリ動作の確認をカバーできていると楽
- デメリット
    - Androidに関しては、ほぼすべてのSaaS CIでエミュレーターの動き(グラフィック周り)が制限されているので、SaaS CIで回すのは難しい
        - どうしてもやりたい場合は自前でマシンを用意して、Jenkinsなどで環境構築をする必要があると思う
        - Detoxの開発元であるwixはこれをやっている模様
    - テストが容易に失敗しうるので、安定させるまでの試行錯誤が非常に面倒くさい

Detoxのテストを作成する上で得たtipsについて記述していく。

#### Detoxで構築するアプリUIテストのtips

##### 1. 通信する部分は、[mockttp](https://github.com/httptoolkit/mockttp)でカバーする

mockttpはNode.jsでモックサーバーを立てるライブラリであり、Detoxのテストと混ぜてモックサーバーの設定を記述できるので、非常に相性が良い。リクエストの検査も簡単に行えるので、オススメである。

##### 2. テスト失敗時に動画を必ず撮影する

テスト実行の際、下記のように`--artifacts-location <任意のパス> --record-videos failing`をオプションとして付与することで、テスト失敗時の動画撮影を行ってくれる。

```sh
yarn detox test --artifacts-location /arbitrary_artifacts_path --record-videos failing -c ios
```

Detoxのテストがこけたときの出力を辛抱強く眺めるのもよいが、動画があると圧倒的に原因究明が楽なので、できる限りオプションを付けて実行することをオススメしたい。

##### 3. キーボードを消すことを意識する

テキスト入力がある画面で、キーボードが出た後にそれを消さないとボタンが押せない、というUIになっていることは多いと思う。そういう場合は、下記のように「確定」「完了」ボタンを押下（日本語キーボードの場合）、「Done」ボタンを押下（英語キーボードの場合）を実行するようにする。

```js
// 日本語キーボード
await element(by.label('確定')).atIndex(0).tap();
await element(by.label('完了')).atIndex(0).tap();
// 英語キーボード
await element(by.label('Done')).atIndex(0).tap();
```

##### 4. iOSでアラートビューのボタンを押す

アラートビューを押すときは、通常のUIのボタンを押すのと少しやり方が異なる。

```js
await element(by.label('押したいアラートビューのボタンのラベル').and(by.type('_UIAlertControllerActionView'))).tap();
```

##### 5. ある要素が見える前でスクロールしたい

縦に長い画面で、あるボタンが見える前でスクロールさせないとテストが失敗する、ということはよくある。下記のコードで、「見えるようになりたい要素」が見えるまで、「スクロール量」分画面をちょびっとずつスクロールさせる、ということが出来る。

```js
await waitFor(element(by.id('見えるようになりたい要素のID'))).toBeVisible().whileElement(by.id('スクロールしたい要素のID')).scroll(<スクロール量、数字>,<スクロール方向、'down' or 'up'>);
```

##### 6. アプリからSafariを開き、開いたことをテストしたいときは、**待つ**

アプリであるボタンをタップしたら、外部のURLを開かせたい、ということはよくあると思う。そういう場合、URLを動的に入れ替えられるようにしておき、mockttpでダミーのURLを用意しておいて、それを開かせることでテストを実行することが可能である。

ただ、アプリからSafariを開くと、実はアプリのテスト自体はそこで終わってしまう。なのでSafariを開いたことをmockttpのリクエストで検証したいときは必ず待つこと。10秒ぐらいまでば大丈夫だと思う。

##### 7. WebView内のアクションで遷移する画面はあきらめる

WebView内のボタンが押されたら次の画面、みたいなのは利用規約の表示などでよくあると思うが、Detoxにとっては天敵である。基本的に諦めるしかなく、[8](#8.-拡張子によるコンポーネントの入れ替え)で示す拡張子によるコンポーネントの入れ替えを行うことで、WebViewではなく、`componentDidMount`などで強制的に遷移させてしまうと良い。

##### 8. 拡張子によるコンポーネントの入れ替え

Detox、というかReact NativeはJSファイルのバンドル時に動的に読み込むべき拡張子を決定できる。

`metro.config.js`に下記の記述を足す。

```js
const defaultSourceExts = require('metro-config/src/defaults/defaults').sourceExts;
module.exports = {
...
  resolver: {
    sourceExts: process.env.RN_SRC_EXT ? process.env.RN_SRC_EXT.split(',').concat(defaultSourceExts) : defaultSourceExts
  },
...
};
```

その後、Detox実行前に環境変数`RN_SRC_EXT`を設定する。

```sh
set RN_SRC_EXT=detox.ts,detox.tsx
```

これにより、`Hoge.tsx`と`Hoge.detox.tsx`というファイルが同じフォルダに存在した場合、`Hoge.detox.tsx`が読み込まれるようになる。

この方法は非常に応用できて、先ほど書いたようにWebView内部のアクションで遷移させる画面であればそのコンポーネントをマルっと入れ替えることに使えるし、ユーザー認証を行っているコンポーネントを入れ替えることでユーザー認証部分をモックすることが出来るようになる。

##### 9. キャッシュのキー

CircleCIでビルドを繰り返し実行する場合、下記のパスなどをキャッシュさせると思う。

- `ios/build`
- `ios/Pods`
- `~/Library/Developer/Xcode/DerivedData`

キャッシュさせるときは、出来るだけ現在使用しているReact Nativeのバージョンをキャッシュのキーに含めたほうが良い。React Nativeをアップグレードしたときに謎のビルドエラーでCIがこける場合があった。

### Cocoapodsのバージョンを固定する

下記の内容のGemfileを用意する。

```ruby
source 'https://rubygems.org'
gem 'cocoapods', '= 1.7.5'
```

`package.json`に下記のスクリプトを追加する。

```
"pod-install": "bundle install --path vendor/bundle && cd ios && BUNDLE_GEMFILE=../Gemfile bundle exec pod install"
```

これで、`yarn pod-install`で必要なファイルが全部入るようになる。Cocoapodsをプロジェクト固有のバージョンで固定してしまえば、新しくメンバーが入ってきたときにPods周りでトラブルになることが少ない。

### 開発環境・本番環境を切り替えられるようにしておく

商用のアプリであれば、開発環境・ステージング環境・本番環境のように、複数の環境を用意していることが多い。その場合、アプリもそれぞれの環境に合わせて設定を変えてビルドを行いたいことがよくあると思う。

#### [React Native Config](https://github.com/luggit/react-native-config)を使う

このライブラリを使うと、下記のことができるようになる。

- 環境ごとに設定ファイルを切り替えることができる
- JS/ネイティブ側で同じ設定ファイルを参照することができる

#### Androidでの環境の切り替え方

Androidでは、productFlavorの機能を使う。

`app/build.gradle`に次のような記述を加える。

```gradle
// 冒頭部分
project.ext.envConfigFiles = [
        develop           : ".env",
        production        : ".env.production",
        staging           : ".env.staging",
        anothercustombuild: ".env" // 上記以外のproductFlavorはここにくる
]
...
apply from: project(':react-native-config').projectDir.getPath() + "/dotenv.gradle"
...
android {
...
    productFlavors {
        develop {
            dimension "default"
            applicationId "com.example.develop.app"
            resValue "string", "build_config_package", "com.example.app" // これをいれないとパッケージがうまく解決しない
        }
        staging {
            dimension "default"
            applicationId "com.example.staging.app"
            resValue "string", "build_config_package", "com.example.app" // これをいれないとパッケージがうまく解決しない
        }
        production {
            dimension "default"
        }
    }
...
}
...
```

ビルドを行うときは、productFlavorを指定してビルドするか、`set ENVFILE=.env.hogehoge`を実行し環境変数を設定してから実行すれば指定した環境設定ファイルが読み込まれる（ENVFILE環境変数は、build.gradleの設定よりも強い）。

#### iOSの環境の切り替え方

iOSのビルドは、**スキーム**と**ビルドコンフィギュレーション**の2つの設定がある。スキームはビルドコンフィギュレーションより大きい枠組みで、**スキームはどのビルドコンフィギュレーションを使うか選べる**。

ReactNativeConfigは、環境ごとにスキームを持たせる方法をREADMEに記載している。なので、その方法が一番適している。具体的には下記のイメージ。

- 開発 → `app_develop`スキームを使ってビルドする
    - `Build`の`pre-action`で、`echo ".env" > /tmp/envfile`を実行するようにする
- 本番 → `app_production`スキームを使ってビルドする
    - `Build`の`pre-action`で、`echo ".env.production" > /tmp/envfile`を実行するようにする
- ステージング → `app_staging`スキームを使ってビルドする
    - `Build`の`pre-action`で、`echo ".env.staging" > /tmp/envfile`を実行するようにする

ただし、ここで厄介なのが**iOSのアプリを識別するためのBundle Identifierはスキームに結びつかない**、ということ。**Bundle Identifierは先述したビルドコンフィギュレーションに結びつく**ので、そちらを使って切り替えるしかない。また、Xcodeは`Debug`/`Release`というビルドコンフィギュレーションをあらかじめ持っているので、それを使うのが好ましい。

- 開発 → `Debug`ビルドコンフィギュレーションで、`com.example.develop.app`Bundle Identifierを使用する
- 本番 → `Release`ビルドコンフィギュレーションで、`com.example.app`Bundle Identifierを使用する
- ステージング → `Staging`ビルドコンフィギュレーションで、`com.example.staging.app`Bundle Identifierを使用する

ここまで用意できれば、edit schemeから各スキーマで使うビルドコンフィギュレーションを選ぶことで、各環境ごとに環境設定ファイルを切り替え、Bundle Identifier(application id)を切り替えることが出来るようになる。

### タスク一覧からのIntentの処理を考えておく

- Androidでは、起動時のIntent処理問題がある
- **起動時のIntentは、タスク一覧にアプリが残った状態で、そこから起動するとアプリ起動のIntentとして再利用される**
- なので、例えば一時的な処理のためにCustom URL Schemeを使ってアプリを起動するが、起動時のIntentは一度だけ処理して欲しい、という場合に問題が起きる（一時的な処理の完了後に、再度タスク一覧からアプリを起動されると、もう一度一時的な処理が走る）
- それを回避するために、下記のように、Intentを処理するタイミングでタスク一覧から起動されたかを判定し、タスク一覧からの起動であれば処理しないようにすると良い

```java
public class UtilityModule extends ReactContextBaseJavaModule {
    public UtilityModule(@Nonnull ReactApplicationContext reactContext) {
        super(reactContext);
    }

    @ReactMethod
    public void launchedFromHistory(final Promise promise) {
        promise.resolve((getCurrentActivity().getIntent().getFlags() & Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY) != 0);
    }
}
```

```ts
const url = await Linking.getInitialURL();
if (url) {
  // Androidで、履歴から起動された場合は何もしない
  if (Platform.OS === 'android' && (await UtilityModule.launchedFromHistory())) {
    return;
  }

  await this.handleUrl({ url });
}
```