81
63

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

iOS, Androidアプリの強制アップデート(サーバーレス)

Last updated at Posted at 2020-01-25

はじめに

文字列→塩基配列の相互変換ツールをつくってみた(アプリ版)でつくったアプリ

これに強制アップデート機能(半強制で抜け道あり)をつけてみました。

特に必要なわけではないですがこの記事([iOS]アプリに強制アップデート機能を導入すべき理由と、簡単に実装する方法)をみてやりたいと思い実装してみました。

が!!サーバーを用意するのはめんどくさいと思いサーバーなしで強制アップデート機能のようなものをつけてみました。

iOS&Mac

方法

iTunes Search APIというのがあるらしくこれを使うとアプリの情報が取れるそうです。

下記のURLのアプリIDに指定のアプリを設定するとそのアプリ情報が取得できます。

https://itunes.apple.com/lookup?id=[アプリID]

取得した情報からバージョンをみてBundleのバージョンと比較してアプリストアに遷移させるようにすれば強制アップデートのようなことができます。

ソース

iOS&Macアプリソース

AppStoreModel.swift
struct AppStoreModel {
    private let version = Version(version: Bundle.main.version!)
    private var appId: String {
        #if targetEnvironment(macCatalyst)
        return "1494127578"
        #else
        return "1493994947"
        #endif
    }
    private var url: URL {
        return URL(string: "https://itunes.apple.com/lookup?id=\(appId)")!
    }
    var appStoreURL: URL {
        return URL(string: "itms-apps://itunes.apple.com/app/id\(appId)")!
    }

    func checkVersion(completion: @escaping ((Result<AppVersionState, AppVersionError>) -> ())) {
        let task = URLSession.shared.dataTask(with: url) { (data, response, error) in
            if let _ = error {
                completion(.failure(.network))
                return
            }
            guard let data = data else {
                completion(.failure(.invalidData))
                return
            }
            do {
                let appVersion = try JSONDecoder().decode(AppVersion.self, from: data)
                if let version = appVersion.version,
                    Version(version: version) > self.version {
                    completion(.success(.shouldUpdate))
                } else {
                    completion(.success(.noUpdate))
                }
                
            } catch {
                completion(.failure(.invalidJSON))
            }
        }
        task.resume()
    }
}

struct AppVersion: Decodable {

    struct Result: Codable {
        let version: String
        let trackName: String
    }

    let name: String?
    let version: String?
    private enum CodingKeys: String, CodingKey {
        case results = "results"
    }

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        let results = try container.decodeIfPresent([Result].self, forKey: .results)
        name = results?.first?.trackName
        version = results?.first?.version
    }
}

struct Version {
    let major: Int
    let minor: Int
    let revision: Int
}

extension Version {
    init(version: String) {
        let versions = version.components(separatedBy: ".")
        self.major = versions[safe: 0].flatMap { Int($0) } ?? 0
        self.minor = versions[safe: 1].flatMap { Int($0) } ?? 0
        self.revision = versions[safe: 2].flatMap { Int($0) } ?? 0
    }

    static func > (lhs: Version, rhs: Version) -> Bool {
        if lhs.major > rhs.major {
            return true
        }
        if lhs.major < rhs.major {
            return false
        }
        // lhs.major == rhs.major
        if lhs.minor > rhs.minor {
            return true
        }
        if lhs.minor < rhs.minor {
            return false
        }
        // lhs.major == rhs.major && lhs.minor == rhs.minor
        if lhs.revision > rhs.revision {
            return true
        }
        return false
    }
}

enum AppVersionError: Error {
    case network
    case invalidData
    case invalidJSON
}

enum AppVersionState {
    case shouldUpdate
    case noUpdate
}

extension Bundle {
    var version: String? {
        return Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
    }
}

