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?

【詳細設計編】State/Event分離の最終形!Composeで堅牢なUIエラー通知を実装する

Posted at

はじめに

ChatGPT Image 2025年10月1日 14_44_04.png

こんにちは!前回は、アプリがエラー時に沈黙しないよう、「State」と「Event」を分離するという基本設計を行いました。

今回は、その設計に基づき、データベース操作の失敗をユーザーに**Snackbar**で通知する機能を、実務レベルのコードに落とし込んでいきます。

この実装を通じて、Jetpack Compose開発における「一度きりのアクション(イベント)」を扱うためのベストプラクティスをマスターしましょう!

1. タスクの目的とゴール

今回のタスクの目的は、データベース操作(メモの追加・削除)が失敗した際に、その事実をユーザーに視覚的に通知する仕組みを実装することです。

ゴール: ViewModelが発信したエラーイベントをUI層が受け取り、画面下部にSnackbarを数秒間表示できるようにします。


2. 設計の核心:StateEventの再確認

実装に入る前に、このアーキテクチャの核となる考え方を再確認します。

概念 役割 使用するFlow 特徴
State (状態) メモのリストなど、画面に常に表示され続けるべきデータ。 StateFlow 画面回転しても維持される。現在の「状態」を示す。
Event (イベント) 「保存に失敗しました」など、一度だけ通知すればよい出来事。 SharedFlow 画面回転で再表示されては困る。一回きりの「出来事」を通知する。

この2つのパイプラインを使い分けることが、今回の実装の鍵です。


3. Step 1: ViewModelの改造 - イベント発信基地の設置

まずはViewModel側に、一回きりの通知を送るための専用の出口(SharedFlow)を設置します。

3.1. UIイベントの型安全な定義

どんな種類のイベントがあるかを、型安全に定義するためにsealed interfaceを使用します。

// ファイル: ui/viewmodel/MemoViewModel.kt の外など

/**
 * UIに一度だけ通知したいイベントを定義するインターフェース。
 */
sealed interface UiEvent {
    // Snackbar表示イベント。表示したいメッセージを持つ。
    data class ShowSnackbar(val message: String) : UiEvent
    
    // 今後、「画面遷移しろ」などのイベントもここに追加できる
}

3.2. SharedFlowの追加とカプセル化

ViewModel内に、イベント通知専用のパイプラインを定義します。

// ファイル: ui/viewmodel/MemoViewModel.kt

import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
// ...

class MemoViewModel(private val repository: MemoRepository) : ViewModel() {

    // ... (既存のuiStateや初期化処理) ...

    /**
     * UIイベントを通知するための専用パイプライン。
     * privateなMutableSharedFlowでイベントを発行し、
     * publicなSharedFlowとして公開する(カプセル化)。
     */
    private val _eventFlow = MutableSharedFlow<UiEvent>()
    val eventFlow = _eventFlow.asSharedFlow() //

    // ...

3.3. DB失敗時にイベントを発行する

メモの追加や削除が失敗した際(onFailureブロック)に、この_eventFlowにエラーイベントを流し込みます。

// ファイル: ui/viewmodel/MemoViewModel.kt

// ...

fun addMemo(text: String) {
    if (text.isBlank()) return

    viewModelScope.launch {
        repository.insert(text.trim())
            .onFailure { e ->
                // 失敗時にイベントを発行するよう変更
                _eventFlow.emit(UiEvent.ShowSnackbar("メモの保存に失敗しました"))
                println("メモの挿入に失敗しました: ${e.message}") // 開発用ログは残す
            }
    }
}

fun deleteMemo(memo: Memo) {
    viewModelScope.launch {
        repository.delete(memo)
            .onFailure { e ->
                // 失敗時にイベントを発行するよう変更
                _eventFlow.emit(UiEvent.ShowSnackbar("メモの削除に失敗しました"))
                println("メモの削除に失敗しました: ${e.message}") // 開発用ログは残す
            }
    }
}

💡実装のポイント:
emitsuspend関数であるため、必ずviewModelScope.launchのようなコルーチンの中で呼び出す必要があります。


4. Step 2: MemoScreenの改造 - イベント受信とSnackbar表示

次に、UI側(MemoScreen)で、ViewModelからの緊急速報(イベント)を受け取り、Snackbarとして表示する仕組みを実装します。

4.1. SnackbarHostStateの用意とScaffoldへの設置

Snackbarの表示を制御する司令塔であるSnackbarHostStateを用意し、ScaffoldSnackbarHostとして渡します。

// ファイル: ui/screen/MemoScreen.kt

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MemoScreen() {
    // ... (viewModelの取得) ...

    /**
     * Snackbarの状態を管理し、表示を制御するためのもの。
     */
    val snackbarHostState = remember { SnackbarHostState() } //

    Scaffold(
        // SnackbarHostをScaffoldに設置
        snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, 
        topBar = { TopAppBar(title = { Text("永続化メモアプリ") }) }
    ) { paddingValues ->
        // ... (Column以下の既存のUIコード) ...
    }
}

4.2. LaunchedEffectによるイベントの単発監視

ここが最も重要な部分です。StateFlowではなく、一度きりのイベントを扱うため、LaunchedEffectを使用してイベントフローを監視します。

// ファイル: ui/screen/MemoScreen.kt

import androidx.compose.runtime.LaunchedEffect
import kotlinx.coroutines.flow.collectLatest
// ...

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MemoScreen() {
    // ... (viewModel, snackbarHostState の定義) ...

    /**
     * ViewModelからの単発イベントを監視し、UIに反映させる。
     * LaunchedEffect(Unit)により、画面が再コンポーズされても中の処理は一度だけ実行される。
     */
    LaunchedEffect(Unit) { //
        viewModel.eventFlow.collectLatest { event ->
            when (event) {
                is UiEvent.ShowSnackbar -> {
                    // イベントが来たら、snackbarHostStateを使ってSnackbarを表示
                    snackbarHostState.showSnackbar(
                        message = event.message
                    )
                }
            }
        }
    }

    Scaffold( /* ... */ ) { 
        // ...
    }
}

💡実装のポイント:
LaunchedEffect(Unit)と書くことで、「この画面が初めて表示された時に一度だけ、中の処理を実行してくれ」という命令になります。これにより、再コンポーズのたびにcollectが多重に実行され、エラーメッセージが二重・三重に表示される事故を防ぎます。


5. まとめと残課題

今回の実装により、StateEventを明確に分離した、堅牢で予測可能なUIエラー通知機能を実装することができました。これは、現代のComposeアプリにおける王道のベストプラクティスです。

5.1. 今回達成したこと

  1. SharedFlowUiEventの定義により、ViewModelからUIへの一回限りの通知経路を確保しました。
  2. UI側でLaunchedEffectcollectLatestを使用し、画面の状態に影響を与えずにイベントを処理する仕組みを確立しました。
  3. 操作失敗時にユーザーへ視覚的なフィードバックを返すことができるようになりました。

5.2. 残課題

現在、ViewModelonFailureブロックでは、固定の文字列をSnackbarに表示しています。実務においては、基となるExceptionの種類に応じて、「ネットワーク接続エラー」「データベースの破損」など、より具体的なメッセージをユーザーに伝えるための工夫が必要になります。

この詳細設計書と実装ガイドがあれば、今後の機能拡張にも耐えうる堅牢なアプリ開発を進めることができるでしょう。

さあ、実装を開始しましょう!

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?