React Nativeのプロジェクトを新しく始める
TypeScriptテンプレートを利用する
- 新規で始めるのであれば、TypeScriptで始めるのが絶対に良い
npx react-native init MyApp --template react-native-template-typescript
単体テスト用のライブラリを入れる
ナビゲーションライブラリを入れる
- React NavigationとReact Native Navigationの2つがメジャー
- こだわりが無ければReact Navigationを使う
- React Native Navigationを使うと、ナビゲーション部分がネイティブの仕組みに置き換わるので、アニメーションが滑らかになる。そのかわり、自由なナビゲーションがしづらくなる
- また、React Native Navigationはドキュメントが貧弱
UIライブラリを入れる
- React NativeのUIライブラリ(NativeBase、React Native Elements、他)は、基本的にJSのみのライブラリで、React Nativeが提供している部品をラップしてるだけのものが多い
- NativeBaseが一番部品が多く、かつTypeScriptにもきっちり対応しているが、結局アプリで使う部品があるかないかの話なので、TypeScriptの型定義があるUIライブラリであれば何でも良いかも
- Shoutem UIとかはデザインは良さそうだが、TypeScriptの型定義がない…
- iOS13のダークモードとかにデフォルトで対応してくれるUIライブラリが出てくれば、それに乗っかるのが一番楽になると思う
例外ハンドラを入れる
- react-native-exception-handlerを入れる
- 他にも色々やり方はあると思うが(ReactのcomponentDidCatchとか)、上記のライブラリならネイティブの例外もキャッチできるのでオススメ
フォントサイズを考えておく
- アプリ開発で最終的に困るポイントが、フォントサイズである
- 開発時はスペックの良い(=画面が広い)端末でずっと開発していたから、いざQAフェーズで小さい画面で確認するとレイアウトが崩れまくる、というのは大いに起こりうる
OSのフォントスケーリングを無効にする
- OSのフォントスケーリングは厄介なので、ちゃんと対応する気力がないのであれば無視する設定にしておいたほうが無難だと思う
- index.ts(js)に下記のコードを書いておく
// OSのフォントスケーリングを無効化する //
Text.defaultProps = Text.defaultProps || {};
Text.defaultProps.allowFontScaling = false;
レスポンシブライブラリを入れる
- react-native-responsive-fontsizeのような画面サイズに応じてサイズ値を修正してくれるようなライブラリを入れる
- 大きい画面の端末・小さい画面の端末でも見え方がそこそこ同じになる
再起動用のライブラリを入れる
- React Nativeは、ネイティブ的な再起動ではなく、React Nativeの再起動を行うことができる(具体的にはJSバンドルをリロードする)
- react-native-restartを使うと、React Nativeの再起動ができる
余力があればやること
Detoxの導入
アプリケーションを実際に動かしてテストを行うライブラリとして、Detoxがある。Detoxは実行しているマシン上で実際にシミュレーター(エミュレーター)を起動し、その中でアプリを実行する。
引用元: How Detox Works#architecture - Github wix/Detox
上のアーキテクチャの図はiOSのものだが、Detoxの構造がよくわかる。シミュレーター上では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はこれをやっている模様
- テストが容易に失敗しうるので、安定させるまでの試行錯誤が非常に面倒くさい
- Androidに関しては、ほぼすべてのSaaS CIでエミュレーターの動き(グラフィック周り)が制限されているので、SaaS CIで回すのは難しい
Detoxのテストを作成する上で得たtipsについて記述していく。
Detoxで構築するアプリUIテストのtips
1. 通信する部分は、mockttpでカバーする
mockttpはNode.jsでモックサーバーを立てるライブラリであり、Detoxのテストと混ぜてモックサーバーの設定を記述できるので、非常に相性が良い。リクエストの検査も簡単に行えるので、オススメである。
2. テスト失敗時に動画を必ず撮影する
テスト実行の際、下記のように--artifacts-location <任意のパス> --record-videos failing
をオプションとして付与することで、テスト失敗時の動画撮影を行ってくれる。
yarn detox test --artifacts-location /arbitrary_artifacts_path --record-videos failing -c ios
Detoxのテストがこけたときの出力を辛抱強く眺めるのもよいが、動画があると圧倒的に原因究明が楽なので、できる限りオプションを付けて実行することをオススメしたい。
3. キーボードを消すことを意識する
テキスト入力がある画面で、キーボードが出た後にそれを消さないとボタンが押せない、というUIになっていることは多いと思う。そういう場合は、下記のように「確定」「完了」ボタンを押下(日本語キーボードの場合)、「Done」ボタンを押下(英語キーボードの場合)を実行するようにする。
// 日本語キーボード
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のボタンを押すのと少しやり方が異なる。
await element(by.label('押したいアラートビューのボタンのラベル').and(by.type('_UIAlertControllerActionView'))).tap();
5. ある要素が見える前でスクロールしたい
縦に長い画面で、あるボタンが見える前でスクロールさせないとテストが失敗する、ということはよくある。下記のコードで、「見えるようになりたい要素」が見えるまで、「スクロール量」分画面をちょびっとずつスクロールさせる、ということが出来る。
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で示す拡張子によるコンポーネントの入れ替えを行うことで、WebViewではなく、componentDidMount
などで強制的に遷移させてしまうと良い。
8. 拡張子によるコンポーネントの入れ替え
Detox、というかReact NativeはJSファイルのバンドル時に動的に読み込むべき拡張子を決定できる。
metro.config.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
を設定する。
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を用意する。
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を使う
このライブラリを使うと、下記のことができるようになる。
- 環境ごとに設定ファイルを切り替えることができる
- JS/ネイティブ側で同じ設定ファイルを参照することができる
Androidでの環境の切り替え方
Androidでは、productFlavorの機能を使う。
app/build.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を処理するタイミングでタスク一覧から起動されたかを判定し、タスク一覧からの起動であれば処理しないようにすると良い
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);
}
}
const url = await Linking.getInitialURL();
if (url) {
// Androidで、履歴から起動された場合は何もしない
if (Platform.OS === 'android' && (await UtilityModule.launchedFromHistory())) {
return;
}
await this.handleUrl({ url });
}