【iOS】半強制アップデートの仕組みをカジュアルに実装する

# はじめに

この記事では、**アプリを起動したとき、新バージョンがリリースされていることをユーザに伝え、アップデートを促す仕組み（この記事では半強制アップデートと呼ぶ）** の実装方法を紹介します。

強制アップデートはゲームアプリなどでよく見る仕組みです。アップデートするまでアプリを利用できなくすることで、常に最新バージョンで動かすことができます。ただし、この方法はユーザがアップデートできる通信環境にいることを前提としているため、もし通信環境の悪いところにいた場合、ダウンロードに時間がかかったりして、すぐにアプリを使うことができなくなってしまいます。そういった使い勝手の観点から、僕はアップデートを促すまでに留めた半強制くらいが好みです。

さて、ユーザにインストールされているアプリのバージョンに乖離が生じると、最新機能を提供できないのはもちろんのこと、既存機能を改修しづらくなってきます。例えば、DBのマイグレーションを何世代かに渡って行っているとき、バージョンが飛んでいると思わぬバグを踏んだりします。

ユーザには常に最新バージョンを使ってもらうのが望ましく、アップデートを促す仕組みは積極的に取り入れた方が良いと考えています。

# iTunes Search APIを利用した実装

半強制アップデートの仕組みを実装するとき、だいたい以下のフローが考えられます。

![No Title.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/73490/141a5ede-ff40-85a6-c660-90b34bef8535.png)

1. アプリが起動されたらサーバに最新バージョンを問い合わせる
2. サーバから返却されたバージョンと現バージョンを比較
3. 最新ではなかったらユーザにバージョンアップを促すUIを表示

## iTunes Search API

