初めに
- 個人開発アプリをリリースしたが、今後の追加実装のことを考えると強制アップデートを実装したいと考えた。
- 隙間時間に開発しているため、可能な限り簡単にすましたかった。特に今回はバックエンドのサーバがないアプリだったため、別途サーバを用意するなどはしたくなかった。
- そこで、【iOS】半強制アップデートの仕組みをカジュアルに実装するという記事を見つけ、この方法ならアプリ側だけで実装できると思い、採用に至った。
- 強制でなく、半強制な理由は、頻繁にアップデートをした時に強制アップデートの場合はユーザの負担になると考えたため。
- ストアのアップデートを公開すれば半強制アップデートも自動で反応するので運用も楽。
- 参考にした記事では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ではアプリの更新情報も取得できるので、半強制アップデートの際にそれを見せるということもできるだろう。
この半強制アップデートを実装したアプリの宣伝
- セットリーはセトリ(セットリスト)と呼ばれる、ライブで演奏した曲の曲名一覧をもとに、Apple Musicのプレイリストを一括作成できるアプリ。
- ライブやフェスが好きな人は、よくライブ後にセトリを見ながらプレイリストを作っているが、そのために一曲ずつ検索しながら曲を追加していくのが負担になっていると考え、そこを自動化するアプリを作った。
セトリ自体はSNSやLiveFunsというサイトで共有されているため、あとはセットリーさえあれば簡単にプレイリストが楽しめる。
この記事を読んでいるライブ好きでApple Musicを使っている人は是非ダウンロードお願いします!
Twitterもやってるので、よければフォローお願いします。
→https://twitter.com/ObataGenta