クックパッドマートアプリのログインの裏側〜Android アプリの実装を添えて〜

こんにちは。クックパッドマートの Android アプリを開発しています、門田です。
この記事は「クックパッドマートを支えるアカウントたち」の連載記事3日目です。

1日目: クックパッドマートを支えるアカウントたち - クックパッド開発者ブログ
2日目: クックパッドマートアプリにおけるログイン体験の実現

今回は、2日目の記事の中であった「ログイン画面の状態管理」について、実際の Android アプリの実装の紹介を交えながら説明します。

おさらい

クックパッドマートにログイン機能が導入されました。 これは、メールアドレスや電話番号を利用してクックパッドのユーザーを作成して、そのユーザーにログインすることで、機種変更時などにユーザーデータを引き継げるようにするものでした。 その際、ログイン前のユーザーデータをログイン後のユーザーに統合する必要があり、その処理が非同期で行われるために画面の見せ方などを工夫しているという話を前日の記事でご紹介しています。

クックパッドマートでは、アプリを利用し始める際に「受け取り場所」を設定する必要があります。これは、サービスの特性上それぞれの「受け取り場所」によってその日配送できる(注文できる)商品が異なる場合があり、どういう商品が受け取れるのかをユーザーに正しく見せるために設定しています。

たとえばここで、ログイン先のユーザーが一度もクックパッドマートを利用したことがないとします。 その場合、ユーザーデータを統合する処理が非同期で行われているため、ログインをしたときにユーザーの「受け取り場所」が一時的に設定されていない状態になってしまいます。 このままアプリを利用するためには、もう一度ユーザーに「受け取り場所」を設定してもらう必要があります。しかし、それではユーザーの行動を阻害してしまうことになり、サービスのコアの体験を損なってしまうことが懸念として挙げられていました。

そこで、「ログイン時に非同期のデータ統合処理が完了するまで待ち合わせる」という方針で仕様を整理し、アプリの実装を行うことにしました。

ここから本題

さて、「非同期のデータ統合処理が完了するまで待ち合わせる」というのをどのように実現すると良いでしょうか。 サーバーは「データ統合が完了したか」をなんらかの手段でアプリに返す必要があります。アプリは、その情報を元に状態を更新していけば良さそうです。

しかし、データ統合の処理は数十秒〜数分かかる場合があります。一度問い合わせればそれで良いというものでもなく、かといって待ち続けていても必ず終わる保証がありません。サーバーで何らかの問題があり統合処理が失敗してしまう可能性もあります。 ユーザーが待ちきれずにアプリを閉じたり画面をオフにしたり、はたまた電波の状況が悪くなりサーバーから情報を取得できなくなることもあるでしょう。

これを解決するには、シンプルに考えて以下の方法があると思います。

  • 定期的な間隔で API を叩き、処理が完了したことをサーバーから受け取る
  • 双方向通信を行うような仕組みを導入し、サーバーからデータを受け取る

この画面のためだけに双方向の通信を実現するのはさすがにオーバースペックだったため、今回のケースでは前者を採用しました。 ただ、闇雲に繰り返しを実装するだけでは一生終わらない可能性もあります。ユーザーも呆れ果てて離脱してしまうかもしれません。ということで、この繰り返しには十分な期間を設け、その期間が終了するまでに完了を確認できなかった場合はエラーにするという形にしました。これによって、無限ループに陥る状況を回避できます。 データの統合に失敗してしまった場合は、失敗された内容を検出するためのログを送信しつつ、ユーザーにはお問い合わせへの誘導を行うようにしています。

ここから実装の話

さて、ここからは具体的な実装の話に移っていきます。 これまでの内容をもとに、ここまでで話されていなかった箇所のエラー処理も含めて、アプリの動作を状態遷移図を書いてみると、こうなります。

この状態を Android アプリでは実際に以下のようなコードで表現しています。

sealed interface LoginState {
    /* 画面を初めて開いたときの状態 */
    object Initial : LoginState

    /* ログインボタンを押してログインを試行している状態 */
    object Login : LoginState

    /* ユーザーデータの統合処理を待っている状態 */
    class DataMigrationWaiting(val startedAt: Long) : LoginState

    /* ログインが成功した状態 */
    object LoginSucceeded : LoginState

    /* ログインが失敗した状態 */
    class LoginFailed(failureReason: String): LoginState
}

ユーザーデータの統合処理を待つ状態は繰り返す可能性があるので、自分自身に状態を戻すように定義しました。そして、「ログイン失敗」と「タイムアウト」は LoginFailed としてまとめ、失敗理由で分岐できるようにしました。

そして、ログインフローの処理は以下のように書いています。

class LoginViewModel(private val apiClient: ApiClient) : ViewModel() {
    private val _loginState = MutableStateFlow<LoginState>(LoginState.Initial)
    val loginState = _loginState.asStateFlow()

    fun login(credentials: Credentials) {
        _loginState.update { LoginState.Login }

        viewModelScope.launch {
            _loginState.update {
                runCatching { apiClient.login(credentials) }
                    .map {
                        LoginState.DataMigrationWaiting(
                            startedAt = System.currentTimeMillis()
                        )
                    }
                    .getOrElse { LoginState.LoginFailed("login failed") }
            }

            waitDataMigration()
        }
    }