最新バージョンを返すサーバを自前で用意しても良いですが、ここでは[iTunes Search API](https://affiliate.itunes.apple.com/resources/documentation/itunes-store-web-service-search-api/)を利用します。このAPIを使うと、App Storeで配信されているアプリの情報を無料で取得することができます。アプリ情報にはApp Storeでリリースされているバージョン情報も含まれています。

iTunes Search APIを使うことで、**アプリが実際にApp Storeから配信されるようになった段階で、取得できるアプリのバージョン情報が自動的に最新のものになる**ため、自前で配信バージョンを管理するサーバを立てる必要がなく、最新バージョンの更新し忘れなども起きないため、運用が楽です。

## AppStoreクラス - APIを利用する

僕は以下のようなiTunes Search APIを叩くAppStoreクラスを実装しています。（通信ライブラリに[Alamofire v5](https://github.com/Alamofire/Alamofire)を使用しています）

```swift
import Foundation
import Alamofire

typealias LookUpResult = [String: Any]

enum AppStoreError: Error {
    case networkError
    case invalidResponseData
}

class AppStore {

    private static let lastCheckVersionDateKey = "\(Bundle.main.bundleIdentifier!).lastCheckVersionDateKey"

    static func checkVersion(completion: @escaping (_ isOlder: Bool) -> Void) {
        let lastDate = UserDefaults.standard.integer(forKey: lastCheckVersionDateKey)
        let now = currentDate

        // 日付が変わるまでスキップ
        guard lastDate < now else { return }

        UserDefaults.standard.set(now, forKey: lastCheckVersionDateKey)

        lookUp { (result: Result<LookUpResult, AppStoreError>) in
            do {
                let lookUpResult = try result.get()

                if let storeVersion = lookUpResult["version"] as? String {
                    let storeVerInt = versionToInt(storeVersion)
                    let currentVerInt = versionToInt(Bundle.version)
                    completion(storeVerInt > currentVerInt)
                }
            }
            catch {
                completion(false)
            }
        }
    }

    static func versionToInt(_ ver: String) -> Int {
        let arr = ver.split(separator: ".").map { Int($0) ?? 0 }

        switch arr.count {
            case 3:
                return arr[0] * 1000 * 1000 + arr[1] * 1000 + arr[2]
            case 2:
                return arr[0] * 1000 * 1000 + arr[1] * 1000
            case 1:
                return arr[0] * 1000 * 1000
            default:
                assertionFailure("Illegal version string.")
                return 0
        }
    }

    /// App Storeを開く
    static func open() {
        if let url = URL(string: storeURLString), UIApplication.shared.canOpenURL(url) {
            UIApplication.shared.open(url)
        }
    }
}

private extension AppStore {

    static var iTunesID: String {
        "<YOUR_ITUNES_ID>"
    }

    /// App Storeのアプリページ
    static var storeURLString: String {
        "https://apps.apple.com/jp/app/XXXXXXX/id" + iTunesID
    }

    /// iTunes Search API
    static var lookUpURLString: String {
        "https://itunes.apple.com/lookup?id=" + iTunesID
    }

    /// 現在日時から生成される20201116のような整数を返す
    static var currentDate: Int {
        let formatter = DateFormatter()
        formatter.calendar = Calendar(identifier: .gregorian)
        formatter.locale = .current
        formatter.dateFormat = "yyyyMMdd"
        return Int(formatter.string(from: Date()))!
    }

    static func lookUp(completion: @escaping (Result<LookUpResult, AppStoreError>) -> Void) {
        AF.request(lookUpURLString).responseJSON(queue: .main, options: .allowFragments) { (response: AFDataResponse<Any>) in
            let result: Result<LookUpResult, AppStoreError>

            if let error = response.error {
                result = .failure(.networkError)
            }
            else {
                if let value = response.value as? [String: Any],
                   let results = value["results"] as? [LookUpResult],
                   let obj = results.first {
                    result = .success(obj)
                }
                else {
                    result = .failure(.invalidResponseData)
                }
            }

            completion(result)
        }
    }
}

extension Bundle {
    /// Info.plistにあるバージョン番号を取得。major.minor.patch形式になっていることを前提とする
    static var version: String {
        return Bundle.main.infoDictionary!["CFBundleShortVersionString"] as! String
    }
}
```

#### iTunesIDの調べ方

`<YOUR_ITUNES_ID>`の部分は、バージョン情報を取得したいアプリのiTunesIDに置き換えてください。iTunesIDはブラウザでApp Store上のアプリのページを開いたときのURLに表示されています。同様に`storeURLString`もApp StoreのURLに置き換えてください。

![Slice.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/73490/e752d19a-1ecb-c648-6828-55e380a1cdcf.png)

#### アプリバージョンの比較

上記の実装では、1日1回バージョンの更新チェックをするようになっています。アプリバージョンの比較は、*major.minor.patch*形式のバージョン文字列をドットで分割しInt型に変換（`versionToInt(_:)`メソッドを見てください）したもので比較しています。上記のやり方ではminorとpatchは0~999の1000ステップしか値を取れませんが十分でしょう。
`storeVersion > currentVersion`となるとき、App Storeに最新版がリリースされていることが分かります。

## AppStoreクラスを使う

AppStoreクラスを使うときはViewControllerで以下のようなメソッドを作っておき、`viewDidAppear`や`UIApplication.willEnterForegroundNotification`をオブザーブしたメソッドなどで呼び出します。

```swift
private extension ViewController {

    func checkVersion() {
        AppStore.checkVersion { (isOlder: Bool) in
            guard isOlder else { return }

            let alertController = UIAlertController(title: "新しいバージョンがあります!", message: "アップデートしてください。", preferredStyle: .alert)
            alertController.addAction(UIAlertAction(title: "アップデート", style: .default) { action in
                AppStore.open()
            })
            alertController.addAction(UIAlertAction(title: "キャンセル", style: .cancel))
            self.present(alertController, animated: true)
        }
    }
}
```

以上の実装で、新しいバージョンがリリースされたことをユーザに伝えることができるようになりました。

# Frameworkを作りました

半強制アップデートの仕組みを提供するFrameworkを作りました。以下の続編をご一読ください。

https://qiita.com/hituziando/items/1cb8914138a5ec3b6e6a