🐱

ExpoのOTA updateの仕様について

2020/12/24に公開
5

この記事は React Native アドベントカレンダーの24日目の記事です。
ExpoのOTA updateの仕様が分かりづらく,はまりどころが多いので,注意点や自分でアレンジする方法をまとめました。

OTA updateとは

Over The Air updateの略。
App Store(またはGoogle Play)からアップデートしなくても最新のバージョンに更新できます。
審査を通す必要がないので素早く最新のバージョンをユーザーに届けることができます。

OTA updateのやりかた

$ expo publish

たったこれだけです。

release-channel

$ expo publish --release-channel staging
$ expo publish --release-channel production
のようにチャネル名をつけて使用することができます。

ストアに出してからOTA updateするまでの流れとしては,

  1. 以下のコマンドでbuildしてストアに出す
    $ expo build:ios --release-channel production
    $ expo build:android --release-channel production
  2. OTA update
    $ expo publish --release-channel production

という形で,buildした際のchannel名に対してexpo publishします。channel名が同じものに対してのみOTAで配布されるので,$ expo publish --release-channel stagingとしたときにはストアからインストールしたユーザーさんには届くことなく,stagingのQRコードのリンクを知っている人のみ見ることができます。

Manual Updates

やり方については記事の後半で説明しますが,前半でも所々出てくるので用語の説明だけします。
ドキュメントでは,automatic updatesの対義語として使われています。app.jsonをいじって設定を変えるだけの場合はautomatic updates, 自分でUpdates.fetchUpdateAsyncなどを使ってアレンジする場合はManual Updatesと呼んでいます。

注意点

ここからが本題です。とにかく注意しなくてはいけないのが以下の4点です。

  1. OTA updateはデフォルトでONになっています。
  2. $ expo publishのときだけでなく$ expo buildのときもOTAで配布されます。
  3. Expo SDKのバージョンを上げたときなどは必ずストアに提出し直す必要があります。
  4. ストアからインストールして初めて開いたときにはOTA updateが反映されません。

詳しく説明していきます。

注意点1:デフォルトでONになっている

これを知らないと,注意点2である通りストアに出そうとして$ expo buildしただけなのに,ストアに出す前に勝手にOTA updateされてユーザーに届いてしまう…ということになりかねません。

OTA updateをオフにしたい場合はapp.jsonで変更できます。

