Jetpack Compose アプリに Cloud Firestore を追加する

これは、ユーザー インターフェースにJetpack Composeを使用し、 Firebase AuthenticationCrashlyticsCloud FirestorePerformance MonitoringRemote ConfigFirebase ExtensionsFirebase Cloud MessagingHiltなど、Google が提供するその他のツールを使用して、新しい Android アプリケーションをゼロから作成する方法を紹介する一連の記事の第 4 部です。

このシリーズのパート 1では、このアプリで何ができるのか、またその構造について概要を説明します。パート 2 では、Firebase Authentication を使用してログインおよびサインアップ フローを実装する方法を説明します。パート 3 では、クラッシュを処理してユーザーにメッセージを表示するために Firebase Crashlytics を追加する方法について説明します。

この第 4 部では、Cloud Firestore について学び、アプリケーションに追加して、クラウドに ToDo アイテムを保存するために使用し始める方法を確認します。また、保存されたアイテムの変更を監視し、これらのイベントに基づいて UI を更新する方法についても説明します。データをリアルタイムで同期できることは、Firestore の最も魅力的な機能の 1 つです。

タスクの作成と編集

タスク画面のフロー

コードに進む前に、タスク画面 (下の最初のスクリーンショットに表示) のフローを確認しましょう。この画面から、次のアクションを実行できます。

  • 新しいタスクを作成する: 右下隅にある追加ボタンをタップすると、タスクを作成できます。ユーザーはタスク編集画面にリダイレクトされ、新しいタスクを作成できます。すべてのフィールドはデフォルト値で初期化されます。
  • タスク メニューを展開します。各タスクには 3 つのドットのメニューがあり、ユーザーがタップすると 3 つのオプションが表示されます。
    • タスクの編集: 上記と同じタスク編集画面が開きますが、空のタスクのデフォルト値ではなく、選択した項目の値が表示されます。
    • フラグの切り替え: フラグの状態をオンからオフに、またはその逆に変更します。
    • タスクの削除: タスクを削除します。
  • 設定画面を開きます。右上隅の歯車アイコンをタップするとアクセスできます。設定画面では、ユーザーは既存のアカウントにログインしたり、新しいアカウントを作成したりできます。

タスク編集画面の流れ

タスク編集画面は、下の 2 番目のスクリーンショットでわかるように、非常に分かりやすいです。各タスクには、TextFields画面上部に表示される 3 つの場所に入力できるタイトル、説明、URL があります。また、スクリーンショット 3 と 4 に示すように、日付と時刻のオプションをタップし、カレンダーと時計を使用して値を選択することで、各タスクの期限と時刻を追加することもできます。

最後に、優先度とフラグのステータスを変更することができます。各タスクはデフォルトの優先度 で初期化されNoneますが、LowMediumまたはに変更することができますHigh。フラグについては、初期値は ですOffが、 に変更することができますOn。ユーザーはタスクの設定が完了したら、画面の右上隅にある確認アイコンをタップできます。

タスクとタスク編集画面

クラウド ファイアストア

Cloud Firestore とは何ですか?

Cloud FirestoreはNoSQLクラウド データベースです。つまり、情報の保存方法は SQL データベースとは異なります。テーブルと列を使用する代わりに、情報はコレクションとドキュメントに保存されます。コレクションとドキュメントはJSONに非常に似た構造になっています (各ドキュメントには値にマッピングされたフィールドが含まれています)。

ドキュメント内のこれらのフィールドはさまざまなタイプ ( StringIntegerArrayDateおよび他のドキュメントへの参照) にすることができ、アプリケーションに最適なデータ構造を選択できます。ドキュメントはコレクションに保存されます。データを取得するには、個々のドキュメントを取得するか、クエリ条件に一致する複数のドキュメントをコレクションでクエリします。

Cloud Firestore を使用すると、リアルタイム リスナーを通じて、さまざまなプラットフォームやデバイス間でアプリケーション データを同期させることもできます。これについては、この記事でさらに詳しく説明します。これらの利点やその他の多くの利点の詳細については、公式ドキュメントをご覧ください。

