0
0

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 1 year has passed since last update.

Qiita全国学生対抗戦Advent Calendar 2023

Day 18

[compose multiplatform 中級チュートリアル8]DBとリアルタイム通信する

Last updated at Posted at 2023-12-17

はじめに

前回の記事ではCRUD処理を実装して、課題を作ってみました。
まずは前回の課題を解決できるコードを自分なりに書いてみたのでみてください。

課題: 完了に移せない
前回のやつだとタスクの右のボタンを押しても対応中に移るだけでした。
なので、対応中に入っているタスクに対して、右のボタンを押したら完了に移るようにしましょう。

私の回答
これ実は大きい問題に起因してました。
今だとListItemのカテゴリを変更するときに、どのように変更するかを定義しているのはListItem.ktの中です。
これだとカテゴリごとに処理を変更することもできないし、そもそもUIだけを定義しているはずのListItem.ktのなかでデータを変更してしまっていました。
そこで、まずは、正しくデータ処理を行う場所に処理を移しましょう。

ListScreenModel.ktの処理を変更する

ListScreenModel.ktの処理のなかに以下の二つの処理を追加します。

ListScreenModel.kt
fun startTask(task: Task) {
    coroutineScope.launch {
        kotlin.runCatching {
            val updatedTask = task.copy(category = "対応中") // カテゴリを対応中に変更
            taskApi.update(updatedTask) // APIを呼び出してタスクを更新
        }.onSuccess {
            fetchTasks() // 更新成功時、タスクリストを更新
        }.onFailure {
            Logger.e(it) { "Error while starting task" } // エラー発生時、ログに記録
        }
    }
}

fun finishTask(task: Task) {
    coroutineScope.launch {
        kotlin.runCatching {
            val updatedTask = task.copy(category = "完了") // カテゴリを完了に変更
            taskApi.update(updatedTask)
        }.onSuccess {
            fetchTasks() // 更新成功時、タスクリストを更新
        }.onFailure {
            Logger.e(it) { "Error while finishing task" } // エラー発生時、ログに記録
        }
    }
}

これにより、screenModelの段階で、タスクを対応中に移す処理と、完了に移す処理できました。
次に今までタスクを更新する処理を行っていたListItem.ktの処理を変更します。

ListItem.kt
fun ListItem(
    onTaskChanged: (Task) -> Unit,
    onTaskDeleted: (Task) -> Unit,
    task: Task
) {
    Row {
        // タイトルと予定工数の表示
        Text("${task.title} (${task.estimatedTime}h)", modifier = Modifier.padding(4.dp))

        // カテゴリを変更するアイコン
        Icon(
            imageVector = Icons.Default.Send,
            contentDescription = "Change Category",
            modifier = Modifier
                .padding(4.dp)
                .clickable {
                    // カテゴリを変更してコールバックを呼び出す
                    val updatedTask = task.copy(category = "対応中")
                    onTaskChanged(updatedTask)
                }
        )

        // ゴミ箱アイコン
        Icon(
            imageVector = Icons.Default.Delete,
            contentDescription = "Delete Task",
            modifier = Modifier
                .padding(4.dp)
                .clickable {
                    // ゴミ箱アイコンをクリックしてコールバックを呼び出す
                    onTaskDeleted(task)
                }
        )
    }
}

ついでにCategory.ktで受け渡していたコールバック関数の名前も変更します。

Category.kt
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.company.saikyotodo.net.Task

// カテゴリ名と全タスクを渡す(ほんとは絞り込んだ後の方がパフォーマンスが良いが目を瞑ろう)
@Composable
fun Category(category: String, tasks: List<Task>, onTaskChanged: (Task) -> Unit, onTaskDeleted: (Task) -> Unit) {
    // カテゴリ名を表示
    Text(text = category, style = TextStyle(fontSize = 20.sp), modifier = Modifier.padding(4.dp))
    // カテゴリ名が一致するタスクをListItemコンポーザブルを使って表示
    tasks.filter { it.category == category }.forEach { task ->
        ListItem(onTaskChanged, onTaskDeleted, task)
    }

    // 適当な余白
    Spacer(modifier = Modifier.height(20.dp))
}

最後に、ListScreen.ktでカテゴリごとに呼ぶ処理を変更するように修正します。