SceneDelegatefunc scene(_ scene: UIScene, willConnectTo... に下記を記載

SceneDelegate.swift
guard let _ = (scene as? UIWindowScene) else { return }
let appStoreModel = AppStoreModel()
        appStoreModel.checkVersion { [weak self] result in
            DispatchQueue.main.async {
                if case .success(.shouldUpdate) = result {
                    let alertController = UIAlertController(title: "アップデート", message: "", preferredStyle: .alert)
                    let action = UIAlertAction(title: "OK", style: .default,
                                               handler:
                        { _ in
                            UIApplication.shared.open(appStoreModel.appStoreURL)
                    })
                    alertController.addAction(action)
                    self?.window?.rootViewController?.present(alertController, animated: true)
                }
            }
        }

なんかめっちゃ長くなった...

最初は Version(version: version) > self.version このバージョン比較を version != self.version にしてたのですがこれだと審査のときに常にアラートが表示されリジェクトされました:sob:

この方法はストアに遷移したあとにもう一回アプリを表示したら普通に使えるので強制アップデートとまではいえませんが、アップデートを促すことはできるのでまあいいかな。

Android

方法

in-app Updates APIというのがあるのでこれを使えばいい感じにやってくれるみたいです。

使えるのはAndroid 5.0 (API level 21) 以上です。

下記の参考サイトに丁寧に書いてくれています:tada:

ソース

Androidアプリソース

ソースも参考サイトにあるのですが少しつまずきました...

manager.appUpdateInfo.addOnCompleteListener { task ->
            val info = task.result
            when (info.updateAvailability()) {
                UpdateAvailability.UPDATE_AVAILABLE -> {
                    manager.startUpdateFlowForResult(info, AppUpdateType.FLEXIBLE, activity, REQUEST_CODE)
                }
                else -> {
                }
            }
        }

上記のような実装をしているとエミュレータで実行すると下記のようなエラーが発生しました。

com.google.android.play.core.tasks.RuntimeExecutionException: com.google.android.play.core.internal.aa: Failed to bind to the service.
        at com.google.android.play.core.tasks.l.getResult(Unknown Source:18)
        at am10.dnaconverter.models.AppUpdateModel$checkAppVersion$1.onComplete(AppUpdateModel.kt:19)
        at com.google.android.play.core.tasks.a.run(Unknown Source:23)
        at android.os.Handler.handleCallback(Handler.java:883)
        at android.os.Handler.dispatchMessage(Handler.java:100)
        at android.os.Looper.loop(Looper.java:214)
        at android.app.ActivityThread.main(ActivityThread.java:7356)
        at java.lang.reflect.Method.invoke(Native Method)
        at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:492)
        at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:930)
     Caused by: com.google.android.play.core.internal.aa: Failed to bind to the service.
        at com.google.android.play.core.internal.t.b(Unknown Source:82)
        at com.google.android.play.core.internal.t.a(Unknown Source:0)
        at com.google.android.play.core.internal.v.a(Unknown Source:4)
        at com.google.android.play.core.internal.r.run(Unknown Source:0)
        at android.os.Handler.handleCallback(Handler.java:883)
        at android.os.Handler.dispatchMessage(Handler.java:100)
        at android.os.Looper.loop(Looper.java:214)
        at android.os.HandlerThread.run(HandlerThread.java:67)

これは一部の実機でも起こるようで addOnCompleteListener を使う場合は task が成功したかを下記のようにしっかりチェックしないといけないようです:see_no_evil:

manager.appUpdateInfo.addOnCompleteListener { task ->
            if (task.isSuccessful) {
                val info = task.result
                when (info.updateAvailability()) {
                    UpdateAvailability.UPDATE_AVAILABLE -> {
                        manager.startUpdateFlowForResult(info, AppUpdateType.FLEXIBLE, activity, REQUEST_CODE)
                    }
                    else -> {
                    }
                }
            } else {
                task.exception.printStackTrace()
            }
        }

参考サイトは addOnSuccessListener を使ってました:hear_no_evil:

