Hituzi Ando's Blog

日々のアプリ開発についてや雑記など。

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

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

強制アップデートはゲームアプリなどでよく見る仕組みですね。強制力の強いものだと、アップデートするまで、アプリを利用できなくするものもありますが、必ずしもアップデートできる通信環境に居るとは限らないため(地下鉄に乗っているときなど辛い)、アップデートを促すまでに留めた半強制くらいが僕は好みです。

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

アップデートは放っておいてもされていくものと思っていましたが、計測してみると意外にも乖離していることが分かりました。僕が開発しているiOSアプリのBlue Sketchの例を見ても、バージョンのばらつきが見受けられます(離脱ユーザも含まれているため、正確な数字ではないですが)。

ver. インストールされている割合
1.0.4 3%
1.0.5 9%
1.0.6 51%
1.0.7 5%
1.0.8 32%

Blue Sketchのインストールバージョンの分布

この差を埋めるため、以下のようなAppStoreクラスを実装しBlue Sketchにも導入しています。

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.clyr.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
        }
    }

    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>"
    }

    static var storeURLString: String {
        "https://apps.apple.com/jp/app/XXXXXXX/id" + iTunesID
    }

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

    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)
        }
    }
}

App Storeでリリースされているバージョンの確認には、iTunes Search APIを利用しています。このAPIを使うと、App Storeで配信されているアプリの情報を無料で取得することができます。また、通信ライブラリはAlamofire v5を使用しています。

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

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

f:id:hituziando:20200209154257p:plain
iTunesIDを調べる

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

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

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)
        }
    }
}

以上の実装で、新しいバージョンがリリースされたことをユーザに伝えることができるようになりました。半強制アップデートの仕組みを導入したことでアップデートが促進されるか楽しみです。