Orbit MVIと共に始める新たなスタート
前回はOrbitライブラリが何なのか、どのように状態管理をし、どのような利点があるのかについて学習しながらまとめていました。
今回はOrbit MVIの核心と言えるDSL(Domain-Specific Language)について学習し、まとめてみようと思います。
📌 Orbitの3大核心DSL
Orbitのすべてのロジックは通常3つのDSLの組み合わせでよく使用され、他のDSLもたくさんありますが、特にこの3つを多く使用しています。
1. intent : アクションの開始点
-
ViewModelのすべてのビジネスロジックが実行されるコルーチンスコープです。
ユーザーの入力や画面初期化のようなイベントを受けてロジック処理を開始する「作業空間」として考えることができ、
非同期作業の開始点と言えます。
2. reduce : 状態変更者
-
UIの状態
(State)を変更できる唯一の方法です。intent内で呼び出され、
現在の状態を基に新しい状態を返し、state.copy(...)を使用するのが一般的で、
状態の不変性を守ってくれます。 -
特徴:
state.copy(...)を使用して不変性を維持することが核心
直接値を変更するのではなく、新しいコピーを作成する方式なので、状態変化を予測し追跡しやすくなります。
3. postSideEffect : 一回性イベント伝達者
-
スナックバー表示、画面移動、トーストメッセージなど「一回だけ」実行されるべきイベントをUIに送る時に使用。
Stateに保存されないため、画面回転のような状況でもイベントが重複で実行されることを防いでくれます。
4. その他 :
-
repeatOnSubscription:Flowのようなデータストリームをsubscribeする時に使用し、
UI(Composable)がstateFlowをsubscribeし始めると(=画面が見えると)自動的にデータストリームのsubscribeを開始し、UIがsubscribeを中断すると(=画面が消えると)ストリームも自動的にキャンセルしてリソースの浪費を防ぐ役割をします。
Room/Firestore/Socketのような持続的なデータフロー処理に適しています。 -
subIntent:
intent内でロジックを細かく分けて再利用するために使用され、複雑なロジックを小さな単位に分離して読みやすく管理し、複数のintentで共通に呼び出すことができます。 -
runOn { dispatcher }:非同期処理時にDispatcherを明示的に制御し、CPU bound / IO bound作業の分離を可能にします。
基本的にOrbitはMain.immediateを使用 → UIスレッドで実行。
まとめると
- intent → アクション開始点
- reduce → State更新(純粋、不変)
- postSideEffect → 一回性イベント
- subIntent → コードモジュール化、再利用
- repeatOnSubscription → ライフサイクルsubscribe管理
- runOn → Coroutine Dispatcher制御
現在の記事では3つの
intent、reduce、postSideEffectのみを書こうと思います。
📌 実戦例題
前回実習で作成したプロジェクトで
HomeViewModelを上記のDSLの役割がより明確に現れるよう修正後、
状態をより明確に管理し、データsubscribe方式を改善することに焦点を合わせました。
🎯 State、Intent、SideEffect定義
/**
* State: UIに表示されるデータを含む不変オブジェクト
* - すべてのUI状態はここで定義
*/
data class HomeState(
val items: List<Message> = emptyList(),
val isRefreshing: Boolean = false,
val isLoading: Boolean = false,
val isEmpty: Boolean = true
)
/**
* Intent: ユーザーの意図/アクションを表現
* - UIで発生するすべてのイベント
*/
sealed interface HomeIntent {
data object Load : HomeIntent
data object Refresh : HomeIntent
data class Delete(val id: Int) : HomeIntent
data object ClearAndReload : HomeIntent
data object ToggleEmpty : HomeIntent
}
/**
* SideEffect: 一回性イベント
* - スナックバー、トースト、ナビゲーションなど
*/
sealed interface HomeSideEffect {
data class ShowSnackBar(val message: String) : HomeSideEffect
data class ShowError(val message: String) : HomeSideEffect
data class NavigateToDetail(val messageId: Int) : HomeSideEffect
}
- HomeStateは画面を描くのに必要なすべての情報(items、isLoadingなど)を持っています
- HomeIntentはユーザーができるすべての行動をsealed interfaceで定義し、コンパイル時点でタイプをチェック
- HomeSideEffectはスナックバー表示、画面移動のような状態に保存されると困る一回性アクションを明確に定義
💡 State設計原則
- 不変性維持(data class + val)
- UIレンダリングに必要なすべての情報を含む
- デフォルト値設定で初期状態を明確化
- 単一責任原則(一つの画面の状態のみ管理)
💡 Intent設計原則
- sealed interfaceでタイプ安全性確保
- ユーザーアクションを明確に表現
- パラメーターが必要な場合はdata classを使用
- 単純なアクションはobjectを使用
💡 SideEffect 使用事例
- スナックバー表示
- ナビゲーション
- トーストメッセージ
- ダイアログ表示
- 外部アプリ実行など
💡 State vs SideEffectの区別
State = 画面回転しても維持されるべきもの(リスト、ローディング状態)
SideEffect = 一度だけ発生すべきもの(トースト、ナビゲーション)
🎯 ViewModel実装
@HiltViewModel
class HomeViewModel @Inject constructor(
private val repository: MessageRepository
) : ContainerHost<HomeState, HomeSideEffect>, ViewModel() {
// Orbit Container初期化
override val container = container<HomeState, HomeSideEffect>(HomeState())
init {
loadInitialData()
observeMessages()
}
/**
* 一般関数でintent呼び出し
* - UIイベント処理エントリーポイント
*/
fun onIntent(intent: HomeIntent) = when (intent) {
HomeIntent.Load -> loadInitialData()
HomeIntent.Refresh -> onRefresh()
is HomeIntent.Delete -> onDeleteMessage(intent.id)
HomeIntent.ClearAndReload -> onClearAndReload()
HomeIntent.ToggleEmpty -> onToggleEmptyState()
}
/**
* 例題1: Flowサブスクリプションでリアルタイムデータ同期
*/
private fun observeMessages() = intent { // 👈 intentでコルーチン開始
repository.messages.collect { messages ->
reduce { // 👈 reduceで状態アップデート
state.copy(
items = messages,
isEmpty = messages.isEmpty()
)
}
}
}
/**
* 例題2: 初期データロード with エラー処理
*/
private fun loadInitialData() = intent {
// ローディング開始
reduce { state.copy(isLoading = true) }
// API呼び出し
val result = repository.refresh()
// ローディング終了
reduce { state.copy(isLoading = false) }
// 結果によるサイドエフェクト
result
.onSuccess {
postSideEffect( // 👈 成功通知
HomeSideEffect.ShowSnackbar("Data loaded successfully")
)
}
.onFailure { error ->
postSideEffect( // 👈 エラー通知
HomeSideEffect.ShowError(error.message ?: "Unknown error")
)
}
}
/**
* 例題3: Pull-to-Refresh実装
*/
fun onRefresh() = intent {
// リフレッシュインディケーター表示
reduce { state.copy(isRefreshing = true) }
// UXのための最小遅延
delay(500)
// データリフレッシュ
val result = repository.refresh()
// 結果処理
result
.onSuccess {
postSideEffect(HomeSideEffect.ShowSnackbar("Updated!"))
}
.onFailure { error ->
postSideEffect(
HomeSideEffect.ShowError("Refresh failed: ${error.message}")
)
}
// リフレッシュ終了
reduce { state.copy(isRefreshing = false) }
}
/**
* 例題4: Optimistic UI Update (削除)
*/
fun onDeleteMessage(id: Int) = intent {
// バックアップ (失敗時ロールバック用)
val previousItems = state.items
// UI即座アップデート (Optimistic)
reduce {
state.copy(items = state.items.filter { it.id != id })
}
// 削除中通知
postSideEffect(HomeSideEffect.ShowSnackbar("Deleting..."))
// 実際削除実行
try {
repository.deleteMessage(id)
postSideEffect(HomeSideEffect.ShowSnackbar("Message deleted"))
} catch (e: Exception) {
// 失敗時ロールバック
reduce { state.copy(items = previousItems) }
postSideEffect(HomeSideEffect.ShowError("Failed to delete"))
}
}
}
ViewModelではOrbitの核心DSLであるintent、reduce、postSideEffectを使用
-
intent { ... }: 非同期ロジックを実行する作業空間 -
reduce { ... }: 唯一の状態変更方法です。現在の状態を基に新しい状態を作成 -
postSideEffect(...): 一回性イベントをUIに伝達
- observeMessagesは
Room DBのFlowをサブスクリプションしてデータが変わるたびにreduceでUI状態をアップデートします。- loadInitialDataはローディング状態をオンにしてデータを取得した後、ローディング状態をオフにして
postSideEffectで結果を知らせる典型的な非同期作業パターンを示します。- onDeleteMessageは
reduceを通じてUIを即座に変更してユーザーに素早いフィードバックを与え、失敗すると再びreduceを通じて以前の状態に安全に復元する強力さを示すことができます。
📌 画面描画
📄 UIでの使用
@Composable
fun HomeScreen(
viewModel: HomeViewModel = hiltViewModel()
) {
// Stateサブスクリプション
val state by viewModel.container.stateFlow.collectAsState()
// SideEffect処理
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
viewModel.container.sideEffectFlow.collectLatest { sideEffect ->
when (sideEffect) {
is HomeSideEffect.ShowSnackbar -> {
snackbarHostState.showSnackbar(sideEffect.message)
}
is HomeSideEffect.ShowError -> {
snackbarHostState.showSnackbar(
message = sideEffect.message,
actionLabel = "Retry"
)
}
}
}
}
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = { Text("Messages") },
actions = {
IconButton(onClick = { viewModel.onRefresh() }) {
if (state.isRefreshing) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp
)
} else {
Icon(Icons.Default.Refresh, "Refresh")
}
}
}
)
}
) { padding ->
when {
state.isLoading -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
state.isEmpty -> {
EmptyState(
onRetry = { viewModel.loadInitialData() }
)
}
else -> {
MessageList(
messages = state.items,
onDelete = { viewModel.onDeleteMessage(it) }
)
}
}
}
}
💡 UIロジックの流れ:
-
Stateサブスクリプション:
collectAsState()を通じてstateFlowをサブスクリプション。ViewModelでreduceで状態が変更されるたびにstate変数が更新され、UIは自動的に再描画されます。 -
SideEffect処理:
LaunchedEffectは画面が最初に描画される時に一度だけ実行。ここでsideEffectFlowをサブスクリプションしてスナックバー表示や画面移動のような一回性イベントを処理 -
State基盤レンダリング:
when式を使用してstateの値(isLoading、isEmptyなど)によって適切なUIコンポーネントを表示。これによりUIは常にStateを正確に反映。 -
Intent伝達: ユーザーの
クリックイベント(onClick、onRefreshなど)が発生すると、viewModel.onIntent(...)やviewModel.onRefresh()のような関数を呼び出してViewModelに行動の意図を伝達。
📌 まとめ
Orbit MVIは単方向データフローを強制して複雑なUIロジックをシンプルで予測可能にします
- Stateは UI の唯一の真実供給源(Single Source of Truth)
- IntentはStateを変更できる唯一の通路
- SideEffectは状態とロジックをきれいに分離
🚀 さらに進む
追加学習方向
Orbitのintent、reduce、postSideEffectなどでも大部分の状態管理をきれいに処理できることがわかりました。
次回は該当例題プロジェクトをさらに磨くために、データとビジネスロジック部分をクリーンアーキテクチャで適用してリファクタリングしながら、該当モジュールの設計部分について勉強し、まとめようと思います。
GitHub : https://github.com/GEUN-TAE-KIM/Mvi_Orbit_Study
参考資料
Orbit公式ドキュメント : https://orbit-mvi.org/