{
    "expo": {
        "updates": {
            "enabled": false //デフォルトtrue。ここをfalseにするとOTA updateできなくなる
    }
}

また,"enabled"以外にも以下の項目があります。

  • checkAutomatically
  • fallbackToCacheTimeout

checkAutomatically

"ON_LOAD"と"ON_ERROR_RECOVERY"が選択でき,デフォルトは"ON_LOAD"になっています。

"ON_LOAD"の場合,アプリがロードされるたびに最新のものがあるかどうか自動でチェックしてくれます。ただし,アプリがロードされるタイミングはアプリを再起動したときなので,アプリがバックグラウンドに残り続けている限りはOTA updateしてくれません。
アプリがバックグラウンドからフォアグラウンドに来た時にチェックしたい場合は自分でアレンジする必要があります。その方法については記事の後半のManual Updatesを参照してください。

"ON_ERROR_RECOVERY"の場合,アプリがクラッシュしたあともう一度立ち上げた際に発動します。

fallbackToCacheTimeout

デフォルト30000(ミリ秒)。

fallbackToCacheTimeout: 30000,
checkAutomatically: "ON_LOAD"

だったときの挙動は以下の通りです。

[アプリ起動時]

OTAで配布された最新のバンドルを取得・ダウンロードします。30000ミリ秒以内にダウンロードできた場合パターン1へ,できなかった場合パターン2へ。

[パターン1: 30000ミリ秒以内にダウンロードできたとき]

取得した最新のバンドルが反映された状態でアプリが起動。

[パターン2: 30000ミリ秒以内にダウンロードできなかったとき]

端末にキャッシュされている最新のバージョンを使用してアプリが起動。その後もバックグラウンドでダウンロードし続けて,ダウンロードが終わった時点でキャッシュに保存されます。そのため,次回起動時(※)は最新のバンドルが反映されます。

Updates.addListenerの使用例

ダウンロードが終わったら再起動させる

import * as Updates from 'expo-updates';

const handleUpdate = async (e: Updates.UpdateEvent) => {
  if (e.type === Updates.UpdateEventType.UPDATE_AVAILABLE) {
    try {
      await Updates.reloadAsync(); //再起動と同じ挙動になります
    } catch (err) {
      alert(err);
    }
  }
};
  
useEffect(() => {
  const update = Updates.addListener(handleUpdate);
  return () => update.remove();
}, []);

注意点2:$ expo buildのときもOTAで配布される。

タイトルの通りですが,
$ expo build:ios --release-channel production
としたときにOTAで配布されてしまいます。そのため,ストアに提出するよりも前にユーザーに届けられてしまいます。

OTA updateでの反映を防ぐ方法は

  1. OTA updateを完全にOFFにする。(注意点1を参照)
  2. Manual Updatesで自力でOTA updateのロジックを書く
    app.jsonでcheckAutomatically: "ON_ERROR_RECOVERY"にすることで,再起動時に反映されることを防ぐ。記事の後半で詳しく解説してあります。

の2パターンあります。

注意点3:必ずストアに提出し直す必要があるときがある

公式に書いてあるとおり,Expo SDKのバージョンを上げたときにはOTA updateができません。具体的には,$ expo publish自体はできるのですが,ユーザーには届きません。

:::messages
Manual Updatesの章でもう一度触れますが,SDKのバージョンを上げたあとに$ expo publishをしても,前のバージョンのユーザーは(await Updates.checkForUpdateAsync()).isAvailableがfalseになってしまいます。
:::

他にもapp.json

  • スプラッシュスクリーンを変更したとき
  • アプリアイコンを変更したとき
  • アプリ名を変更したとき

などはストアを通す必要があります。
OTA updateでは反映されない項目全体はこちらに書いてあります。

注意点4:ストアからインストールして初めて開いたときにはOTA updateが反映されません。

app.jsonで

{
    "expo": {
        "updates": {
            "enabled": true,
	    "checkAutomatically": "ON_LOAD",
	    "fallbackToCacheTimeout": 30000,
	}
    }
}

にしていたとしても,ストアからインストールしてから最初の1回目にアプリを開く時は最新のバンドルがあるかどうかチェックしてくれません。そのため,しばらくストアに出さないと,かなり古いバージョンが初期ユーザーに表示されてしまうということになりかねません。

Manual Updates

最初に理解すべきこととして, OTA updateには

  1. 最新のバージョンが配布されているかcheckする(Updates.checkForUpdateAsync())
  2. 最新のファイルを取得してくる(Updates.fetchUpdateAsync())
  3. 取得したファイルを反映させる(Updates.reloadAsync())

の三段階があります。Manual Updatesをしない場合はこの一連の流れを自動で行ってくれているのですが、Manual Updatesの場合はここを1つずつ制御していく形になります。
3はアプリの再起動と同様の挙動を行います。

これから以下のようにアレンジしたいと思います。ステップ1まではよくある実装だと思いますが,ステップ2以降はサービスによってバラバラだと思うので,あくまで参考程度に見ていただけると嬉しいです。コード全体はこの章の最後に貼ってあります。

  1. アプリを再起動したときではなく,アプリがバックグラウンドからフォアグラウンドに来た時に新しいバンドルがOTAで配布されているかチェックしたい。
  2. ダウンロードが終了した時点で「アプリを再起動して今すぐ更新しますか?」というアラートを出して再起動するかどうか選択させたい。
  3. Expo SDKのversionを上げたときには,強制アップデートをさせたい(ストアに行くようダイアログを出す)。
  4. 他にも,強制アップデートをしたいときにはできるようにしたい。

ステップ1

アプリを再起動したときではなく,アプリがバックグラウンドからフォアグラウンドに来た時に新しいバンドルがOTAで配布されているかチェックしたい。

Updates APIを使ってManual Updatesを実装します。

以下の実装によって,ダウンロードしている間は普通にアプリが使えて,ダウンロードが終了した時点で自動的に再起動されます。


import { AppState } from 'react-native';

const handleUpdate = async (state: string) => {
  if (state === 'active') { //フォアグラウンドになったときのみこの関数を実行

    const update = await Updates.checkForUpdateAsync();
    
    //OTA updateするものがないときは終了
    if (!update.isAvailable) {
      return;
    }
    
    try {
    //最新のものをダウンロードする
    await Updates.fetchUpdateAsync();
    //再起動させる
    Updates.reloadAsync();
    } catch(e) {
      alert(e);
    }
    
  }
};

useEffect(() => {
  AppState.addEventListener('change', handleUpdate);
  return () => {
    AppState.removeEventListener('change', handleUpdate);
  };
}, []); //フォアグラウンド・バックグラウンドが変わったタイミングでhandleUpdateを発火

Updates.checkForAsync

利用可能なバンドルがあるのかを確認にします。バンドルのダウンロードは行わず。ダウンロードされていてもされていなくてもisAvalable=trueとなります。つまり、現在展開されているソースよりも新しいバンドルがあるかを確認しています。

Updates.reloadAsync

再起動と同じ挙動を引き起こします。

Updates.fetchUpdateSync

利用可能なリソース取得しCacheに保存する。反映は行わない。反映されるのは再起動した時。

ステップ2

ダウンロードが終了した時点で「アプリを再起動して今すぐ更新しますか?」というアラートを出して再起動するかどうか選択させたい。

ステップ1の時点で,ダウンロードはバックグラウンドで進行しています。このままでは,使用している最中に急に再起動してスプラッシュスクリーンが表示されてしまいます。これは『利用中のクラッシュ』に似たUXを引き起こすため好ましくありません。そのため,ダイアログを出して,再起動するか選択させます。

新たに実装する箇所としては以下です。

  • ダイアログの選択肢として以下を用意します。
    • 「はい」:今すぐ再起動して最新のバージョンを使用
    • 「いいえ」:次回アプリがフォアグラウンドに来た時に再びダイアログを出す
    • 「このバージョンはスキップ」:次の新しいバージョンが来るまでダイアログを出さない
  • AsyncStorageにセットするものとして以下の2つを用意します。
    • 'cachedReleaseId':ダウンロードしてある最新のキャッシュのreleaseIdを入れます。新しいバージョンをダウンロード(Updates.fetchUpdateAsync)が終わった直後にセットします。
    • 'hasSkipped':前回スキップを押したかどうかのフラグを入れます。新しいバージョンをダウンロードしたら"false"にします。
  • update.isAvailableがtrueのとき,以下の3パターンに分けて考えます。
    • 最新のものはダウンロード済みで,前回「このバージョンはスキップ」を押した
      • 何もせずに終了。
    • 最新のものはダウンロード済みで,前回「いいえ」を押した
      • 再びダイアログを出す。
    • 最新のものは未ダウンロード
      • ダウンロードをする。終わり次第ダイアログを出す。

const RELEASE_KEY = 'cachedReleaseId';
const SKIPPED_KEY = 'hasSkipped';

const dialog = () => {
  Alert.alert(
    '最新版が利用可能です',
    'アプリを再起動して今すぐ更新しますか?',
    [
      {
        text: 'このバージョンはスキップ',
        onPress: async () => {
          await AsyncStorage.setItem(SKIPPED_KEY, 'true');
        },
        style: 'cancel',
      },
      {
        text: 'はい',
        onPress: () => {
          Updates.reloadAsync();
        },
      },
      { text: 'いいえ', onPress: () => {}, style: 'cancel' },
    ],
    { cancelable: false }
  );
};

const handleUpdate = async (state: string) => {
  if (state === 'active') {
    const update = await Updates.checkForUpdateAsync();
    if (!update.isAvailable) {
      return;
    }

    const cachedReleaseId = await AsyncStorage.getItem(RELEASE_KEY);
    const hasSkipped = await AsyncStorage.getItem(SKIPPED_KEY);

    if (update.manifest.releaseId === cachedReleaseId) {
      //最新のものがキャッシュされているとき
      if (hasSkipped === 'true') {
        return; //前回「このバージョンをスキップ」を押したときはここに来る。何もせずに終了。
      } else {
        dialog(); //前回「いいえ」を押したときはここに来る。
      }
      Updates.reloadAsync();
    } else {
      //最新のものがキャッシュされていないとき
      await Updates.fetchUpdateAsync();
      await AsyncStorage.setItem(RELEASE_KEY, update.manifest.releaseId);
      await AsyncStorage.setItem(SKIPPED_KEY, 'false');
      dialog();
    }
  }
};

ステップ3

Expo SDKのversionを上げたときには,強制アップデートをさせたい(ストアに行くようダイアログを出す)。

注意点3で触れたとおり,Expo SDKのversionを上げてOTA updateで配布しても,const update = await Updates.checkForUpdateAsync()updates.isAvailableがfalseになってしまいます。

そのため,ストアに上がっているアプリのバージョンを見て,マイナーバージョンが上がっていたら強制的にストアに移動させる
ことで対応したいと思います。

マイナーバージョンとは

バージョン 1.2.0 のとき,

  • メジャーバージョン:「1」の部分。
  • マイナーバージョン:「2」の部分。
  • リビジョン:「0」の部分。

今回は
1.2.4 -> 1.3.0

1.2.4 -> 2.0.0
のときに強制アップデートしたい

また,ストアに上がっているアプリのバージョンを取得するためにreact-native-version-checkを使用しました。

import { Platform } from 'react-native';
import VersionCheck from 'react-native-version-check-expo';

const ANDROID_STORE_URL = 'hoge'; //androidアプリのURL
const IOS_STORE_URL = 'fuga'; //iosアプリのURL

const currentVer = '1.2.0';


const openStore = () => {
  if (Platform.OS === 'android') {
    Linking.openURL(ANDROID_STORE_URL);
  } else {
    Linking.openURL(IOS_STORE_URL);
  }
};


const changedMajorOrMinorVersion = (latestVer: string) => {
  const chengedMinorVersion =
    Number(currentVer?.split('.')[1]) < Number(latestVer?.split('.')[1]);
  const chengedMajorVersion =
    Number(currentVer?.split('.')[0]) < Number(latestVer?.split('.')[0]);
  return chengedMinorVersion || chengedMajorVersion;
};

const storeDialog = (latestVer: string) => {
  Alert.alert(
    'アップデートのお知らせ',
    `バージョン${latestVer}に更新してから、引き続きアプリをご利用ください。`,
    [
      {
        text: '今すぐアップデート',
        onPress: openStore,
      },
    ]
  );
};

const handleUpdate = async (state: string) => {
  if (state === 'active') {
    const update = await Updates.checkForUpdateAsync();

    //端末のバージョンとストアのバージョンを比較して,マイナーバージョンまたはメジャーバージョンが上がってたらストアへ
    const storeLatestVer = await VersionCheck.getLatestVersion();
    if (changedMajorOrMinorVersion(storeLatestVer)) {
      storeDialog(storeLatestVer);
      return;
    }

    
//以下省略

ステップ4

他にも,強制アップデートをしたいときにはできるようにしたい。
致命的な不具合などを,全員に即アップデートしてほしい時に使用します。
こちらもステップ3と同様にマイナーバージョンを上げる運用で解決します。ステップ3と異なるところは,ステップ3ではストアに反映されないとユーザーに届かなかったですが,今回はOTA updateを配布したらすぐにユーザーが最新版を使用するようにします。

const update = await Updates.checkForUpdateAsync()update.manifest.versionで最新のversionを取得できます。


const forceUpdateDialog = (releaseId: string) => {
  Alert.alert(
    '最新版が利用可能です',
    'アプリを再起動してください',
    [
      {
        text: '再起動する',
        onPress: async () => {
          await Updates.fetchUpdateAsync();
          await AsyncStorage.setItem(RELEASE_KEY, releaseId);
          await AsyncStorage.setItem(SKIPPED_KEY, 'false');
          Updates.reloadAsync();
        },
      },
    ],
    { cancelable: false }
  );
};

const handleUpdate = async (state: string) => {
  if (state === 'active') {
    const update = await Updates.checkForUpdateAsync();

    //今のバージョンと配布されたバージョンを比較して,マイナーバージョンが上がってたら強制アップデート
    if (
      update.isAvailable &&
      changedMajorOrMinorVersion(update.manifest.version)
    ) {
      forceUpdateDialog(update.manifest.releaseId);
      return;
    }
   
//省略

これで完成です。

コード全体
import { useEffect } from 'react';
import * as Updates from 'expo-updates';
import { Alert, AsyncStorage, Linking, Platform, AppState } from 'react-native';
import VersionCheck from 'react-native-version-check-expo';

const RELEASE_KEY = 'cachedReleaseId';
const SKIPPED_KEY = 'hasSkipped';

const ANDROID_STORE_URL = 'hoge'; //androidアプリのURL
const IOS_STORE_URL = 'fuga'; //iosアプリのURL

const currentVer = '1.2.0';

const changedMajorOrMinorVersion = (latestVer: string) => {
  const chengedMinorVersion =
    Number(currentVer?.split('.')[1]) < Number(latestVer?.split('.')[1]);
  const chengedMajorVersion =
    Number(currentVer?.split('.')[0]) < Number(latestVer?.split('.')[0]);
  return chengedMinorVersion || chengedMajorVersion;
};

const openStore = () => {
  if (Platform.OS === 'android') {
    Linking.openURL(ANDROID_STORE_URL);
  } else {
    Linking.openURL(IOS_STORE_URL);
  }
};

const dialog = () => {
  Alert.alert(
    '最新版が利用可能です',
    'アプリを再起動して今すぐ更新しますか?',
    [
      {
        text: 'このバージョンはスキップ',
        onPress: async () => {
          await AsyncStorage.setItem(SKIPPED_KEY, 'true');
        },
        style: 'cancel',
      },
      {
        text: 'はい',
        onPress: () => {
          Updates.reloadAsync();
        },
      },
      { text: 'いいえ', onPress: () => {}, style: 'cancel' },
    ],
    { cancelable: false }
  );
};

const storeDialog = (latestVer: string) => {
  Alert.alert(
    'アップデートのお知らせ',
    `バージョン${latestVer}に更新してから、引き続きアプリをご利用ください。`,
    [
      {
        text: '今すぐアップデート',
        onPress: openStore,
      },
    ]
  );
};

const forceUpdateDialog = (releaseId: string) => {
  Alert.alert(
    '最新版が利用可能です',
    'アプリを再起動してください',
    [
      {
        text: 'はい',
        onPress: async () => {
          await Updates.fetchUpdateAsync();
          await AsyncStorage.setItem(RELEASE_KEY, releaseId);
          await AsyncStorage.setItem(SKIPPED_KEY, 'false');
          Updates.reloadAsync();
        },
      },
    ],
    { cancelable: false }
  );
};

const handleUpdate = async (state: string) => {
  if (state === 'active') {
    const update = await Updates.checkForUpdateAsync();

    //今のバージョンと配布された最新バージョンを比較して,マイナーバージョンが上がってたら強制アップデート
    if (
      update.isAvailable &&
      changedMajorOrMinorVersion(update.manifest.version)
    ) {
      forceUpdateDialog(update.manifest.releaseId);
      return;
    }

    //今のバージョンとストアのバージョンを比較して,マイナーバージョンが上がってたらストアへ
    const storeLatestVer = await VersionCheck.getLatestVersion();
    if (changedMajorOrMinorVersion(storeLatestVer)) {
      storeDialog(storeLatestVer);
      return;
    }

    if (!update.isAvailable) {
      return;
    }

    const cachedReleaseId = await AsyncStorage.getItem(RELEASE_KEY);
    const hasSkipped = await AsyncStorage.getItem(SKIPPED_KEY);

    if (update.manifest.releaseId === cachedReleaseId) {
      //最新のものがキャッシュされているとき
      if (hasSkipped === 'true') {
        return; //前回「このバージョンをスキップ」を押したときはここに来る。何もせずに終了。
      } else {
        dialog(); //前回「いいえ」を押したときはここに来る。
      }
      Updates.reloadAsync();
    } else {
      //最新のものがキャッシュされていないとき
      await Updates.fetchUpdateAsync();
      await AsyncStorage.setItem(RELEASE_KEY, update.manifest.releaseId);
      await AsyncStorage.setItem(SKIPPED_KEY, 'false');
      dialog();
    }
  }
};

useEffect(() => {
  AppState.addEventListener('change', handleUpdate);
  return () => {
    AppState.removeEventListener('change', handleUpdate);
  };
}, []);

最後に

ここに書いてあること以外にもハマりどころがあったらコメントいただけると嬉しいです。
また,内容の誤りを見つけた場合や,より良い方法をご存知の方も,ご気軽にコメントお願いします。

Discussion

kiseragikiseragi

こんにちは!

わかりやすい記事をありがとうございます。expo buildのときもOTAで配布される点ですが--no-publishを指定すればOTA配布を防げるみたいです:

https://forums.expo.io/t/confused-about-exp-build-vs-exp-publish/13313/3

まりるまりる

コメントありがとうございます!

--no-publish は、最後にpublishされたものをもとにbuildする形になってしまうんです…。puslishせずにlocalのコードをbuildする方法は今の所なさそうです💦

参考:
https://forums.expo.io/t/build-standalone-without-publishing/1312/7

Quick question, the --no-publish will skip publishing but will use the build previously published, is there a way to just build a standalone app with the build from local? This way if I want to update one platform, the other will not be updated

https://github.com/expo/expo/issues/2012

(実際に自分のコードで確認しても、issueと同様、localのものをbuildしてくれるわけではありませんでした。)

kiseragikiseragi

ご返信ありがとうございます!失礼いたしました。
最後にpublishされたものを元にbuildするのですね…

RyojiKRyojiK

こちらの記事大変参考になりました!!

一点質問なのですが、
release-channle=production(ストア配布向け)でOTAアップデートした際に、
バージョンアップデートを促すプロンプトが表示されない(stagingのExpoGoでは表示)のですが、何かプラスαで必要でしょうか??

ストアからのインストール直後では表示されない点は認識済みで、
アプリをバッググラウンドからフォアグラウンドにした際に表示されるはずなのですが。。。

まりるまりる

コメントありがとうございます!
stagingでは表示されるんですね。。すみません、原因がぱっと思い当たらないです。。

app.jsonの"updates"の箇所が"ON_LOAD"になっているとしたら

    "updates": {
      "fallbackToCacheTimeout": 30000,
      "checkAutomatically": "ON_ERROR_RECOVERY"
    },

に変えてみていただいてもよろしいでしょうか?

また、OTAアップデート後に

const update = await Updates.checkForUpdateAsync();

update.isAvailable

はtrueになりますでしょうか?もしAlertなどを使ってデバッグできるとしたら試してみていただきたいです。