【Swift/iOS】強制アップデートの実装方法!iTunes Search API

この記事からわかること
- Swift/iOSアプリで強制アップデートを実装する方法
- iTunes Search APIの使い方
- ストア情報を取得するには?
index
[open]
\ アプリをリリースしました /
環境
- Xcode:16.0
- iOS:18.0
- Swift:5.9
- macOS:Sonoma 14.6.1
強制アップデートの仕組み
モバイルアプリ開発でよくある「強制アップデート」の仕組みはユーザーが使用しているアプリのバージョンと最新のアプリバージョンを比較し異なる場合にポップアップなどを表示させることで強制または半強制的にアップデートを促す方法です。
これによりユーザーには常に最新のアプリバージョンでの使用を徹底することもできるので、メジャーバージョンが変わった時や致命的なバグが見つかった際に速やかにアップデートをさせることができます。
起動中のアプリのバージョンを取得する
アプリ内から使用されているバージョンを取得するにはBundle
を使用してCFBundleShortVersionString
の値を取得すればOKです。ここからX.X.X
のようなバージョンを文字列で取得することが可能です。
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
最新バージョンを取得する方法
アプリが比較対象とする最新のバージョンを管理/取得する方法はいくつかあります。
- Firebase Remote Config
- 自前のサーバー
- iTunes Search API
要はサーバー側に最新のアプリバージョンを設置してそれをアプリからAPIなどで取得できる仕組みを構築すれば良いだけなので方法はいくつかあります。ただ「Firebase Remote Config」や「自前のサーバー」などでは更新するごとに手動でサーバー側の値を変更する必要があります。そこでおすすめなのが「iTunes Search API」です。
iTunes Search APIとは?
「iTunes Search API」はApple公式のAPIでiTunes Store, App Store, Apple Musicなどのコンテンツを検索・取得できるAPIです。このAPIは認証キーなどが不要かつ無料で誰でも利用できるので導入コストも軽く、APIなのでアプリ側からの取得ロジックの実装も簡単です。
取得だけでなく、検索などもできる便利なAPIなので詳細は公式リファレンス:iTunes Search APIを参考にしてください。ここでは該当アプリのストア情報を取得する方法にフォーカスしていきます。
つまりiTunes Search APIを使用することでストア情報が更新されたタイミングで自動的に強制アップデートを促すことが可能になります。
該当アプリのストア情報を取得
iTunes Search APIを使用してアプリのストア情報を取得するにはhttps://itunes.apple.com/lookup?id=[アプリID]
形式のURLを構築すればOKです。アプリID
は公開前であればApp Store Connectの「アプリ情報」>「Apple ID」に掲載されており、公開していればストアをWebで開いた際のURLの最後にidXXXXXXXXX
という形で確認することができます。

