SwiftUIとCombineで半強制アップデートを実装する

# 初めに
- 個人開発アプリをリリースしたが、今後の追加実装のことを考えると強制アップデートを実装したいと考えた。
- 隙間時間に開発しているため、可能な限り簡単にすましたかった。特に今回はバックエンドのサーバがないアプリだったため、別途サーバを用意するなどはしたくなかった。
- そこで、[【iOS】半強制アップデートの仕組みをカジュアルに実装する](https://qiita.com/hituziando/items/fc2ad0642ccdb1258840)という記事を見つけ、この方法ならアプリ側だけで実装できると思い、採用に至った。
    - 強制でなく、半強制な理由は、頻繁にアップデートをした時に強制アップデートの場合はユーザの負担になると考えたため。
    - ストアのアップデートを公開すれば半強制アップデートも自動で反応するので運用も楽。
- 参考にした記事ではAlamofireとUIKitを利用していたが、自分のアプリに合わせてSwiftUIとCombineで書き直した。


# 半強制アップデートの仕組み

- appleが用意しているapiを利用すると、app storeで現在公開されているアプリのバージョンを取得することができる。
- アプリ起動時にapp storeで現在公開されているアプリのバージョンと、今インストールされているアプリのバージョンを比較し、インストールされているバージョンのほうが小さければアップデートを促す。
- アップデートの促しはスキップもできる
- 一度促した後は再度アプリを開いても日付が変わるまではアップデートの促しは表示されない。

# 実装

```AppVersionModel.swift
class AppVersionModel: BaseModelProtcol {
    let host = "itunes.apple.com"
    let basePath = "/"
    var cancellables = Set<AnyCancellable>()
    let params = ["id": "1234567890"]
    let lastCheckedDateKey = "last_checked_date_key"

    /*
        https://itunes.apple.com/lookup?id=1234567890にgetリクエストをするとストアに公開されている情報が取得できる
    */
    private func getAppStoreVersion() -> AnyPublisher<Double, Error> {
        return self.resumePublisher(path: "lookup", method: .get, params: params, responseType: AppStoreResponse.self)
            // NOTE: 取得できなかった時は必ずストアバージョンの方が若くなるように0を渡す
            .map { $0.results.isEmpty ? 0.0 : NSString(string: $0.results[0].version).doubleValue }
            .eraseToAnyPublisher()
    }

    /*
        レスポンスのjsonをマッピングする構造体
    */
    struct AppStoreResponse: Codable {
        let results: [Results]

        struct Results: Codable {
            let version: String
        }
    }

    /*
        アップデートを促した日を保存する  
    */
    func setLastCheckedDate() {
        UserDefaults.standard.set(self.getCurrentDate(), forKey: lastCheckedDateKey)
    }

    func getLastCheckedDate() -> Int {
        UserDefaults.standard.integer(forKey: lastCheckedDateKey)
    }

    func getCurrentDate() -> Int {
        let formatter = DateFormatter()
        formatter.calendar = Calendar(identifier: .gregorian)
        formatter.locale = .current
        formatter.dateFormat = "yyyyMMdd"
        return Int(formatter.string(from: Date())) ?? 0
    }


    /*
        アップデートを促すべきならtrueをパブリッシュするメソッド  
    */
    func shouldRequestUpdate() -> AnyPublisher<Bool, Never> {
        let lastCheckDate = getLastCheckedDate()
        let currentDate = getCurrentDate()

        // NOTE: アップデート確認した日を保存することで、同じ日に2回アップデートを促さないようにする
        self.setLastCheckedDate()
        /*
            最後に確認した日が今日と同じならアップデートは促さない
        */
        if lastCheckDate == currentDate {
            return Future { promise in
                promise(.success(false))
                return
            }.eraseToAnyPublisher()
        }
        return self.getAppStoreVersion()
            .replaceError(with: 0)  //エラーの場合はとりあえずアップデートを促さない様に0に置き換える
            .map { return $0 > Bundle.appVersion }
            .eraseToAnyPublisher()
    }
}

extension Bundle {
    static var appVersion: Double {
        // NOTE: 取得できなかった時は必ずストアバージョンの方が若くなるように9999.9を渡す
        guard let infoDictionary = Bundle.main.infoDictionary else {
            return 9999.9
        }
        let version = infoDictionary["CFBundleShortVersionString"] as? NSString
        guard let version = version else {
            return 9999.9
        }
        return version.doubleValue
    }
}
```

``` BaseModelProtcol.swift
import Combine
import Foundation

protocol BaseModelProtcol {
    var scheme: String { get }
    var host: String { get }
    var basePath: String { get }
    var cancellables: Set<AnyCancellable> { get set }

    func parseGetParams(params: [String: Any]) -> [URLQueryItem]
}

extension BaseModelProtcol {
    var scheme: String {
        "https"
    }

    func resumePublisher<T: Codable>(path: String, method: HTTPMethod, params: [String: Any], responseType: T.Type, headers: [HTTPHeader] = []) -> AnyPublisher<T, Error> {
        var urlComponents = URLComponents()
        urlComponents.scheme = scheme
        urlComponents.host = host
        urlComponents.path = basePath + path
        if method == .get {
            urlComponents.queryItems = self.parseGetParams(params: params)
        }

        guard let url = urlComponents.url else {
            let error = RequestError.parse(description: "wrong request url")
            return Fail(error: error).eraseToAnyPublisher()
        }

        var request = URLRequest(url: url)
        request.httpMethod = method.rawValue

        if method == .post {
            do {
                request.httpBody = try JSONSerialization.data(withJSONObject: params, options: .prettyPrinted)
            } catch {
                print(error.localizedDescription)
            }
        }

        headers.forEach { header in
            request.setValue(header.value, forHTTPHeaderField: header.field)
        }

        return URLSession.shared
            .dataTaskPublisher(for: request)
            .tryMap {
                if let statusCode = ($0.response as? HTTPURLResponse)?.statusCode {
                    if statusCode != 200 && statusCode != 201 && statusCode != 204 {
                        throw RequestError.response(description: "\(statusCode) error")
                    }
                }
                return $0.data
            }
            .decode(type: T.self, decoder: JSONDecoder())
            .eraseToAnyPublisher()
    }

    func parseGetParams(params: [String: Any]) -> [URLQueryItem] {
        return params.map {
            if let value = $0.value as? String {
                return URLQueryItem(name: $0.key, value: value)
            }
            return URLQueryItem(name: $0.key, value: "")
        }
    }
}

enum RequestError: Error {
    case parse(description: String)
    case request(description: String)
    case network(description: String)
    case response(description: String)
}

extension RequestError: LocalizedError {
    var errorDescription: String? {
        switch self {
        case let .parse(description):
            return description

        case let .request(description):
            return description

        case let .network(description):
            return description

        case let .response(description):
            return description
        }
    }
}
```

```HomeViewModel.swift
import Combine
import Foundation
import SwiftUI

class HomeViewModel: ObservableObject {
    @Published var isShowAlert: Bool = false
    @Published var alertType: AlertType = .success

    private var appVersionRequest = AppVersionRequest()
    var cancellables = Set<AnyCancellable>()

    init() {
        /*
            アプリ起動時に初期化されるviewModelの初期化処理でアップデートの確認を行う
        */
        self.checkUpdate()
    }

    func checkUpdate() {
        self.appVersionRequest.shouldRequestUpdate()
            .sink(receiveValue: { shouldRequestUpdate in
                if shouldRequestUpdate {
                    /*
                        アップデートを促すべきならアラート表示フラグとアラートタイプを指定する。
                    */
                    self.isShowAlert = true
                    self.alertType = .hasUpdate
                }
            })
            .store(in: &cancellables)
    }

    enum AlertType {
        case hasUpdate
    }
}
```

```HomeView.swift
import SwiftUI

struct HomeView: View {
    @ObservedObject var viewModel = HomeViewModel()

    var body: some View {
        VStack {
            // 省略
        }
        .alert(isPresented: $viewModel.isShowAlert) {  // アラート表示フラグとアラートタイプで複数のアラートを出し分けている
            switch viewModel.alertType {
            // 実際には他のalertTypeの分case文を書いている
            case .hasUpdate:
                return Alert(title: Text("新しいバージョンがあります"),
                             message: Text("アプリをより便利に使うためにアップデートをお願いします！"),
                             primaryButton: .default(Text("App Stpreを開く"), action: {
                                viewModel.isShowAlert = false
                                if let url = URL(string: "https://apps.apple.com/jp/app/1234567890/id1234567890") {
                                    if UIApplication.shared.canOpenURL(url) {
                                        UIApplication.shared.open(url, options: [:], completionHandler: nil)
                                    }
                                }
                             }),
                             secondaryButton: .cancel(Text("あとで"), action: {
                                viewModel.isShowAlert = false
                             }))
            }
        }
    }
}
```

# 最後に
- 今回はバージョンの単純な大小だけで比較を行ったが、`1.7.5`のようなバージョンの`1`の部分が変化したらメジャーアップデートとみなして強制アップデートをさせるといった実装も考えられる。
- バージョンを取得するapiではアプリの更新情報も取得できるので、半強制アップデートの際にそれを見せるということもできるだろう。


# この半強制アップデートを実装したアプリの宣伝

![Twitter.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/268579/09058abf-24b3-fcd2-5c8c-53aab334f9c0.png)

- セットリーはセトリ(セットリスト)と呼ばれる、ライブで演奏した曲の曲名一覧をもとに、Apple Musicのプレイリストを一括作成できるアプリ。
- ライブやフェスが好きな人は、よくライブ後にセトリを見ながらプレイリストを作っているが、そのために一曲ずつ検索しながら曲を追加していくのが負担になっていると考え、そこを自動化するアプリを作った。
セトリ自体はSNSや[LiveFuns](https://www.livefans.jp/)というサイトで共有されているため、あとはセットリーさえあれば簡単にプレイリストが楽しめる。

**この記事を読んでいるライブ好きでApple Musicを使っている人は是非[ダウンロード](https://apps.apple.com/us/app/settly-%E3%82%BB%E3%83%83%E3%83%88%E3%83%AA%E3%83%BC/id1585803361)お願いします！**




Twitterもやってるので、よければフォローお願いします。
→https://twitter.com/ObataGenta