クックパッドマートアプリのログインの裏側〜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/

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

クックパッドマートアプリにおけるログイン体験の実現

こんにちは。買物プロダクト開発部の大川(@aomathwift)です。クックパッドマートのiOSアプリ(以下マートアプリ)の開発に携わっています。

マートアプリ

この記事は、「クックパッドマートを支えるアカウントたち」連載2本目の記事で、マートアプリでの認証にフォーカスを当てたものです。 シリーズの全貌については以下の記事を御覧ください。

クックパッドマートを支えるアカウントたち - クックパッド開発者ブログ

本稿では、2022年4月に完全導入されたマートアプリでのログイン体験の実現について紹介します。

クックパッドマートにおけるユーザー登録

多くのECサービスでは、最初にユーザー登録をして決済情報や配送先情報等を登録した上で利用開始するものが多いと思います。 しかし、マートアプリでは利用開始時にユーザー登録のフローはありません。アプリインストール後に気軽に買い物を始めてもらうため、クレジットカード情報と商品を配送するマートステーションという名の冷蔵庫の場所(以下受け取り場所)、受け取り名*1のみを入力するだけで商品を購入できる体験設計をしていました。

「ユーザー登録無し」の課題

しかし、この「ユーザー登録無し」という方針には課題があります。 まず、機種変更の際のデータ引き継ぎができないという点です。マートアプリでは、明示的なユーザー登録を行わないため、購入履歴や注文状況といったユーザーデータは端末に紐付いて保存されます。そのため、アプリがインストールされている端末が変わった場合、必然的にデータを参照できなくなり、強制的に新規ユーザーとしてやり直すことになります。すなわち、機種変更の際のデータ引き継ぎができません。

また、明示的なユーザー登録をしない場合、電話番号やメールアドレスといったユーザーの連絡先情報を取得できず、ユーザーへのコミュニケーション手段としてメールやSMSを選択できません。プッシュ通知は利用できますが、これはユーザー側でオフにすることができるため、連絡手段として堅牢なものとは言えないでしょう。

これらの課題が、ユーザー数の増加・アプリの機能拡大とともに顕在化してきました。そのため、「ユーザー登録無し」という方針を見直し、マートアプリにログイン機能を導入することにしました。

ログイン時のユーザーデータの統合

さて、ログインを導入してアプリを利用してもらう際に課題となるのが、登録無しの状態で利用していた際のデータを登録後のユーザー(以下登録ユーザー)に持ち越せるのか、という点です。これに関しては、ユーザー登録無しの状態で作成された購入履歴やお気に入り商品といったユーザーデータを、新規に作成した登録ユーザーに統合する、ということを実現しています。詳しくは「クックパッドマートにおけるアカウントの統合」という記事で詳しく解説されていますので、そちらをご覧ください。

マートアプリにおけるログインの見せ方

データ統合の際に生じるマートアプリ特有の課題

前節で述べたユーザーデータの統合は非同期に実行され、一定の実行時間(数十秒から数分)がかかります。レシピアプリでは、登録ユーザーとして新しくログイン後、ユーザーデータの統合を待たずしてアプリ内コンテンツが表示されることに大きな違和感はありません。生じるタイムラグの間も、アプリのコアであるレシピの閲覧機能を利用することができるからです。

ところが、これがマートアプリではそうはいきません。 マートアプリでは、アプリを初めて起動したタイミングで受け取り場所の設定を行います。受け取り場所が設定されていることで、どの商品をどの配送に乗せるかが確定し、選択された日付にその商品を受け取ることができるかどうかが決まるようになっています。 そのため、登録ユーザーでログイン後、ユーザーデータの統合が未完了の状態でアプリを起動すると、ユーザー目線では設定済みの受け取り場所が突然未設定の状態に戻ったように見えてしまいます。その結果、ユーザーがログイン前に設定していた受け取り場所が突然消えたように見えてしまう上、受け取り場所未設定の状態ではアプリの主要な機能をほとんど利用することができません。これは、アプリの体験として非常に悪いものです。 したがって、マートアプリ上でこの課題を解決する見せ方をする必要がありました。

画面遷移での工夫

上記課題の解決策として、画面遷移での工夫を施しました。 具体的には、マートアプリのログインフローを、以下の図のように構成しています。