ListScreen.kt
@Composable
override fun Content() {
    val screenModel = getScreenModel<ListScreenModel>() // ListScreenModelを取得
    var tasks = screenModel.tasks.value // タスクのリストをStateから取得
    val categories = listOf("バックログ", "対応中", "完了") // カテゴリのリストを定義

    Column(modifier = Modifier.padding(20.dp).fillMaxSize()) { // 縦に要素を配置するColumn
        // 各カテゴリに対して、Categoryコンポーザブルを呼び出す
        categories.forEach { category ->
            Category(
                category, tasks,
                onTaskChanged = {
                    when (category) {
                        "完了" -> screenModel.deleteTask(it)
                        "対応中" -> screenModel.finishTask(it)
                        else -> screenModel.startTask(it)
                    }
                },
                onTaskDeleted = {
                    screenModel.deleteTask(it) // タスク削除時の処理をViewModelに委譲
                }
            )
        }
        TaskForm(
            onTaskAdded = { task ->
                screenModel.addTask(task) // タスク追加時の処理をViewModelに委譲
            },
            categories = categories
        )
    }
}

これで、対応中のタスクを移動させたら、完了に移動するようになりました。
また、ついでに完了のタスクを移動させたら、削除するようにしておきました。

本題のリアルタイム同期

ようやく本題のリアルタイム同期です。
今まではデータの追加、更新、削除を行うたびに、APIを呼び出していました。
しかし、これだと他の端末からデータを更新した際に、自分の端末には反映されません。
また、裏側の処理で今後は自動的に完了のタスクを削除しようとしているので、その時も困ります。

ということでリアルタイムにデータを監視するようにしてみましょう。

ただし、実は前回までのチュートリアルでこっそり準備を終わらせているので、今回はライブラリの導入は不要です。
もしここからやる人は、このライブラリを追加しておいてください。
https://github.com/supabase-community/supabase-kt/tree/master/Realtime

チャンネルを作成する

ListScreenModel.ktのなかでリアルタイム通信を行うためのチャンネルを作成する処理を実装します。
以下の関数を追加してください。

ListScreenModel.kt
fun connectRealtime() {
    coroutineScope.launch {
        kotlin.runCatching {
            supabaseClient.realtime.connect() // リアルタイム機能を有効化
            // チャンネルを作成
            realtimeChannel.postgresChangeFlow<PostgresAction>("public") {
                table = "tasks" // tasksテーブルを監視
            }.onEach {
                when(it) {
                    // Selectアクションは無視
                    is PostgresAction.Select -> // Do nothing
                        Logger.d { "Select action received" }
                    // それ以外のアクションが行われたことを検知したらデータを再構築する。
                    else -> {fetchTasks()}
                }
            }.launchIn(coroutineScope)
            realtimeChannel.join()
        }.onFailure {
            it.printStackTrace()
        }
    }
}

ついでに以下のライブラリをimportしておいてください。

ListScreenModel.kt
import io.github.jan.supabase.SupabaseClient
import io.github.jan.supabase.realtime.PostgresAction
import io.github.jan.supabase.realtime.RealtimeChannel
import io.github.jan.supabase.realtime.postgresChangeFlow
import io.github.jan.supabase.realtime.realtime
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach

ただしこのままだと動かないです。ListScreenModelを呼び出す時の引数にSupabaseClientとrealtimeChannelを追加してください。