プロジェクトに Cloud Firestore を追加する

Cloud Firestore を Android プロジェクトに追加するには、app/build.gradleファイルに次の依存関係を追加します。

dependencies {
    // Import the BoM for the Firebase platform
    implementation platform('com.google.firebase:firebase-bom:31.0.0')

    // Declare the dependency for the Firestore library
    implementation 'com.google.firebase:firebase-firestore-ktx'
}

Firebase コンソールでプロジェクト用の Cloud Firestore データベースを作成することを忘れないでください。これにより、コードでこのデータベースに接続できるようになります。コンソールの Firestore データベース セクションに移動し、[Firestore データベースを作成] をクリックします。セットアップ フローの詳細については、公式のGet Started ドキュメントをご覧ください。

ストレージサービスの作成

次のステップは、このシリーズのパート 1StorageServiceで説明したサービス インターフェイスと実装パターンを使用して、を作成することです。インターフェイスは次のようになります。

interface StorageService {
    fun addListener(
        userId: String,
        onDocumentEvent: (Boolean, Task) -> Unit,
        onError: (Throwable) -> Unit
    )

    fun removeListener()
    fun getTask(taskId: String, onError: (Throwable) -> Unit, onSuccess: (Task) -> Unit)
    fun saveTask(task: Task, onResult: (Throwable?) -> Unit)
    fun updateTask(task: Task, onResult: (Throwable?) -> Unit)
    fun deleteTask(taskId: String, onResult: (Throwable?) -> Unit)
}

監視したいドキュメントのコレクション内のイベントをリッスンするリスナーを登録および登録解除するメソッドがあります。その他のメソッドは、アイテムを取得、保存、更新、および削除するためのものです。上記のように、ドキュメントは と呼ばれるデータ構造で表されますTask。これは次のdata classようになります。

data class Task(
    val id: String = "",
    val title: String = "",
    val priority: String = "",
    val dueDate: String = "",
    val dueTime: String = "",
    val description: String = "",
    val url: String = "",
    val flag: Boolean = false,
    val completed: Boolean = false,
    val userId: String = ""
)

ユーザーに表示されるすべての情報に加えて、各アイテムにはuserId(アプリケーションにログインしたユーザーに基づいてタスクを取得できるようにするため) と、id ドキュメントの作成時に Firestore によって自動生成される もあります。後者は、LazyColumn(Compose でリストを表示するために使用される UI 要素) では各アイテムに一意のキーが必要であるため必要です。

タスク画面フローの実装

リスナーの追加

ライブ データを表示できるようにしたいと考えています。そのため、ユーザーが別のデバイスでタスク リストを変更するたびに、他のすべてのデバイスに同じデータが表示されるようにする必要があります。Firestore にはスナップショット リスナーと呼ばれるメカニズムが用意されており、数行のコードでこれを実装できます。

ただし、このスナップショット リスナーが画面のライフサイクルに準拠していることを確認する必要があります。画面が表示されていない場合、イベントを受信して​​も意味がありません。そのため、ビューが表示されたときにリスナーを登録し、ビューが消えたときに登録を解除する必要があります。これを実現するには、最上位の composable 関数にDisposableEffectを追加します。これは、クリーンアップする必要がある副作用 (リスナーの登録と登録解除など、まさにその通りです) に使用する必要があります。

DisposableEffect(viewModel) {
    viewModel.addListener()
    onDispose { viewModel.removeListener() }
}

add上記のスニペットでは、 メソッドとremoveメソッドが互いに下にありますが、これらは順番に実行されるわけではありません。addListenerメソッドは、コンポーズ可能な関数が開始されるとすぐに呼び出され、removeListenerメソッドは、関数が破棄されるとすぐに呼び出されます。

内のこれらのメソッドaddListenerとメソッドは、ドキュメント イベントを受信するたびに実行されるコールバックを渡して を呼び出します。removeListener TasksViewModelStorageService

