「成功するまでリトライし続ける処理」をJobSchedulerで実装する

## 背景
現在開発に携わっているAndroidアプリでは、Firebase Cloud Messaging（FCM）を利用してプッシュ通知を送信しています。
通知内容はお気に入りに登録している商品のアップデート情報などユーザと関連付いたものであるため、APIサーバ側でユーザと登録トークンの紐づけ処理が必要となります。

当初はログイン時にAPIに対してEmailとパスワードに加えて登録トークンを送信するという実装にしていました。
こうすることでユーザと登録トークンの紐付けを確実に行うことができるのですが、まれにログイン時点で登録トークンがまだ発行されておらず、ヌルポでクラッシュするという問題が起きていました。
そこで、Emailとパスワードだけでログインするようにして、登録トークンは発行されたタイミングでバックグラウンドでAPIに送信するように変更することにしました。

登録トークンを確実にAPIに送信する、つまり**「成功するまでリトライし続ける」**処理をどのように実装しようかといろいろ調べた結果、JobSchedulerが自分の要件にマッチしました。

## 要件
今回の具体的な要件は以下の通りです。

- ユーザの操作の邪魔にならないようにしたい（ユーザに登録トークンの送信完了を待たせない）
- アプリがバックグラウンドになってもリトライし続けたい
- 送信に失敗したら即リトライではなく、間隔を空けたい
- ネットワーク通信が有効なときだけリトライしたい

こうした要件にマッチするのがJobSchedulerです。

## 実装
JobSchedulerはキューに追加されたジョブ（JobInfoオブジェクトで表現）を管理し実行するコンポーネントです。
アプリはJobSchedulerに対してジョブの登録を行うだけで、ジョブの開始やリトライはジョブの定義に従ってJobSchedulerがいい感じにやってくれます。

実装のメインとなるのは、JobServiceを継承したクラスを作ることです。
JobSchedulerによってジョブが開始されると、JobServiceの`onStartJob()`メソッドが呼ばれます。

FCMトークンをAPIに送信する具体的な実装はこんな感じです。
実装の意味は各コメントを参考にしてください。

```kotlin:FcmTokenJobService.kt
class FcmTokenJobService: JobService() {
    companion object {
        // JobInfoのファクトリメソッド
        // Activityから呼ぶ
        fun makeJobInfo(context: Context): JobInfo {
            return JobInfo.Builder(JobId.FCM_TOKEN, ComponentName(context, FcmTokenJobService::class.java))
                    // (1) ジョブを即時開始する
                    .setMinimumLatency(1)
                    .setOverrideDeadline(1)
                    // (2) ネットワークが接続されていることをジョブ開始の条件とする
                    .setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY)
                    .build()
        }
    }

    override fun onStartJob(job: JobParameters): Boolean {
        // (3) SharedPreferencesからトークンと未送信フラグを取得する
        val token = Prefs.fcmToken
        val isTokenSynced = Prefs.isFcmTokenSynced

        // (4) トークンがAPIに送信済みであればジョブを終了する
        if (isTokenSynced) {
            return false
        }

        // (5) トークン未発行もしくはログインしていない場合はジョブをリトライする
        if (token == null || Prefs.email == null) {
            jobFinished(job, true)
            return true
        }

        val body = RegisterFcmTokenBody(token)
        API.shared.registerFcmToken(body).enqueue(RequestCallback(job))

        // ジョブが継続中であることを伝える（ジョブの終了/リトライ判定はRequestCallbackで行う）
        return true
    }

    override fun onStopJob(job: JobParameters): Boolean {
        // ジョブ実行中にネットワーク接続が切れたらリトライする
        return true
    }

    inner class RequestCallback(private val job: JobParameters): Callback<Response> {
        override fun onResponse(call: Call<Response>, response: Response<Response>) {
            if (response.isSuccessful) {
                // (6) 送信完了したらジョブを終了する
                Prefs.isFcmTokenSynced = true
                jobFinished(job, false)
            } else {
                // (7) エラーレスポンスが返ってきた場合はリトライする
                jobFinished(job, true)
            }
        }

        override fun onFailure(call: Call<DefaultResponseBody>, t: Throwable) {
            // ネットワークエラーやタイムアウトの場合はリトライする
            jobFinished(job, true)
        }
    }
}
```

