🌆

Expo managed プロジェクトの画像をネイティブプロジェクトに追加する

2022/10/10に公開

(経緯) expo-update の assets のファイル数に上限に引っかかった

Expo で実装したアプリに expo-update を追加して OTA アップデートを試そうとしたところエラーが発生してしまいました。

$ eas update --branch preview --message 'TEST UPDATE'

✖ Failed to upload assets
    CombinedError: [GraphQL] EAS Update supports up to 700 assets per update for a given platform. 
    When publishing a group of updates for multiple platforms (up to 2 platforms), there thus may 
    be at most 1400 assets in total across the update group. However, the update group you are 
    publishing has 3701 assets. Reduce the number of assets and try again.

(機械翻訳)
✖ アセットアップロードに失敗しました
    CombinedErrorです。[GraphQL] EAS Update は、指定されたプラットフォームのアップデートごとに最大 700 のアセットをサポートします。
    複数のプラットフォーム(最大2つのプラットフォーム)向けのアップデートグループを公開する場合、アップデートグループ全体で最大1400のアセットが存在する可能性があります。
    は、アップデートグループ全体で最大1400アセットとなります。しかし、今回公開するアップデート グループには 
    には3701個のアセットがあります。アセット数を減らして、もう一度試してみてください。

すみません。何も考えずに 3701 個の画像をアップしようとしてすみません。

画像を大量に組み込んでいたところファイル数の上限に引っかかってしまったようです。

公式のサイトをざっくり読んでいる感じ、現状(Expo46)「圧縮を頑張る」くらいの解決案しか見つけられませんでした。

https://docs.expo.dev/eas-update/optimize-assets/

今回は画像自体頻繁にアップデートが入る予定はないものだったため、ネイティブ側に直接画像を持たせることで、Bundle 側に画像ファイルが含まれない様にしたいと思います。

Build lifecycle hooks で画像ファイルを直接ネイティブプロジェクトに追加する

React Native CLI や Expo bare workflow プロジェクトであれば直接 Android、iOS それぞれのプロジェクト配下に組み込みたいファイルを直接追加できますが、Expo managed プロジェクトの場合はこれといったやり方が見つけられませんでした。

そこで Build lifecycle hooks を使用すると eas build 中に処理を差し込むことができる様だったので、今回はこれを使用して自前でファイルを追加する処理を追加していきます。

https://docs.expo.dev/build-reference/npm-hooks/

prebuild 後に呼び出される eas-build-post-install を使用して画像ファイルをネイティブプロジェクト内にコピーします。

ファイル階層
project
  ├ assets
  │   └ embedded ・・・ 組み込みたいファイルの置き場所の例。場所は変更可能
  ├ assetImageCopy.sh ・・・ 画像コピースクリプト
  └ package.json ・・・ hook を追加

スクリプトの引数に組み込みたい画像置き場のパスを渡します。

package.json
{
  "scripts": {
    "eas-build-post-install": "bash assetImageCopy.sh assets/embedded"
  }
}
assetImageCopy.sh
#!/bin/bash

# ネイティブプロジェクトにコピーする画像が置いてあるパス
ASSETS_IMAGE_PATH=$1
# ネイティブプロジェクトにコピーする際に"_"に置き換える文字
DISALLOW_CHARACTOR="\/-"

echo 'run assetImageCopy'

