はじめに
こちらは個人開発アプリができるまで by am10 Advent Calendar 2024の 20 日目の記事です。
20 日目は強制アップデートについてです。
致命的なバグがあったときなどユーザーにアプリをアップデートしてもらいたいときにアラートを出してストアに飛ばすやつです。
やり方としては下記 3 つを考えました。
- Firebase Remote Config を使う
- iTunes Search API を使う
- 独自のサーバーを使う
Firebase Remote Config を使う
下記の Firebase Remote Config を使う方法です。
導入方法は公式ドキュメントかいろいろ記事があるのでそちらを見てもらえればと思います。
Firebase コンソールで下記のようにストアに公開したアプリのバージョン情報を追加します。
アプリ側で下記のように実装をすればバージョン比較ができます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
import FirebaseRemoteConfig let remoteConfig = RemoteConfig.remoteConfig() remoteConfig.fetch { status, _ in if status == .success { remoteConfig.activate { _, _ in let currentAppVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String let remoteAppVersion = remoteConfig.configValue(forKey: "app_version").stringValue if currentAppVersion != remoteAppVersion { // アラート表示 } } } } |
メリット
- リアルタイム通知を受けられる
- shouldUpdate フラグなど値を追加すれば特定バージョンのみ表示もできる
デメリット
- Firebase を導入する必要がある
iTunes Search API を使う
続いて iTunes Search API を使う方法です。
下記の形式でアクセスすれば App Store から公開しているアプリ情報が取得できます(bundleId=[バンドル ID]でも取得できるようです)。
1 |
https://itunes.apple.com/lookup?id=[アプリID] |
アプリ ID はストアの URL の idXXXXXXXXXX となっている数字の部分です。
試しに私がリリースしている「サイコロ人生」というアプリでやってみると下記のような JSON が返ってきます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 |
{ "resultCount": 1, "results": [ { "isGameCenterEnabled": false, "features": [], "supportedDevices": [ "iPhone5s-iPhone5s", "iPadAir-iPadAir", "iPadAirCellular-iPadAirCellular", "iPadMiniRetina-iPadMiniRetina", "iPadMiniRetinaCellular-iPadMiniRetinaCellular", "iPhone6-iPhone6", "iPhone6Plus-iPhone6Plus", "iPadAir2-iPadAir2", "iPadAir2Cellular-iPadAir2Cellular", "iPadMini3-iPadMini3", "iPadMini3Cellular-iPadMini3Cellular", "iPodTouchSixthGen-iPodTouchSixthGen", "iPhone6s-iPhone6s", "iPhone6sPlus-iPhone6sPlus", "iPadMini4-iPadMini4", "iPadMini4Cellular-iPadMini4Cellular", "iPadPro-iPadPro", "iPadProCellular-iPadProCellular", "iPadPro97-iPadPro97", "iPadPro97Cellular-iPadPro97Cellular", "iPhoneSE-iPhoneSE", "iPhone7-iPhone7", "iPhone7Plus-iPhone7Plus", "iPad611-iPad611", "iPad612-iPad612", "iPad71-iPad71", "iPad72-iPad72", "iPad73-iPad73", "iPad74-iPad74", "iPhone8-iPhone8", "iPhone8Plus-iPhone8Plus", "iPhoneX-iPhoneX", "iPad75-iPad75", "iPad76-iPad76", "iPhoneXS-iPhoneXS", "iPhoneXSMax-iPhoneXSMax", "iPhoneXR-iPhoneXR", "iPad812-iPad812", "iPad834-iPad834", "iPad856-iPad856", "iPad878-iPad878", "iPadMini5-iPadMini5", "iPadMini5Cellular-iPadMini5Cellular", "iPadAir3-iPadAir3", "iPadAir3Cellular-iPadAir3Cellular", "iPodTouchSeventhGen-iPodTouchSeventhGen", "iPhone11-iPhone11", "iPhone11Pro-iPhone11Pro", "iPadSeventhGen-iPadSeventhGen", "iPadSeventhGenCellular-iPadSeventhGenCellular", "iPhone11ProMax-iPhone11ProMax", "iPhoneSESecondGen-iPhoneSESecondGen", "iPadProSecondGen-iPadProSecondGen", "iPadProSecondGenCellular-iPadProSecondGenCellular", "iPadProFourthGen-iPadProFourthGen", "iPadProFourthGenCellular-iPadProFourthGenCellular", "iPhone12Mini-iPhone12Mini", "iPhone12-iPhone12", "iPhone12Pro-iPhone12Pro", "iPhone12ProMax-iPhone12ProMax", "iPadAir4-iPadAir4", "iPadAir4Cellular-iPadAir4Cellular", "iPadEighthGen-iPadEighthGen", "iPadEighthGenCellular-iPadEighthGenCellular", "iPadProThirdGen-iPadProThirdGen", "iPadProThirdGenCellular-iPadProThirdGenCellular", "iPadProFifthGen-iPadProFifthGen", "iPadProFifthGenCellular-iPadProFifthGenCellular", "iPhone13Pro-iPhone13Pro", "iPhone13ProMax-iPhone13ProMax", "iPhone13Mini-iPhone13Mini", "iPhone13-iPhone13", "iPadMiniSixthGen-iPadMiniSixthGen", "iPadMiniSixthGenCellular-iPadMiniSixthGenCellular", "iPadNinthGen-iPadNinthGen", "iPadNinthGenCellular-iPadNinthGenCellular", "iPhoneSEThirdGen-iPhoneSEThirdGen", "iPadAirFifthGen-iPadAirFifthGen", "iPadAirFifthGenCellular-iPadAirFifthGenCellular", "iPhone14-iPhone14", "iPhone14Plus-iPhone14Plus", "iPhone14Pro-iPhone14Pro", "iPhone14ProMax-iPhone14ProMax", "iPadTenthGen-iPadTenthGen", "iPadTenthGenCellular-iPadTenthGenCellular", "iPadPro11FourthGen-iPadPro11FourthGen", "iPadPro11FourthGenCellular-iPadPro11FourthGenCellular", "iPadProSixthGen-iPadProSixthGen", "iPadProSixthGenCellular-iPadProSixthGenCellular", "iPhone15-iPhone15", "iPhone15Plus-iPhone15Plus", "iPhone15Pro-iPhone15Pro", "iPhone15ProMax-iPhone15ProMax", "iPadAir11M2-iPadAir11M2", "iPadAir11M2Cellular-iPadAir11M2Cellular", "iPadAir13M2-iPadAir13M2", "iPadAir13M2Cellular-iPadAir13M2Cellular", "iPadPro11M4-iPadPro11M4", "iPadPro11M4Cellular-iPadPro11M4Cellular", "iPadPro13M4-iPadPro13M4", "iPadPro13M4Cellular-iPadPro13M4Cellular", "iPhone16-iPhone16", "iPhone16Plus-iPhone16Plus", "iPhone16Pro-iPhone16Pro", "iPhone16ProMax-iPhone16ProMax", "iPadMiniA17Pro-iPadMiniA17Pro", "iPadMiniA17ProCellular-iPadMiniA17ProCellular" ], "advisories": [], "kind": "software", "screenshotUrls": [ "https://is1-ssl.mzstatic.com/image/thumb/PurpleSource211/v4/40/94/f7/4094f7ec-297f-55e2-6d70-e86e13fbb621/5f3df639-8095-4f64-8b4c-d61b90de405d_1.png/392x696bb.png", "https://is1-ssl.mzstatic.com/image/thumb/PurpleSource211/v4/e4/e7/99/e4e7997f-152e-cf53-4c00-9f839adc66ab/57df56f4-1f01-4479-a050-e2ef6a98c1c6_2.png/392x696bb.png", "https://is1-ssl.mzstatic.com/image/thumb/PurpleSource211/v4/44/92/2c/44922ca7-71ef-a41a-4969-4e3d9b327943/5118abec-5a0f-4bee-923a-7f9c1ecf71b6_3.png/392x696bb.png" ], "ipadScreenshotUrls": [], "appletvScreenshotUrls": [], "artworkUrl512": "https://is1-ssl.mzstatic.com/image/thumb/Purple211/v4/f8/d2/e9/f8d2e965-47ad-701a-77d7-da40949efdc9/AppIcon-0-1x_U007ephone-0-85-220-0.png/512x512bb.jpg", "artistViewUrl": "https://apps.apple.com/us/developer/makoto-amijima/id1493994946?uo=4", "artworkUrl60": "https://is1-ssl.mzstatic.com/image/thumb/Purple211/v4/f8/d2/e9/f8d2e965-47ad-701a-77d7-da40949efdc9/AppIcon-0-1x_U007ephone-0-85-220-0.png/60x60bb.jpg", "artworkUrl100": "https://is1-ssl.mzstatic.com/image/thumb/Purple211/v4/f8/d2/e9/f8d2e965-47ad-701a-77d7-da40949efdc9/AppIcon-0-1x_U007ephone-0-85-220-0.png/100x100bb.jpg", "minimumOsVersion": "16.0", "artistId": 1493994946, "artistName": "makoto amijima", "genres": [ "Entertainment" ], "price": 0, "currentVersionReleaseDate": "2024-05-24T02:52:55Z", "bundleId": "am10.DiceRoll", "trackId": 6450755473, "trackName": "DiceLife", "genreIds": [ "6016" ], "releaseDate": "2023-06-29T07:00:00Z", "primaryGenreName": "Entertainment", "primaryGenreId": 6016, "isVppDeviceBasedLicensingEnabled": true, "sellerName": "makoto amijima", "releaseNotes": "Design change", "version": "2.0.0", "wrapperType": "software", "currency": "USD", "description": "It's an app that allows you to roll the dice anytime, anywhere.", "languageCodesISO2A": [ "AR", "EN", "FR", "ID", "JA", "MS", "PT", "RU", "ES", "ZH" ], "fileSizeBytes": "1265664", "formattedPrice": "Free", "userRatingCountForCurrentVersion": 0, "trackContentRating": "4+", "averageUserRatingForCurrentVersion": 0, "trackCensoredName": "DiceLife", "trackViewUrl": "https://apps.apple.com/us/app/dicelife/id6450755473?uo=4", "averageUserRating": 0, "contentAdvisoryRating": "4+", "userRatingCount": 0 } ] } |
注意点として、iTunes Search API はキャッシュの影響で古い情報を返すことがあります。そのため、下記記事を参考にクエリーにタイムスタンプを付与してキャッシュを回避することが必要です(例:https://itunes.apple.com/lookup?id=[アプリ ID]×tamp=[現在時刻])。
アクセスするURLを都度変更(URLのクエリーにタイムスタンプを付与)するようにしてアクセスすることでキャッシュされないように対応しました。
アプリ側で下記のように実装をすればバージョン比較ができます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
Task { do { let appID = "XXXXXXXXXX" // ここにアプリIDを設定 let data = try await URLSession.shared.data(from: .init(string: "https://itunes.apple.com/lookup?id=\(appID)×tamp=\(Int(Date().timeIntervalSince1970))")!) let response = try JSONSerialization.jsonObject(with: data.0, options: []) as? [String: Any] let results = response?["results"] as? [[String: Any]] ?? [] let remoteAppVersion = results.first?["version"] as? String let currentAppVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String print(remoteAppVersion) print(currentAppVersion) if currentAppVersion != remoteAppVersion { // アラート表示 } } catch { } } |
メリット
- 自前でとくに何も用意する必要がない
デメリット
- バージョンしかわからないのでストアに新バージョンがあれば必ずアラートを出すことになる
独自のサーバーを使う
最後にサーバーを用意してそこにバージョン情報をおく方法です。
今回のアプリの場合はスプレッドシートを使っているのでスプレッドシートにバージョン情報を記載するでもいいでしょう。他の簡単な方法としては GitHub に JSON ファイルを置く方法です。
アプリ側の処理は iTunes Search API を使う方法と似たようなものです。
メリット
- shouldUpdate フラグなど値を追加すれば特定バージョンのみ表示もできる
デメリット
- 自前で用意する必要がある
実装
今回は 3 つ目の方法で GitHub にバージョン情報の JSON を置くことにします。
こんな感じで JSON ファイルを置きます。
AppVersionChecker.swift ファイルを作成し下記のように実装します。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
import Foundation struct AppVersionChecker { func shouldUpdate() async throws -> Bool { let url = URL(string: "https://raw.githubusercontent.com/adventam10/ExpenseLog/refs/heads/main/AppVersion.json")! let data = try await URLSession.shared.data(from: url) let response = try JSONSerialization.jsonObject(with: data.0, options: []) as? [String: Any] let remoteAppVersion = response?["appVersion"] as? String let currentAppVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String return currentAppVersion != remoteAppVersion } } |
ContentView を下記のように修正します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
@State private var currentAlert: AlertEntitiy? @State private var isAlertShown = false @Environment(\.openURL) private var openURL @Environment(\.scenePhase) private var scenePhase private func showAlert(_ alert: AlertEntitiy) { currentAlert = alert isAlertShown = true } .onChange(of: scenePhase) { oldValue, newValue in switch newValue { case .active: Task { do { let shouldUpdate = try await AppVersionChecker().shouldUpdate() if shouldUpdate { showAlert(.init(title: "", message: .init(localized: "app_update_message")) { // ここでストアのURL設定 openURL(URL(string: "")!) }) } } catch let error { print(error) } } default: break } } .alert( currentAlert?.title ?? "", isPresented: $isAlertShown, presenting: currentAlert ) { entity in Button("alert_default_button_title") { entity.okAction?() } } message: { entity in Text(entity.message) } |
これでアプリがアクティブになったときに毎回バージョン比較しバージョンが違えばストアへ遷移させるアラートを表示するようになりました。
おわりに
もしものための強制アップデート機能も実装できたのでこれで安心です。
明日は単体テストを実装します。
コメント