ListScreenModel.kt
class ListScreenModel(
    private val taskApi: TaskApi,
    private val supabaseClient: SupabaseClient,
    private val realtimeChannel: RealtimeChannel
) : ScreenModel {

そしてviewModelModuleのなかでsupabaseClientとrealtimeChannelを注入するように修正してください。

viewModelModule.kt
val viewModel = module {
    // 以下のように修正
    factory { ListScreenModel(get(), get(), get()) }
}

これで動くようになるはずです。
supabase側からデータベースを更新してあげたりすると、リアルタイムで同期されていることがわかると思います。

最後に、元々、データを更新するたびにfetchTasksを行っていましたが不要なので、その処理を適当なログだしとかに変えておくと良いでしょう。

最終的なListScreenModel.ktは以下のようになります。

ListScreenModel.kt
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import cafe.adriel.voyager.core.model.ScreenModel
import cafe.adriel.voyager.core.model.coroutineScope
import co.touchlab.kermit.Logger
import io.github.jan.supabase.SupabaseClient
import io.github.jan.supabase.realtime.PostgresAction
import io.github.jan.supabase.realtime.RealtimeChannel
import io.github.jan.supabase.realtime.postgresChangeFlow
import io.github.jan.supabase.realtime.realtime
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import org.company.saikyotodo.net.Task
import org.company.saikyotodo.net.TaskApi

class ListScreenModel(
    val supabaseClient: SupabaseClient,
    private val realtimeChannel: RealtimeChannel,
    private val taskApi: TaskApi // TaskApiのインスタンスを注入。API呼び出し用
) : ScreenModel { // VoyagerのScreenModelを継承。画面固有のロジックやデータを保持

    private val _tasks = mutableStateOf<List<Task>>(emptyList()) // タスクのリストを保持する内部状態
    val tasks: State<List<Task>> = _tasks // _tasksの公開状態。UIからアクセスされる

    init {
        fetchTasks() // タスクのリストを取得する処理を呼ぶ
        connectRealtime() // リアルタイム通信を開始する処理を呼ぶ
    }

    fun connectRealtime() {
        coroutineScope.launch {
            kotlin.runCatching {
                supabaseClient.realtime.connect() // リアルタイム機能を有効化
                realtimeChannel.postgresChangeFlow<PostgresAction>("public") {
                    table = "tasks" // tasksテーブルを監視
                }.onEach {
                    when(it) {
                        is PostgresAction.Select -> // Do nothing
                            Logger.d { "Select action received" }
                        else -> {fetchTasks()}
                    }
                }.launchIn(coroutineScope)
                realtimeChannel.join()
            }.onFailure {
                it.printStackTrace()
            }
        }
    }

    fun fetchTasks() {
        coroutineScope.launch { // コルーチンを開始。非同期処理を行う
            kotlin.runCatching {
                taskApi.fetchAll() // APIを呼び出して全タスクを取得
            }.onSuccess {
                _tasks.value = it // 取得成功時、タスクリストを更新
            }.onFailure {
                Logger.e(it) { "Error while fetching tasks" } // エラー発生時、ログに記録
            }
        }
    }

    fun addTask(task: Task) {
        coroutineScope.launch {
            kotlin.runCatching {
                taskApi.add(task) // APIを呼び出してタスクを追加
            }.onSuccess {
                Logger.d { "Task added successfully" } // 追加成功時、ログに記録
            }.onFailure {
                Logger.e(it) { "Error while adding task" } // エラー発生時、ログに記録
            }
        }
    }

    fun updateTask(task: Task) {
        coroutineScope.launch {
            kotlin.runCatching {
                taskApi.update(task) // APIを呼び出してタスクを更新
            }.onSuccess {
                Logger.d { "Task updated successfully" } // 更新成功時、ログに記録
            }.onFailure {
                Logger.e(it) { "Error while updating task" } // エラー発生時、ログに記録
            }
        }
    }

    fun startTask(task: Task) {
        coroutineScope.launch {
            kotlin.runCatching {
                val updatedTask = task.copy(category = "対応中") // カテゴリを対応中に変更
                taskApi.update(updatedTask) // APIを呼び出してタスクを更新
            }.onSuccess {
                fetchTasks() // 更新成功時、タスクリストを更新
            }.onFailure {
                Logger.e(it) { "Error while starting task" } // エラー発生時、ログに記録
            }
        }
    }

    fun finishTask(task: Task) {
        coroutineScope.launch {
            kotlin.runCatching {
                val updatedTask = task.copy(category = "完了") // カテゴリを完了に変更
                taskApi.update(updatedTask)
            }.onSuccess {
                fetchTasks() // 更新成功時、タスクリストを更新
            }.onFailure {
                Logger.e(it) { "Error while finishing task" } // エラー発生時、ログに記録
            }
        }
    }

    fun deleteTask(task: Task) {
        coroutineScope.launch {
            kotlin.runCatching {
                taskApi.delete(task) // APIを呼び出してタスクを削除
            }.onSuccess {
                Logger.d { "Task deleted successfully" } // 削除成功時、ログに記録
            }.onFailure {
                Logger.e(it) { "Error while deleting task" } // エラー発生時、ログに記録
            }
        }
    }
}

最後に

一旦このチュートリアルは終了にします。
authとか、細かいUIとか色々やれることはあるのですが、研究が忙しくなってきたので
一度ここまでで終了してみます。
もし要望等があれば続きも作っていきます。

めちゃくちゃクオリティが低い記事をここまで読んでいただいてありがとうございます。

0
0
0

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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?