JobServiceの実装ができたら、Activityでジョブの登録を行います。
ここではLoginActivityでログインに成功したらジョブを登録するという想定の実装を載せます。

```kotlin:LoginActivity.kt
    override fun onCreate(savedInstanceState: Bundle?) {
    ...

        // LiveDataを監視
        // ログインに成功したら呼ばれる
        viewModel.onLoggedIn.observe(this, Observer {
            scheduleFcmTokenJob()
            val intent = Intent(this@LoginActivity, MainActivity::class.java)
            startActivity(intent)
        })
    }

    private fun scheduleFcmTokenJob() {
        // ファクトリメソッドでJobInfoを生成
        val job = FcmTokenJobService.makeJobInfo(this)
        // JobSchedulerにジョブを登録する
        (getSystemService(Context.JOB_SCHEDULER_SERVICE) as JobScheduler).schedule(job)
    }
```

最後に、AndroidManifestに以下を追記しておきます。

```AndroidManifest.xml
<service
        android:name=".jobs.FcmTokenJobService"
        android:permission="android.permission.BIND_JOB_SERVICE">
</service>
```

## 具体的な動きの説明
上記の実装によりどのような動きになるのか説明します。

JobSchedulerにジョブを登録すると、即座にジョブが開始されます**（1）**。

最初にSharedPreferencesに書き込まれている登録トークンと未送信フラグを確認します**（3）**。
フラグは新しい登録トークンが発行されたときに未送信状態になるようにしています。
フラグが送信済になっていたらすぐにジョブを終了します**（4）**。
これで無駄にジョブが実行されることを防ぎます。

次に、トークンとログインの有無を確認します**（5）**。
トークンが発行されていない、またはログインしていない場合は、ジョブをリトライするようにします。

ここでリトライの間隔について触れておきます。
上記の実装では隠れていますが、ジョブのリトライ間隔を決めるアルゴリズムとしてエクスポネンシャルバックオフというものがデフォルトで使われます。
AWSユーザであればピンとくる仕組みですね。

具体的には、ジョブの初回実行後、次の実行はその30秒後、その次の実行は前の実行の60秒後、120秒後、240秒後...という感じでリトライ間隔が長くなって行きます。
これはアプリやAPIサーバの負荷を抑えるために重要な仕組みです。

「エクスポネンシャルバックオフ」でググると解説記事がたくさん出てきますが、参考リンクを載せておきます。

- [クラウド・ネイティブのお作法（2）「リトライ」～効率的なリトライ手法「Exponential Backoff and jitter」とは何か](https://codezine.jp/article/detail/10739)
- [Exponential Backoff And Jitter](https://aws.amazon.com/jp/blogs/architecture/exponential-backoff-and-jitter/)

トークンが発行済かつログイン済みであれば、登録トークンをAPIに送信します。
リクエストが成功すればジョブを終了し、失敗したらリトライするようにします**（6）（7）**。

なお、「ネットワーク通信が有効なときだけリトライしたい」という要件についてですが、これを実現する設定を**（2）**で行っています。
この設定では、ネットワーク接続が切れている状態ではリトライはされず、ネットワークが接続されたらすぐにリトライするという動きになります。

## 考慮事項とまとめ
実は上記実装だけではまだ不完全で、端末がリブートされるとジョブのリトライは再開されません。
少し実装を追加するだけでリブートしてもリトライし続けるようにすることはできるのですが、私の実装が悪いのか再起動後にジョブが開始されるとクラッシュするという事象が起きてしまい、今回は断念しました。

代わりに、アプリを再起動したらジョブを登録するという処理を追加しています。
JobSchedulerはジョブIDが同じジョブが登録されたとき、既存のジョブを上書きしてくれます。
これにより、2重にジョブが実行されることはありません。

以上により、**「成功するまでリトライし続ける処理」**が実現できます。

結構手軽に実装できてよかったなと思っているのですが、考慮が足りていない点やもっとスマートな実装を知っている方がいたら是非教えてください。