これを元に以下のようにパスを構築してGETメソッドで叩いてみると「txt」ファイルが取得でき、その中にJSON形式でストア情報が記載されています。
// みんなの誕生日アプリ
https://itunes.apple.com/lookup?id=1673431227
中身は以下のような感じでその中にversion
があります。
{
"resultCount": 1,
"results": [
{
"features": ["iosUniversal"],
"advisories": [],
"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", "iPhone16e-iPhone16e"
],
"isGameCenterEnabled": false,
"kind": "software",
"artistViewUrl": "https://apps.apple.com/us/developer/tatsuyuki-shibuya/id1639823174?uo=4",
"artworkUrl100": "https://is1-ssl.mzstatic.com/image/thumb/Purple221/v4/18/e8/c0/18e8c0a7-dab8-a1b3-e7f4-3dea655ffa91/AppIcon-0-0-1x_U007epad-0-1-85-220.png/100x100bb.jpg",
"minimumOsVersion": "16.0",
"artistName": "tatsuyuki shibuya",
"genres": ["Lifestyle", "Utilities"],
"price": 0.00,
"releaseDate": "2023-02-21T08:00:00Z",
"bundleId": "com.ame.MinnanoTanjyoubi",
"trackName": "みんなの誕生日 -メモするだけで通知が届く管理アプリ-",
"releaseNotes": "いつもみんなの誕生日を使っていただきありがとうございます!!\n\n今回のアップデートの変更点は以下です。\n\n・軽微なバグを修正しました。",
"version": "4.6.8",
"description": "\大切な人の大切な日を忘れずにお祝いするためのアプリ/...",
"trackViewUrl": "https://apps.apple.com/us/app/id1673431227?uo=4",
"contentAdvisoryRating": "4+",
"languageCodesISO2A": ["JA"],
"fileSizeBytes": "24097792",
"formattedPrice": "Free"
}
]
}
強制アップデートを実装してみる
強制アップデートを実際に実装してみたいと思います。まずは取得できるJSONレスポンスに合わせた構造体を定義しておきます。
struct AppInfo: Decodable {
let results: [AppDetails]
}
struct AppDetails: Decodable {
let version: String
}
あとはHTTP通信が行えれば良いのでURLSession
やAlamofire
などを使用してAPIを叩きそのレスポンスからバージョンを取得できるように実装します。
今回はそれぞれの責務がわかりやすいように以下のように実装します。
- View:ContentView・・・バージョン取得トリガーとアラート表示
- ViewModel:VersionCheckViewModel・・・Repository操作とバージョン比較
- Repository:ItunesAPIRepository・・・iTunes Search APIでバージョンフェッチ
View:ContentView・・・バージョン取得トリガーとアラート表示
struct ContentView: View {
@StateObject private var viewModel = VersionCheckViewModel()
var body: some View {
VStack {
Text("アプリバージョンチェック")
.fontWeight(.bold)
.padding()
}
.onAppear {
viewModel.checkForUpdate()
}
.alert(isPresented: $viewModel.showUpdateAlert) {
Alert(
title: Text("アップデートが必要です"),
message: Text("最新バージョン (\(viewModel.latestVersion)) にアップデートしてください。"),
primaryButton: .default(Text("アップデート"), action: {
if let url = URL(string: "https://apps.apple.com/jp/app/id\(ItunesAPIRepository.appId)") {
UIApplication.shared.open(url)
}
}),
secondaryButton: .cancel(Text("閉じる"))
)
}
}
}
ViewModel:VersionCheckViewModel・・・Repository操作とバージョン比較
class VersionCheckViewModel: ObservableObject {
@Published var showUpdateAlert: Bool = false
@Published private(set) var latestVersion: String = ""
private let repository = ItunesAPIRepository()
public func checkForUpdate() {
repository.fetchAppVersion { [weak self] fetchedVersion in
guard let self, let fetchedVersion else { return }
// 起動中のアプリのバージョンを取得
let currentVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
/// バージョンを比較しアップデート対象ならアラート発火
if self.isUpdateRequired(current: currentVersion, latest: fetchedVersion) {
DispatchQueue.main.async {
self.latestVersion = fetchedVersion
self.showUpdateAlert = true
}
}
}
}
/// バージョン比較
private func isUpdateRequired(current: String, latest: String) -> Bool {
return current.compare(latest, options: .numeric) == .orderedAscending
}
}
Repository:ItunesAPIRepository・・・iTunes Search APIでバージョンフェッチ
class ItunesAPIRepository {
private let endpoint: String = "https://itunes.apple.com/lookup?id="
// FIXME: 適宜変更する
static let appId: String = "1673431227"
public func fetchAppVersion(completion: @escaping (String?) -> Void) {
let urlString = endpoint + Self.appId
guard let url = URL(string: urlString) else {
completion(nil)
return
}
let task = URLSession.shared.dataTask(with: url) { data, response, error in
guard let data, error == nil else {
print("Error fetching data: \(error?.localizedDescription ?? "Unknown error")")
completion(nil)
return
}
do {
let decodedData = try JSONDecoder().decode(AppInfo.self, from: data)
let version = decodedData.results.first?.version
DispatchQueue.main.async {
completion(version)
}
} catch {
print("JSON decoding error: \(error.localizedDescription)")
DispatchQueue.main.async {
completion(nil)
}
}
}
task.resume()
}
}
実装してみると意外に簡単に強制アップデート機能を実装することができました。
iTunes Search APIで実装する注意点
「iTunes Search API」がベストプラクティスに思えすが注意も必要です。これはあくまでiTunes Search APIから取得できる値が変換した時に発生させることができるのでラグなどがある可能性もあります。手動の場合は確実にユーザーに対して定刻から強制アップデート通知を表示することができますが、iTunes Search APIはそうはいきません。
ストアでアップデートできるようになるタイミングとiTunes Search APIから取得できる値がリアルタイムで同じかは不明ですし、実際に公式ドキュメントを見ると以下のように記述されているので、注意はしておいた方が良いかもしれません。
App が承認されてから App Store で配信されるまで、最大で 24 時間かかります。
Androidアプリの強制アップデートはこちら
まだまだ勉強中ですので間違っている点や至らぬ点がありましたら教えていただけると助かります。
ご覧いただきありがとうございました。