画面遷移

ポイントは、ユーザーデータの統合完了までの間、ユーザーのアプリ上での行動をブロックするためのポーリング画面を設置した点です。この画面で、裏で何が起きているのかをユーザーに端的に伝えつつ、完了までの時間アプリ内の購入・商品検索といった行動をできないようにしました。

ポーリング画面

数十秒から数分の時間、ユーザーの行動をブロックするという決断をするまでに、

  • 一時的に仮の受け取り場所が設定されている状態にする
  • エラー画面を出すのをやめて商品検索だけをできるようにする

といった案も出ましたが、受け取り場所を選択してそこに届くものを買い物するというサービスのコアとなる体験を損ねるよりも、主要機能が使えるようになるまでユーザーに待ってもらった方が良いという判断から、ユーザーデータの統合をポーリング画面で待ち合わせるという手段をとりました。

この実装をするにあたり、エラーケースとして以下のような場合を考慮する必要があります。

  • ユーザーデータの統合中にアプリがバックグラウンドに移行した場合
  • データ統合そのものに失敗した場合

たとえば前者の場合、フォアグラウンド状態に戻ったときにユーザーデータの統合処理が継続中なのか、無事完了済みなのか等、どのようなステータスにあるかを適切に確認して、ユーザーにその現状が改めて伝わるようにしなければなりません。

このような場合をカバーするには、アプリ側での状態管理とエラーハンドリングが非常に難しくなります。実際にどうやってこれを実現したかという実装に関する話は後続の記事「クックパッドマートアプリのログインの裏側」にて詳細に記述されていますので、そちらも是非併せてご覧ください。

また、現在は、この非同期に行われるユーザーデータの統合の処理自体にも改善が入り、この待ち合わせ時間はほぼ一瞬でおわるように修正されています。いずれにしても統合の時間を待つという設計である点に変わりはないのですが、このような継続的なユーザー体験の改善が行われています。

おわりに

マートアプリにおけるログイン機能の実現について紹介しました。ECプラットフォームを運用するにあたって、やはりユーザー登録というのはあって然るべきで、これをアプリの機能が増えてきた途中の段階で入れるというのはなかなか難しいことだったと感じます。それでも、既存の体験を極力壊さず、今世の中のECサービスにおいて当たり前の機能の一つである「ログイン」を導入できたことで、試せる施策の幅を広げることにも繋がりました。

今後もクックパッドマートでは、今回紹介したようなユーザー基盤の整備をはじめ、機能追加や体験改善まで様々な開発を行っていきます。興味をお持ちの方は是非以下キャリアサイトから採用情報にアクセスしていただけると幸いです。

info.cookpad.com

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

*1:受け取りの際に商品に印字される、受け取るユーザーを識別する名前

クックパッドマートを支えるアカウントたち

クックパッドマートの開発に携わっているソフトウェアエンジニアの塩出(@solt9029)です。

生鮮食品ECサービスのクックパッドマートでは、注文ユーザー向けのECアプリを中心として、商品を販売する店舗向けの管理画面、生鮮食品の流通を支えるドライバー向けのWebアプリなど、様々なアプリケーションを開発・提供しています。

今まで利用者の課題を解決するために、それぞれのアプリケーションの認証機能やアカウントの仕組みについて、色々な工夫や挑戦をしました。本記事をはじめとして、2022年7月19日〜7月26日(平日のみ)にかけて、クックパッドマートの様々なアプリケーションを支えるアカウントの仕組みやそれらを用いて解決した課題、より良いユーザー体験を実現するために工夫した点を紹介する予定です。

本記事では、クックパッドマート自体の紹介をはじめ、クックパッドマートで日々開発しているアプリケーションの概要と、それぞれに用いられているアカウントの仕組みについて簡単に紹介します。

クックパッドマートとは

クックパッドマートは、弊社が力を入れて取り組んでいる新規事業の1つです。生鮮食品を中心として扱っているECプラットフォームで、街の販売店や地域の生産者がクックパッドマートに参加しています。コンビニエンスストア・ドラッグストア・駅・マンションなどの様々な場所にユーザーの受け取り場所として専用の冷蔵庫が設置されており、ユーザーはアプリから注文を行い、専用の冷蔵庫から商品を受け取ることができます。