    private suspend fun waitDataMigration() {
        while (_loginState.value is LoginState.DataMigrationWaiting) {
            val state = _loginState.value as LoginState.DataMigrationWaiting
            val newState = _loginState.updateAndGet {
                when {
                    apiClient.isDataMigrationFinished() ->
                        LoginState.LoginSucceeded
                    isOverDataMigrationTimeLimit(state.startedAt) -> 
                        LoginState.LoginFailed("data migration time limit over")
                    else ->
                        dataMigrationWaitingState
                }
            }

            if (newState is LoginState.DataMigrationWaiting) {
                delay(DELAY_TIME) // 次のループまで適当に数秒待つ
            }
        }
    }

    fun confirmLoginFailedError() {
        _loginState.update { LoginState.Initial }
    }

    private fun isOverDataMigrationTimeLimit(startedAt: Long): Boolean =
        (System.currentTimeMillis() - startedAt).absoluteValue >= TIME_LIMIT
}

多少簡略化はしていますが、概ね上のような実装になっています。 注目ポイントはデータ統合待ちの部分で、 LoginState.DataMigrationWating にループの開始時間を持たせており、それをループ中で参照することで「一定時間以上経過したらエラー」を表現しています。 State が自分自身に返ってくる様も、状態遷移図で書いたとおりに出来ていますね。実際に実装し始める前にも、このように図に起こしてから実装していたので頭の中でこの辺の流れがしっかりとイメージできていました。

そして最後に、 View の実装です。これに関しては長すぎるので細かい部分は割愛しますが、特に表現したいのは「各状態の表示の出し分け」に関してです。 ログイン画面は Jetpack Compose を使って実装しており、各状態での表示の出し分けは非常に簡単に実装することが出来ました。

@Composable
fun LoginScreen(
    viewModel: LoginViewModel,
) {
    val loginState = viewModel.loginState.collectAsState()

    Scaffold { /* 省略 */ }

    when (loginState) {
        is LoginState.Initial -> { /* なにもしない */ }
        is LoginState.Login -> {
            Dialog() // 処理中はダイアログでユーザーの行動を抑制する
        }
        is LoginState.DataMigrationWaiting -> {
            Dialog()
        }
        is LoginState.LoginSucceeded -> {
            // 成功時のダイアログを表示
            AlertDialog()
        }
        is LoginState.LoginFailed -> {
            // 失敗時のダイアログを表示、エラー内容に応じて表示する内容を分岐
            AlertDialog(
                confirmButton = {
                    Button(
                        // ViewModel の状態を更新することでエラーダイアログを閉じる
                        onClick = viewModel::confirmLoginFailedError
                    ) {
                        Text(“OK”)
                    }
                }
            )
        }
    }
}

Jetpack Compose を使うことで、各状態の更新も深く考えずに実装できました。 特に Dialog の表示制御に関して、 Jetpack Compose でとても簡単に実装できるようになったのが本当に良いところだったと思います。 これまでのパターンだと DialogFragment を用意してボタンの onClick イベントは Fragment Result API を利用して〜〜と非常に複雑で面倒な手続きが多かったのですが、 Jetpack Compose では条件ごとに Composable の関数を呼び出し分けるだけで解決します。 また、 LoginState を sealed interface にしたことで、状態の管理(増減)がしやすく View の表示を変えるのも簡単だったなと思います。

状態を増やしてみる

実際のアプリでは、ログイン中〜データ統合待ちの間に、「データの移行を開始します」という表示をユーザーに見せています。 その状態を増やしてみることにしましょう。

LoginState に一つ状態を増やします。

sealed interface LoginState {
    /* 省略 */

    /* ユーザーデータの統合処理待ち前の状態 */
    object BeforeDataMigrationWaiting : LoginState

    /* 省略 */
}

View の実装では、 LoginState の分岐に一つ追加し、必要な表示を行います。 今回のケースでは、ダイアログで情報を表示し、ユーザーに確認してもらったら次の処理に進むような形で実装しています。

@Composable
fun LoginScreen(
    viewModel: LoginViewModel,
) {
    val loginState = viewModel.loginState.collectAsState()

    Scaffold { /* 省略 */ }

    when (loginState) {
        /* 省略 */
        is LoginState.BeforeDataMigrationWaiting -> {
            AlertDialog(
                confirmButton = {
                    Button(
                        onClick = viewModel::waitDataMigration
                    ) {
                        Text(“OK”)
                    }
                }
            )
        }
    }
}

when の中に一つの分岐を追加しただけで簡単に状態を追加することができました。 Kotlin の exhaustive when の機能を使えば、コンパイラレベルで状態の表現を強制できるため、実装漏れなどを防ぐことができそうですね。

まとめ

クックパッドマートのログイン画面の状態管理について、実装とともに紹介してみました。 記事にまとめてみた感想としては「思ったよりも複雑じゃなくて焦っている」です。しかし、マートアプリの実装の一端を伝えられるいい機会になったんじゃないかなと思います。

アプリの実装はある程度パターン化出来るものが多いですが、今回の実装では特に「非同期のデータ更新を待ち合わせる」パターンを実装できました。このパターンは現在別の画面でも応用し始めています。こういった実装パターンを集めていくことで、実装速度を向上させ、各施策の実現スピードを上げることが出来ると私は考えています。これからも、こういったパターン化できそうな実装要件があった際には、色々と試していきたいです。

最後に、クックパッドマートでは一緒にサービスを盛り上げてくれる心強い仲間を募集しています。今回のログイン画面での実装に限らず、開発の効率を上げるための様々な工夫をしています。少しでも興味を持ってくれた方がいらっしゃいましたら、ぜひお話しましょう。 https://info.cookpad.com/careers/

アカウント連載の記事一覧