fun addListener() {
    viewModelScope.launch(showErrorExceptionHandler) {
        storageService.addListener(accountService.getUserId(), ::onDocumentEvent, ::onError)
    }
}

fun removeListener() {
    viewModelScope.launch(showErrorExceptionHandler) { storageService.removeListener() }
}

クラスにサービス インターフェイスのメソッドを実装しますStorageServiceImpl

override fun addListener(
    userId: String,
    onDocumentEvent: (Boolean, Task) -> Unit,
    onError: (Throwable) -> Unit
) {
    val query = Firebase.firestore.collection(TASK_COLLECTION).whereEqualTo(USER_ID, userId)

    listenerRegistration = query.addSnapshotListener { value, error ->
        if (error != null) {
            onError(error)
            return@addSnapshotListener
        }

        value?.documentChanges?.forEach {
            val wasDocumentDeleted = it.type == REMOVED
            val task = it.document.toObject<Task>().copy(id = it.document.id)
            onDocumentEvent(wasDocumentDeleted, task)
        }
    }
}

override fun removeListener() {
    listenerRegistration?.remove()
}

listenerRegistration、 への参照を保持するサービス実装のオブジェクトですsnapshotListener。この参照は保持して、必要に応じて登録解除できるようにします。forEachブロック内では、各ドキュメント イベントをチェックして、変更が 型であるかどうかを確認します。そのため、これをパラメーターとして渡してコールバックREMOVEDを呼び出すことができます。onDocumentEventboolean

パラメータに加えて、送信する必要のあるパラメータbooleanもあります。これは、タスク リストに必要な変更を適用するために、どのタスクが変更されたかを が正確に知る必要があるためです。APIによって提供される メソッドを使用して、ドキュメントをコードベースが認識するオブジェクト (この場合は ) にマッピングできます。マッピングについては、この記事の後半で説明します。TaskTasksViewModeltoObjectTask

ViewModel からサービス メソッドを呼び出す

TasksViewModelユーザーが完了としてマークしたりマークを解除したりしたとき、またはフラグのステータスが変更されたときに、Firestore のを更新するのも の役割ですTask。ユーザーがコンテキスト メニューで項目をタップして削除したときも同じことが起こります。 は、サービスから メソッドTasksViewModel を呼び出す役割を担っています。deleteTask

Exceptionサービス メソッドを呼び出した後、次の 2 つのことが発生する可能性があります。エラーが発生した場合は、コールバックでを受け取り、onError以下のコード スニペットに示すように、メソッドを呼び出します。このメソッドは、このシリーズのパート 3onErrorで説明したように、Crashlytics を使用してエラーをログに記録します。成功すると、Firestore コレクションでドキュメントが更新され、登録されたリスナーを通じてイベントが受信され、ローカル リストが更新されます。

private fun onDeleteTaskClick(task: Task) {
    viewModelScope.launch(showErrorExceptionHandler) {
        storageService.deleteTask(task.id) { error ->
            if (error != null) onError(error)
        }
    }
}

構成可能な画面の更新

ToDo リスト画面では、 のリストだけを確認すればよいため、と のUiState間で情報を交換するためにを作成する必要はありません。他の画面で見てきたように を使用する代わりに、 を使用します。TasksViewModelTasksScreenTasksMutableStateMutableStateMap

var tasks = mutableStateMapOf<String, Task>()
    private set

タスクの ID をマップのキーとして使用し、タスクをそのキーの値として保存します。これにより、一意の ID に基づいてアイテムに非常に迅速にアクセスして変更を加えることができます。たとえば、TasksViewModelドキュメント イベントをリッスンするときに が行うことはこれだけです。

private fun onDocumentEvent(wasDocumentDeleted: Boolean, task: Task) {
    if (wasDocumentDeleted) tasks.remove(task.id) else tasks[task.id] = task
}

次に、このマップを監視するコンポーザブル関数が再構成され、更新された項目が表示されます。