クックパッドマートの専用冷蔵庫

ECアプリ

クックパッドマートで生鮮食品を中心とした商品を注文するためのモバイルアプリです。iOSとAndroidの両方で「クックパッドマート」のアプリを提供しています。商品の注文だけでなく、お気に入り商品の登録やレビュー投稿など、様々な機能を提供しています。

ECアプリ

ECアプリでは、インストール後に気軽に買い物を始めてもらうため、利用開始時にユーザー登録のフローを設けていません。しかし、ユーザー登録無しの状態では、機種変更時のデータの引き継ぎができない点や、ユーザーとのコミュニケーション手段がプッシュ通知に限られてしまう点などの課題がありました。

そこで、ECアプリにログイン機能を導入することにしました。その際、ユーザー登録無しの状態で利用していたデータを、ログイン後も引き続き利用するために、ログイン後のユーザーに持ち越す仕組みをバックエンド側で実現する必要がありました。また、その処理は非同期で数十秒〜数分の実行時間がかかるものであったため、ECアプリ上で見せ方や状態管理を工夫する必要がありました。

7月20日(水)・21日(木)に公開予定の記事では、ECアプリのログイン機能導入の背景や体験設計およびその実装を紹介します。また7月22日(金)に、ログイン後のユーザーデータ統合の処理について、バックエンドの仕組みを紹介します。

店舗向け管理画面

クックパッドマートでは、商品を販売する店舗向けに管理画面を提供しています。店舗向け管理画面では、商品の登録・営業日の管理・日々の出荷作業に必要な情報の確認・売上情報の閲覧など、様々な機能があります。

店舗向け管理画面

今までは、店舗向け管理画面の認証のために、ユーザー登録の仕組みを設けていませんでした。専用のチャットアプリ上で管理画面のログインURLを都度発行する方式を取っていました。このログイン方式では、「店舗スタッフごとの権限管理や操作ログの監査ができない」という明確な課題や、「店舗とクックパッドのユーザーは分離された概念となり、クックパッドにおけるサービス共有資産が活用できない」という中長期的な課題がありました。

そこで店舗の認証方法を、クックパッドのユーザーを利用したものに移行しました。認証方法の移行の際、複数の認証方法の並存の実現や、データ設計やログイン体験の変更など、多くの困難な課題を解決する必要がありました。

7月25日(月)に公開予定の記事では、店舗の認証方法の移行の背景や経緯、実現のために求められた要件や解決した課題について紹介します。

ドライバー向けWebアプリ

クックパッドマートでは、生鮮食品の流通を担当するドライバー向けのWebアプリを開発しています。ドライバー向けWebアプリでは、配送経路・時間・対象商品などを確認する機能や、配送状況を随時共有するための機能などがあります。ドライバーの役割に応じて大きく異なる情報や操作が必要となるため、ドライバー向けWebアプリとして複数のアプリケーションが存在します。

ドライバー向けWebアプリ

ドライバー向けWebアプリでは、アカウントの仕組みを整える上で、ECアプリや店舗向け管理画面とは大きく異なる要件がありました。例えば、全てのアカウントをクックパッド側で管理する必要がある点・運送会社や役割に応じて詳細に権限管理をする必要がある点などです。

そこでドライバー向けWebアプリでは、クックパッドのユーザーを利用せず、弊社内で利用している認証サービスであるAzure ADを利用することにしました。Azure ADを利用することによって、クックパッド側でのアカウント管理や権限管理など、運用しやすい状態に保つことができました。

7月26日(火)に公開予定の記事では、ドライバー向けWebアプリの詳細や、Azure ADを使った権限管理や運用体制の詳細について紹介します。

最後に

本記事では、クックパッドマートで開発・提供している代表的なアプリケーションを紹介しました。次回以降の記事では、それぞれのアプリケーションのアカウントの仕組みについて、以下の日程でより詳細を紹介する予定です。

最後になりますが、クックパッドマートでは事業成長のためにスピードを高めて開発に取り組んでおり、様々な技術に触れる機会も多くとても楽しい環境です!弊社では絶賛エンジニア募集中なので、興味を持って頂けた方はぜひ採用情報をご覧ください。

info.cookpad.com