# ファイルパスからassetsパスを削除して、"_"区切りのファイル名に変換する
convertFileName() {
    echo ${1##${ASSETS_IMAGE_PATH}} | sed -e "s/^\///" | sed -e "s/[${DISALLOW_CHARACTOR}]/_/g"
}

if [[ -d "android" ]]; then
    # 念の為パスを通しておく
    mkdir -p android/app/src/main/res/drawable
    mkdir -p android/app/src/main/res/drawable-xhdpi
    mkdir -p android/app/src/main/res/drawable-xxhdpi
    # ファイルをコピー
    for file in $(find $ASSETS_IMAGE_PATH -type f -name "*.png" -not -name "*@2x.*" -not -name "*@3x.*"); do
        filename=$(convertFileName $file)
        cp -n "$file" "android/app/src/main/res/drawable/$filename"

        if [[ -f "${file%.png}@2x.png" ]]; then
            cp -n "${file%.png}@2x.png" "android/app/src/main/res/drawable-xhdpi/$filename"
        fi
        if [[ -f "${file%.png}@3x.png" ]]; then
            cp -n "${file%.png}@2x.png" "android/app/src/main/res/drawable-xxhdpi/$filename"
        fi
    done
fi

if [[ -d "ios" ]]; then
    SCHEME_NAME=$(find ios -maxdepth 1 -name "*.xcworkspace" | xargs basename -s.xcworkspace)
    for file in $(find $ASSETS_IMAGE_PATH -type f -name "*.png" -not -name "*@2x.*" -not -name "*@3x.*"); do
        filename=$(convertFileName $file)
        # アセットを作成
        assetpath="ios/${SCHEME_NAME}/Images.xcassets/${filename%*.png}.imageset"
        mkdir -p $assetpath
        # ファイルをコピー
        cp -n "$file" "$assetpath/$filename"
        if [[ -f "${file%.png}@2x.png" ]]; then
            cp -n "${file%.png}@2x.png" "$assetpath/${filename%.png}@2x.png"
            filename2x=${filename%.png}@2x.png
        fi
        if [[ -f "${file%.png}@3x.png" ]]; then
            cp -n "${file%.png}@3x.png" "$assetpath/${filename%.png}@3x.png"
            filename3x=${filename%.png}@3x.png
        fi
        # Contents.jsonを作成
        echo "{\"images\":[{\"filename\":\"${filename}\",\"idiom\":\"universal\",\"scale\":\"1x\"},{\"filename\":\"${filename2x}\",\"idiom\":\"universal\",\"scale\":\"2x\"},{\"filename\":\"${filename3x}\",\"idiom\":\"universal\",\"scale\":\"3x\"}],\"info\":{\"author\":\"xcode\",\"version\":1}}" > $assetpath/Contents.json
    done
fi

ざっくり作成したスクリプトのため以下の前提条件があります。

  • スクリプトはプロジェクトのルートディレクトリに置く
  • 画像の拡張子は png
  • ファイル名は標準サイズの画像が xxxx.png であれば、サイズ違いは xxxx@2x.png xxxx@3x.png という名称で xxxx.png と同じディレクトリに置く
    • サイズ違いは用意しなくてもよい
  • サブディレクトリの画像は、パスを _ 区切でファイル名のプレフィックスとして追加
  • それ以外のファイル名は Android、iOS のネイティブプロジェクトのルールに従う必要がある。
    • Android の場合、ファイル名が Java の変数名として使用されるため、Java の変数名のルールに従っていないものはビルド時にエラーになる(ファイル名の先頭が数字の場合エラー、等)
    • スクリプトでは /- だけは _ に置き換える

動作イメージ

以下の様な階層にファイルを置いた場合

ファイル階層
project
  └ assets
     └ embedded
           ├ background.png
           ├ background@2x.png
           ├ background@3x.png
           ├ background-blank.png
           └ icon
              ├ user.png
              ├ user@2x.png
              ├ user@3x.png
              └ en
                 ├ user.png
                 ├ user@2x.png
                 └ user@3x.png

ビルドは特に追加オプションなし

# ビルド例
$ eas build --profile development --platform android
$ eas build --profile preview --platform ios

ビルド時以下の様に画像がコピーされます。

ファイル階層
project
  ├ android/app/src/main/res
  │    ├ drawable
  │    │     ├ background.png
  │    │     ├ background_blank.png
  │    │     ├ icon_user.png
  │    │     └ icon_en_user.png
  │    ├ drawable-xhdpi
  │    │     ├ background.png
  │    │     ├ icon_user.png
  │    │     └ icon_en_user.png
  │    └ drawable-xxhdpi
  │          ├ background.png
  │          ├ icon_user.png
  │          └ icon_en_user.png
  │
  └ ios/PROJECT_NAME/Images.xcassets
       ├ background.imageset
       │     ├ Contents.json
       │     ├ background.png
       │     ├ background@2x.png
       │     └ background@3x.png
       ├ background_blank.imageset
       │     ├ Contents.json
       │     └ background_blank.png
       ├ icon_user.imageset
       │     ├ Contents.json
       │     ├ icon_user.png
       │     ├ icon_user@2x.png
       │     └ icon_user@3x.png
       └ icon_en_user.imageset
             ├ Contents.json
             ├ icon_en_user.png
             ├ icon_en_user@2x.png
             └ icon_en_user@3x.png

これでアプリに画像が組み込まれるため Image タグの source の書き方も require ではなく uri で参照します。
require とは異なりサイズ指定が必須になります。

- <Image source={require('./assets/embedded/background.png')} />
- <Image source={require('./assets/embedded/background-blank.png')} />
- <Image source={require('./assets/embedded/icon/user.png')} />
- <Image source={require('./assets/embedded/icon/en/user.png')} />
+ <Image source={{ uri: 'background' }} style={{ width: 200, height: 200 }} />
+ <Image source={{ uri: 'background_blank' }} style={{ width: 200, height: 200 }} />
+ <Image source={{ uri: 'icon_user' }} style={{ width: 200, height: 200 }} />
+ <Image source={{ uri: 'icon_en_user' }} style={{ width: 200, height: 200 }} />

これで expo-update が使える様になりました。Bundle のサイズも小さくなることで読み込み時間も少なくなりアプリ起動速度の向上が期待できるのではないかなと思います👍

反面、エディタ上での画像を指定する際にコード補完が効かなくなるためファイル名はある程度把握しておく必要があります。

補足:リポジトリ管理対象外の画像を追加する場合

対象画像が格納されているディレクトリが .gitignore に追加されていると eas build 時にビルド対象のファイルとして扱われず画像が無視されてしまいます。
そのため、画像をリポジトリで管理するか、リポジトリに追加したくない場合 hook 時に別途画像を準備するスクリプトを assetsImageCopy.sh 前に実行します。

package.json
{
  "scripts": {
    "copy-image": "画像を assets/embedded にコピーするスクリプト",
    "eas-build-post-install": "yarn copy-images && bash assetImageCopy.sh assets/embedded"
  }
}

Discussion