LazyColumn {
    items(viewModel.tasks.values.toList(), key = { it.id }) { item ->
        [...]
    }
}

タスク編集画面フローの実装

ViewModel からサービス メソッドを呼び出す

StorageServiceでは、呼び出すことができるメソッドが3 つありますEditTaskViewModel。最初のメソッドは と呼ばれgetTask、 をパラメータとして受け取りますtaskId。この ID を使用して、この画面で編集する必要がある を取得できますTask。パラメータとして ID が送信されない場合は、空のフィールドを含む画面が表示され、ユーザーはTask最初から新しい を作成できます。

使用する 2 番目と 3 番目のメソッドは、saveTask(新しいタスクを作成するために使用) とupdateTask(既存のタスクを更新するために使用) と呼ばれます。

fun onDoneClick(popUpScreen: () -> Unit) {
    viewModelScope.launch(showErrorExceptionHandler) {
        val editedTask = task.value.copy(userId = accountService.getUserId())

        if (editedTask.id.isBlank()) saveTask(editedTask, popUpScreen)
        else updateTask(editedTask, popUpScreen)
    }
}

private fun saveTask(task: Task, popUpScreen: () -> Unit) {
    storageService.saveTask(task) { error ->
        if (error == null) popUpScreen() else onError(error)
    }
}

private fun updateTask(task: Task, popUpScreen: () -> Unit) {
    storageService.updateTask(task) { error ->
        if (error == null) popUpScreen() else onError(error)
    }
}

onDoneClickユーザーが を保存するためにボタンをタップすると、 メソッドが呼び出されます。Taskこのメソッド内では、 がTaskすでに存在するかどうか (ID が空白かどうかをチェックすることによって) を確認し、 メソッドを呼び出して を保存または更新するかどうかを決定しますTask。繰り返しますが、これらのサービス メソッドのいずれかが呼び出されると、2 つのことが発生する可能性があります。エラーの場合は、Exceptionコールバックで を受け取り、 メソッドを呼び出しますonError。成功した場合は、 を閉じてEditTaskScreenユーザーを にリダイレクトしTasksScreen、更新されたリストを表示できるようにします。

はコレクション内の変更をリッスンするように登録されているので、を にTasksViewModel保存するとすぐに、この更新を含むイベントが Firestore から送信され、 は自身のタスク リストを自動的に更新することに注意してください。TaskEditTaskViewModelTasksViewModel

タスクの保存

上で見たように、ユーザーが保存ボタンをクリックするとすぐに、コンポーザブル関数 onDoneClickが から メソッドを呼び出し、このメソッド内でからまたはをEditTaskViewModel呼び出すことができます。メソッドは をコレクションに追加するだけです(その後、Firestore はこのドキュメントの ID を自動的に生成します)。メソッドは ID を使用してFirestore 内でこのドキュメントを見つけ、新しい値で更新します。saveTaskupdateTaskStorageServicesaveTaskTaskupdateTaskTask

override fun saveTask(task: Task, onResult: (Throwable?) -> Unit) {
    Firebase.firestore
        .collection(TASK_COLLECTION)
        .add(task)
        .addOnCompleteListener { onResult(it.exception) }
}
override fun updateTask(task: Task, onResult: (Throwable?) -> Unit) {
    Firebase.firestore
        .collection(TASK_COLLECTION)
        .document(task.id)
        .set(task)
        .addOnCompleteListener { onResult(it.exception) }
}

上記の両方のメソッドは、onResultパラメータをコールバックとして Firestore API に渡します。このコールバックは、API メソッドが終了するとすぐに実行されます。このコールバックで必要な情報は、タスクの保存プロセス中にエラーが発生したかどうかだけです。これにより、画面にエラーをユーザーに表示できます (タスクを削除するときに以前に見た方法と同様)。

