tomoima525's blog

Androidとか技術とかその他気になったことを書いているブログ。世界の秘密はカレーの中にある!サンフランシスコから発信中。

React NativeでiOSアプリをAndroidアプリに移植するときに気をつけること

f:id:tomoima525:20200713135718p:plain

この記事は英語で書いたこちらの記事 をDeepLで日本語訳し、加筆修正したものです。

数ヶ月前、自社サービス*1Android版アプリを開発/リリースしました。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を使ったフィルターやスティッカーと読んでいるイメージアセットを写真やビデオに乗せてを加工できるエディター

f:id:tomoima525:20200713132322p:plain:w300
OpenGLによるカメラフィルター

f:id:tomoima525:20200713132332p:plain:w300
画像や動画に重畳するスティッカー
このような要件を満たすオープンソースライブラリがなかったので、ゼロからネイティブモジュールを構築しました。さらに、ステッカー用のアセットマネージャーも構築しなければなりませんでした。これは結構大変な作業で、Camera2 APIOpenGL、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のデザインの差異
iOSAndroidは異なるインターフェースとデザイン原則を持っています。 iOSアプリがiOSスタイル(Cupatinoデザイン)を踏襲している場合、AndroidアプリにはAndroid向けのスタイルやビューコンポーネントを適用することを検討する必要があります。

一度Material Design Guidelinesを読んで、ガイドラインに沿っていない部分は修正することをお勧めします。例えば、レイアウトのmarginを8の倍数など適切に設定することなどです。

幸いなことに、React NativeはButtonやその他のデフォルトコンポーネントについて、Material designをサポートしています。だからこそ、カスタムUIコンポーネントやライブラリキットを使わないようにして、のちのちメンテナンスが大変になる事態はさけるべきです。

スタイリング動作の差分
React Native のスタイリングは CSS と似たような動作をしますが、レンダリング方法はプラットフォームによって異なることが多いです。例えば、Androidの場合、heightstyleで明示的に設定する必要があります。これが原因でコンポーネントが表示されないことが何度かありました。
また、Androidではサポートされていないプロパティもあるので、期待通りにレンダリングされているかどうか、Androidアプリの挙動をよくチェックする必要があります。

プッシュ通知の設定

プッシュ通知はReact NativeがAndroidではそのままでサポートしていないものの一つです。

プッシュ通知を送信するためのライブラリはいくつかあります。今回は wix/react-native-notifications を選択しました。wix/react-native-notificationsの注意点としては、バージョン3.xでHermes JS Engineを有効にしている場合、wix/react-native-notificationsHermesでは現在(2020/02現在)サポートされていないProxyオブジェクトを利用しているため、ちょっとしたワークアラウンドが必要になることがあることです。詳細は以下のリンクを参照してください。

https://github.com/wix/react-native-notifications/issues/455

また、iOSAndroidではプッシュ通知のペイロードアーキテクチャが異なることにも触れておきます。
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スレッドにも影響を与えてしまっているようです。
今後、リクエストを分散させたり、重い処理をネイティブ側に寄せることで、パフォーマンス改善にも取り組んで行く予定です。

play.google.com

*1:Chompという友達と外食を楽しむソーシャルアプリです