13
10

More than 1 year has passed since last update.

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

Last updated at Posted at 2020-11-16

はじめに

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

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

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

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

iTunes Search APIを利用した実装

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

No Title.png

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

iTunes Search API

最新バージョンを返すサーバを自前で用意しても良いですが、ここではiTunes Search APIを利用します。このAPIを使うと、App Storeで配信されているアプリの情報を無料で取得することができます。アプリ情報にはApp Storeでリリースされているバージョン情報も含まれています。

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

AppStoreクラス - APIを利用する

僕は以下のようなiTunes Search APIを叩くAppStoreクラスを実装しています。(通信ライブラリにAlamofire v5を使用しています)

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

アプリバージョンの比較

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

AppStoreクラスを使う

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

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

Frameworkを作りました

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

13
10
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
13
10