Task保存する前にオブジェクトを変換していないことにお気づきかもしれません。これは、Firestore がdata classリフレクションを使用してオブジェクトをドキュメントに自動的に変換できるためです。つまり、Firestore を使用するときにオブジェクトをシリアル化または逆シリアル化する必要がないということです。これにより、大量の定型コードを書かなくて済むだけでなく、コードの保守性も向上します。

ドキュメントを取得する場合も同じことが起こります。Firestore ドキュメントをアプリケーションが認識できるオブジェクトにマッピングするのは非常に簡単で、toObjectAPI が提供するメソッドを使用するだけで、マッピングが自動的に行われます。たとえば、Firestore から取得したドキュメントを 型のオブジェクトに変換するにはTask、次の操作を行うだけです。

val task = document.toObject<Task>()

Taskメソッドに型を渡すとtoObject、リフレクションを使用して、ドキュメントのフィールド値がで見たプロパティに変換されますdata class Task

構成可能な画面の更新

もう一度言いますが、タスク編集画面では、 を作成してと のUiState間で情報を交換する必要はありません。確認する必要があるのは 1 つの(ユーザーが作成または編集しているタスク) だけだからです。EditTaskViewModelEditTaskScreenTask

var task = mutableStateOf(Task())
    private set

は、ユーザーがフィールドで編集するとプロパティをEditTaskViewModel更新し、変更ごとにコンポーズ可能な関数が再構成され、ユーザーが入力した新しいプロパティを表示します。ユーザー入力を に渡す方法と、新しい値を にポストバックする方法について詳しくは、このシリーズのパート 2をご覧ください。TaskViewModelsScreens

今後の改善

ユーザーが当社のアプリを気に入ってくれることを願う一方で、納得できずアカウントを削除したいというユーザーもいます。そのため、また現地の規制に準拠するために、ユーザーがすべてのデータを削除できる方法を提供する必要があります。

StorageServiceユーザーのドキュメントを 1 つずつ調べてすべてのデータを削除するメソッドを追加することを検討してください。メソッドは次のようになります。

// DO NOT USE THIS - ANTIPATTERN!
override fun deleteAllForUser(userId: String, onResult: (Throwable?) -> Unit) {
    Firebase.firestore
        .collection(TASK_COLLECTION)
        .whereEqualTo(USER_ID, userId)
        .get()
        .addOnFailureListener { error -> onResult(error) }
        .addOnSuccessListener { result ->
            for (document in result) document.reference.delete()
            onResult(null)
        }
}

この方法は、データのセットが小さい場合には問題なく機能しますが、いくつかの問題が発生する可能性があります。たとえば、タスクの削除中にインターネット接続が失敗すると、一部のドキュメントが削除されずにコレクションに残ります。

理想的には、アカウントを削除するアクションをバックエンドに任せ、ユーザーがアカウントを削除したらすべてのドキュメントを一括削除する単一のメソッドを呼び出すことができます。このために独自のCloud Functionsを作成することも、時間を節約してDelete User Data Extensionを使用することもできます。これについては、このシリーズの次回の記事で取り上げる予定です。

次は何か

Android 用の Make it So のソース コードは、この Github リポジトリで入手できます。この記事で紹介したすべてのコードは、v1フォルダーで入手できます。

Servicesこの記事では、コールバックを使用してとの間で結果を渡す方法を説明しますViewModelsが、このパターンは Make it So アプリの特定のバージョンでのみ使用されます。したがって、v1.0.0 tagこのコードに具体的にアクセスしたい場合は をチェックアウトする必要があります。より新しいバージョンでは、コールバックの代わりにKotlin CoroutinesKotlin FlowTaskを使用してモデルを変更します。

iOS/Mac 開発にも興味がある場合は、同じアプリケーションがこれらのプラットフォームでも利用できます。iOS/Mac バージョンでは、ユーザー インターフェイスの構築に SwiftUI を使用します。これは、Compose で UI を構築する方法と同様に、宣言型 UI モデルに従います。ソース コードは、この Github ページにあります。

ご質問やご提案がございましたら、リポジトリのディスカッション ページからお気軽にお問い合わせください。

Follow me!

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です