全体としては下記のような実装になりました。

gradleに下記を追加

implementation 'com.google.android.play:core:1.6.4'
implementation 'com.google.android.material:material:1.0.0' // Snackbar用
AppUpdateModel.kt
class AppUpdateModel(context: Context) {
    val manager = AppUpdateManagerFactory.create(context)
    var listener: InstallStateUpdatedListener? = null
    val REQUEST_CODE = 100
    fun checkAppVersion(activity: Activity, callback: (() -> (Unit))?) {
        listener = makeListener(callback)
        manager.registerListener(listener)
        manager.appUpdateInfo.addOnSuccessListener { info ->
            when (info.updateAvailability()) {
                UpdateAvailability.UPDATE_AVAILABLE -> {
                    manager.startUpdateFlowForResult(info, AppUpdateType.FLEXIBLE, activity, REQUEST_CODE)
                }
                else -> {
                }
            }
        }
    }

    // ミス(20200126修正ここもCompleteじゃなくてSuccessじゃないと落ちる)
    fun addOnSuccessListener(callback: (() -> (Unit))?) {
        manager.appUpdateInfo.addOnSuccessListener { info ->
            if (info.installStatus() == InstallStatus.DOWNLOADED) {
                callback?.invoke()
            }
        }
    }

    fun completeUpdate() {
        manager.completeUpdate()
    }

    private fun makeListener(callback: (() -> (Unit))?) : InstallStateUpdatedListener {
        return InstallStateUpdatedListener {
            if (it.installStatus() == InstallStatus.DOWNLOADED) {
                callback?.invoke()
                manager.unregisterListener(listener)
            }
        }
    }
}
MainActivity.kt
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        appUpdateModel = AppUpdateModel(this)
        appUpdateModel.checkAppVersion(this) {
            popupSnackbarForCompleteUpdate()
        }
     }

    override fun onResume() {
        super.onResume()
        appUpdateModel.addOnSuccessListener {
            popupSnackbarForCompleteUpdate()
        }
    }
    
    private fun popupSnackbarForCompleteUpdate() {
        Snackbar.make(findViewById(R.id.root_layout),
            "ダウンロード完了", Snackbar.LENGTH_INDEFINITE)
            .setAction("更新") {
                appUpdateModel.completeUpdate()
            }
            .show()
    }

Google Playストアのアプリが更新判定を行うらしいのでどの時点で更新情報が受け取れるのかはわかりません。

これも戻るボタンとかで回避できるらしいので強制アップデートとまではいえないかもしれません。(そもそもGoogle Playストアのアプリがないと取れない?)

20200127追記

やっぱりストアに公開されてから即時反応というわけではなく、2端末(Android 6.0.1, 9.0)で確認したところ、公開されてから2~3時間後は特に反応はなく、10時間後に再度アプリを起動したところ無事下記の画面が表示されました:confetti_ball:

update

おまけ

サーバー用意してバージョン情報のJSONファイル置くのめんどくさいと思って今回の方法で実装しましたが、GitHubにJSONファイル置けばいいんじゃね?ふと思いました。(iOSアプリをリリースしてればもしかしたらプライバシーポリシー用にGitHub使ってるかもしれないですし)

試しにJSONファイル置いてるリポジトリでやってみたらJSON取れました:tada:

https://raw.githubusercontent.com/adventam10/TestApplicationArchitecture/master/TestWeatherApplication/TestWeatherApplication/Resource/CityData.json

github.com のところを raw.githubusercontent.com するといけそうです!!

さいごに

特にこのアプリに強制アップデート必要ないですが、試しに実装してみましたmm

関係ないですがAndroidのアプリを最適化しようと思って下記をgradleに追加したらサイズがめっちゃ小さくなりました。

release {
    minifyEnabled true
    shrinkResources true
    proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
before after
before after

アプリサイズ 50%OFF !!!

81
63
1

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
81
63

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?