この記事は英語で書いたこちらの記事 をDeepLで日本語訳し、加筆修正したものです。
数ヶ月前、自社サービス*1のAndroid版アプリを開発/リリースしました。iOS版は2年ほど前にリリースされており、React Nativeで実装されているため、多くのコードがそのままで動きます。しかし、ビルド設定やデバイス固有のNativeコードについては、気をつけなければならない部分がいくつもあります。
この記事では、既存のReact Native iOSアプリからAndroidアプリを移植するために自分が取り組んだことを紹介します。
依存ライブラリを更新し、Gradleの設定を整える
まずは、アプリがクラッシュもエラーもなくビルドできるか検証するところから始まります。React NativeのバージョンO.60.0
では、AndroidサポートライブラリのメジャーアップデートであるAndroidXに関連した不具合が発生しています。
https://developer.android.com/jetpack/androidx
基本的にすべてのAndroidサポートライブラリはAndroidXに置き換える必要があります。ReactNativeコミュニティの尽力により、0.60.0
以上のReact Nativeにバンドルされているjetifier
というツールが、サードパーティ製のライブラリ内の名前空間android.support.*
を更新してくれるようになりました。
Android Support ライブラリの依存関係に関連した問題を避けるために、React Native のバージョンを 0.60.0
以降に更新し、Android プロジェクトに取り組む前にできるだけサードパーティのライブラリを更新することをお勧めします。
ビルドにおけるもう一つの課題はGoogleライブラリの依存関係です。サードパーティライブラリの中には、build.gradle
の中にGoogle Play Serviceのバージョンがハードコーディングされているものがあり、それがバージョンコンフリクトのビルドエラーの原因となっていました。
この問題に対処するために、post installのスクリプトを書いて、yarn install後にサードパーティのbuild.gradle
をオーバーライドするようにしています。
// postInstall.sh # Workaround for react-native-geolocation-service echo 'Override gradle file to play-services-locations version' cp -p scripts/react-native-geolocation-service/build.gradle node_modules/react-native-geolocation-service/android/build.gradle
// package.json { ... "scripts": { "bootstrap": "yarn install && yarn run post-install" "post-install": "./scripts/postInstall.sh", ... } }
それともうひとつ。Hermes JS Engineは必ず有効化しましょう。アプリの起動スピードを改善できる簡単なTipsです。
project.ext.react = [ entryFile: "index.android.js", enableHermes: true, // clean and rebuild if changing ]
カスタムネイティブモジュールを実装する
これは、自分たちのプロジェクトの中で最も時間のかかった部分でした。自分たちのアプリは2つのコア機能があります。
- ユーザーインタフェースが柔軟に変更可能な写真/ビデオレコーダー
- OpenGLを使ったフィルターやスティッカーと読んでいるイメージアセットを写真やビデオに乗せてを加工できるエディター
このような要件を満たすオープンソースライブラリがなかったので、ゼロからネイティブモジュールを構築しました。さらに、ステッカー用のアセットマネージャーも構築しなければなりませんでした。これは結構大変な作業で、Camera2 APIやOpenGL、DiskLruCacheなど、Nativeアプリを作っていても普段は触れないような低レイヤのAPIを扱わなければなりませんでした。
開発者としてはとてもやりがいのある刺激的な作業でしたが、リリーススケジュールを短縮したいのであれば、できるだけコミュニティがバックアップしているライブラリを利用することを強くお勧めします。
Android向けアセット管理
ほとんどのアセット(アイコンや静止画像など)はReact Native上で管理できますが、ネイティブ側で管理が必要なアセットがいくつかあります。
ランチャーアイコン
ランチャーアイコンはandroid/app/src/main/res/mipmap-{hdpi,xhdpi,xxhdpi,xxxhdpi}
フォルダの下に置きます。もしcreate-react-native-app
を使用している場合、デフォルトでは React Native がそれを行ってくれます。デバッグアプリのアイコンを変更したい場合は、android/app/src/debug/res/mipmap-{hdpi,xhdpi,xxhdpi,xxxhdpi}
に別のアイコンを配置します。プッシュ通知アイコン
アプリがプッシュ通知を受信した場合、その通知用のアイコンを設定したいです。そのアイコンは透過PNGにします。ランチャーアイコンの下の同じフォルダに配置します。そして、AndroidManifest.xml
の中の<application>
の中に以下のコードを追加します。
<application ... android:icon="@mipmap/ic_launch"> // App icon <--! https://goo.gl/l4GJaQ --> <meta-data android:name="com.google.firebase.messaging.default_notification_icon" android:resource="@mipmap/ic_stat_ic_notification" /> // Push Notification Icon
- カスタムフォント
自分たちが開発しているアプリには、ユーザーが写真に追加できるステッカーがあります。
それぞれのステッカーには異なるフォントがあり、ステッカーは不定期に追加されたり更新されたりします。そこで、必要なフォントをGoogle Fonts
からダウンロードして、React NativeとAndroidの両方にプログラム的に渡すことができるモジュールを作りました。一部を抜粋すると以下のようなコードです。
val fontTypefaces = HashMap<String, Typeface>() fun downloadFonts(fontName: String, font: GoogleFonts) { val handlerThread = HandlerThread("fonts") handlerThread.start() val handler = Handler(handlerThread.looper) val builder = QueryBuilder(font.family) if(font.italic == 1) { builder.italic() } if(font.weight > 0) { builder.weight(font.weight) } val request = FontRequest( "com.google.android.gms.fonts", "com.google.android.gms", builder.build(), R.array.com_google_android_gms_fonts_certs) val callback = object : FontsContractCompat.FontRequestCallback() { override fun onTypefaceRetrieved(typeface: Typeface) { ReactFontManager.getInstance().setTypeface(fontName, typeface.style, typeface) fontTypefaces[fontName] = typeface } override fun onTypefaceRequestFailed(reason: Int) { } } FontsContractCompat .requestFont(context, request, callback, handler) }
これによりフォントは ReactFontManager
を使ってReact Nativeにロードできます。
ReactFontManager.getInstance().setTypeface()
を呼び出すと、React Nativeからそのフォントにアクセスすることができます。
const styles = { robotoText: { fontFamily: 'Roboto', }, };
UI コンポーネント
コードはほとんどのUIコンポーネントで再利用可能でした。React Nativeはレイアウト解像度にAndroidと同じPixel Densityを使っているので、両プラットフォーム間のスタイルのズレを心配する必要はほとんどありませんでした。
レイアウトの解像度については以下のドキュメントに詳しいです。
http://reactnative.dev/docs/height-and-width.html#fixed-dimensions
https://reactnative.dev/docs/pixelratio
ただし、iOSアプリをAndroidアプリに変換する際には、2つの点に注意した方がいいかもしれません。
iOSのデザインとAndroidのデザインの差異
iOSとAndroidは異なるインターフェースとデザイン原則を持っています。
iOSアプリがiOSスタイル(Cupatinoデザイン)を踏襲している場合、AndroidアプリにはAndroid向けのスタイルやビューコンポーネントを適用することを検討する必要があります。
一度Material Design Guidelinesを読んで、ガイドラインに沿っていない部分は修正することをお勧めします。例えば、レイアウトのmarginを8の倍数など適切に設定することなどです。
幸いなことに、React NativeはButtonやその他のデフォルトコンポーネントについて、Material designをサポートしています。だからこそ、カスタムUIコンポーネントやライブラリキットを使わないようにして、のちのちメンテナンスが大変になる事態はさけるべきです。
スタイリング動作の差分
React Native のスタイリングは CSS と似たような動作をしますが、レンダリング方法はプラットフォームによって異なることが多いです。例えば、Androidの場合、height
はstyle
で明示的に設定する必要があります。これが原因でコンポーネントが表示されないことが何度かありました。
また、Androidではサポートされていないプロパティもあるので、期待通りにレンダリングされているかどうか、Androidアプリの挙動をよくチェックする必要があります。
プッシュ通知の設定
プッシュ通知はReact NativeがAndroidではそのままでサポートしていないものの一つです。
プッシュ通知を送信するためのライブラリはいくつかあります。今回は wix/react-native-notifications
を選択しました。wix/react-native-notifications
の注意点としては、バージョン3.xでHermes
JS Engineを有効にしている場合、wix/react-native-notifications
はHermes
では現在(2020/02現在)サポートされていないProxy
オブジェクトを利用しているため、ちょっとしたワークアラウンドが必要になることがあることです。詳細は以下のリンクを参照してください。
https://github.com/wix/react-native-notifications/issues/455
また、iOSとAndroidではプッシュ通知のペイロードのアーキテクチャが異なることにも触れておきます。
iOSでは、Push通知のペイロードは以下のようになります。
// https://developer.apple.com/library/archive/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/CreatingtheNotificationPayload.html { "aps" : { "alert" : { "title" : "Game Request", "body" : "Bob wants to play poker", "action-loc-key" : "PLAY" }, "badge" : 5 }, "acme1" : "bar", "acme2" : [ "bang", "whiz" ] }
一方Androidでは以下のようになります。
// https://firebase.google.com/docs/cloud-messaging/android/send-image { "message":{ "token" : "bk3RNwTe3H0:CI2k_HHwgIpoDKCIZvvDMExUdFQ3P1...", "android": { "notification": { "body" : "This is an FCM notification that displays an image.!", "title" : "FCM Notification", "image": "url-to-image" } } }
このような差異があるので、慎重にテストして、アプリがきちんと通知を受信できるかどうかを確認する必要があります。
アプリを公開する
自分たちのプロジェクトではCircle CI上で動作するFastlaneでiOS向けの配信を自動化しています。Androidでも同じツールを使いました。
アプリのビルド
ここでは、非デバッグのアプリをビルドする手順を説明します。
1.アプリ認証用のキーファイル設定
非デバッグビルドではアプリの署名鍵が必要です 。これを安全に保存する必要があります。CIで利用できるように、keystoreの設定ファイルと鍵を圧縮してFirebase Storageにアップロードし、必要に応じて復号化するようにしています。
- 圧縮と暗号化
tar -cvf keystores.tar keystores/ | openssl enc -aes-256-cbc -md sha256 -in keystores.tar -k ENCRYPT_SECRET_KEY -out keystores.enc
Firebase Storageへのアップロード
Fastlane上でtarファイルの解凍を行います。
PROJECT_DIR = './app/android/'.freeze private_lane :setup_keystore do curlScript = "curl -H \"Accept: application/octet-stream\" \"https://firebasestorage.googleapis.com/v0/b/xxx.appspot.com/o/keys%2Fkeystores.enc?alt=media&token=xxx\" -o ../#{PROJECT_DIR}keystores.enc" sh(curlScript) decryptScript = "openssl aes-256-cbc -d -md sha256 -in ../#{PROJECT_DIR}keystores.enc -$ENCRYPT_SECRET_KEY -out ../#{PROJECT_DIR}keystores.tar" sh(decryptScript) untarScript = "tar -xf ../#{PROJECT_DIR}keystores.tar -C ../#{PROJECT_DIR}" sh(untarScript) end
CircleCI上で、ENCRYPT_SECRET_KEY
を設定する必要があります。
2. ビルドバージョン番号の設定
PlayStoreやFirebase App Distributionにアプリを配布するためには、ビルドバージョン番号が一意である必要があります。これにはCircle CIのビルド番号を使用します。
private_lane :update_version_code do script = "sed -i -e \"s/versionCode [0-9]*/versionCode $CIRCLE_BUILD_NUM/\" ../#{PROJECT_DIR}app/build.gradle" sh(script) end
3.アプリのビルド
自分たちのアプリにはステージング向けと本番向けがあります。Fastlaneはアプリをビルドするためのコマンドを提供していますが、Circle CIではメモリ不足の問題にぶつかるので、コマンドラインを使っています。
また合わせて--no-daemon --max-workers=2
という2つのオプションをコマンドに追加しています。デーモンは中間ファイルをキャッシュするのに便利ですが、CIでは必要ないため設定を外します。また、ワーカーの数を制限することでメモリ使用量を減らすことができます。
本番環境では App Bundle
を使ってビルドしているので、アプリのサイズは30%ほど小さくなっています。
一方、ステージング向けアプリでは Apk
を使用しています。 Firebase App Distribution がApp Bundleをサポートしていないためです。
lane :staging do setup_keystore update_version_code script = "cd ../#{PROJECT_DIR} && ./gradlew assembleReleaseStaging --no-daemon --max-workers=2".dup sh(script) end lane :production do setup_keystore update_version_code script = "cd ../#{PROJECT_DIR} && ./gradlew bundleRelease --no-daemon --max-workers=2".dup sh(script) end
ここで一点注意すべきなのが、Facebook SDKのログイン機能を使っているケースです。Facebook SDKでは設定時にkeystoreから生成されるkey hash(SHA1)をFacebook Developer Consoleにて設定する必要がありますが、ApkとApp Bundleでこの値は異なります。
App Bundleを利用している場合は PlayStoreの App Signing に登録されている SHA1を利用する必要があります。
https://github.com/magus/react-native-facebook-login/issues/297#issuecomment-652188405
アプリの配布
Googleの公式ドキュメントに、アプリを公開するための一般的な手法が掲載されています。
https://developer.android.com/studio/publish
ここでは、継続的インテグレーションでAndroidアプリを配信するためのFastlaneのレシピを紹介します。FastlaneはFirebase App DistributionとPlayStoreの両方にアプリを公開するための便利なコマンドを提供しています。
Firebase App Distribution
ステップはこちら: https://firebase.google.com/docs/app-distribution/android/distribute-fastlane
APP IDとトークンはFirebase Web Consoleで確認できます。
FIREBASE_CLI_PATH = './node_modules/.bin/firebase'.freeze lane :distibute_to_firebase do firebase_app_distribution( app: "1:xxx:android:xxx", # APP ID taken from Firebase App Distribution Web Console testers: "tomoima525@gmail.com", release_notes: "Build #{ENV['CIRCLE_BUILD_NUM']}", firebase_cli_path: FIREBASE_CLI_PATH, firebase_cli_token: ENV['FIREBASE_TOKEN'], apk_path: "#{PROJECT_DIR}app/build/outputs/apk/releaseStaging/app-universal-releaseStaging.apk" ) end
PlayStore
CIからPlayStoreでアプリを配信するには、Googleサービスアカウントの設定が必要です。
https://docs.fastlane.tools/actions/upload_to_play_store/#setup
自分たちの場合は、本番に出る前に Internal Test でアプリを配布したかったので、以下のように設定しました。
lane :distibute_to_playstore do setup_keystore setup_google_service_account upload_to_play_store( package_name: 'com.your.app', release_status: 'draft', track: 'internal', json_key: "#{PROJECT_DIR}/keystores/api-xxx.json", aab: "#{PROJECT_DIR}app/build/outputs/bundle/release/app-release.aab" ) end # Check the validation of Google service account private_lane :setup_google_service_account do validate_play_store_json_key(json_key: "#{PROJECT_DIR}/keystores/api-xxx.json") end
本番リリース時は PlayStoreからInternal Test から Production Release に昇格させています。
まとめ
ということで、React NativeのiOSアプリをAndroidアプリに移植するまでの道のりを説明しました。
自分たちの場合、Androidアプリを本番稼働させるまでに約2.5ヶ月かかりました。デバイス固有のコードについては、Android開発者を短期的に採用し、彼と自分で1.5ヶ月間、ネイティブモジュールの開発に従事しました。もしアプリが完全にJavaScriptで書かれていれば、移植には1ヶ月もかからなかったのではないでしょうか。
もしあなたのチームにAndroid開発の経験があるメンバーがいないのであれば、独自のNativeモジュールを持たないことをお勧めします。
リリース後3ヶ月がたった現時点での課題としては、数年前に販売された端末やロースペック端末で起動時間やUIパフォーマンスがかなり低下してしまう点です。
JavaScriptはシングルスレッドであるがゆえに、複数のリクエストや重い処理をReact Native側で実行すると処理が滞り、呼び出しているUIスレッドにも影響を与えてしまっているようです。
今後、リクエストを分散させたり、重い処理をネイティブ側に寄せることで、パフォーマンス改善にも取り組んで行く予定です。
*1:Chompという友達と外食を楽しむソーシャルアプリです