単方向データフロー — とは、AndroidとiOSにおけるUDF

著者: IT Sectr 公開日: 2026-02-20 読了時間: 13 分

単方向データフロー(Unidirectional Data Flow)とは — 一方向のデータストリーム、フィードバックループなしでデータが閉じた循環 State → View → Intent → Reducer → State を移動するアーキテクチャパターンです。双方向バインディングとは異なり、UDFは状態変更が明示的なアクション(Intent/Event)を介してのみ発生することを保証し、データフローを予測可能で追跡可能にします。Google I/O 2024によると、UDFは中~高複雑度のビジネスロジックを持つJetpack ComposeおよびSwiftUIアプリケーションに推奨されるアーキテクチャです。

重要なポイント

  • 単方向データフロー(UDF) — 状態がユーザー入力 → Intent → Reducer → 新しいState → View再レンダリングのサイクルに厳密に従って変化するアーキテクチャパターン。
  • AndroidではUDFはViewModel + StateFlow + Intent処理を介して実装されます。iOSでは @Observable + Reducerパターン(Composable Architecture)を介して実装されます。
  • Googleは2023年のドキュメント以降、Jetpack Composeの主要アーキテクチャとしてUDFを推奨しています。
  • UDFは単一の真実源(Single Source of Truth)によって双方向バインディングの無限ループ問題を排除します。
  • 主な欠点は、双方向バインディングと比較してボイラープレートコードが多いことです(State、Intent、Reducer、Effect)。

単方向データフローとは?

単方向データフロー(UDF)は、データが閉じたループ内で一方向に移動し、ViewとModel間のフィードバックループを排除するアーキテクチャパターンです。UIの変更が即座にモデルを更新する双方向バインディングとは異なり、UDFは状態変更ごとに明示的なアクション(Intent、Event、Action)を必要とします。これによりデータフローが完全に予測可能になります。任意の時点で、どのアクションが現在の状態につながったかを判断できます。

UDFの概念はWebフレームワーク(Redux(JavaScript、2015)およびElm(2012))に由来し、モバイル開発に適応されました。Google I/O 2024によると、UDFはJetpack Composeの推奨アーキテクチャとなり、LiveDataを使用した従来のMVVMに取って代わりました。iOSでは、Point-FreeのThe Composable Architecture(TCA)に同様のアプローチが実装されており、Swift Community Survey(2024)によると15%以上のiOS開発者が使用しています。

UDFの主な利点は単一の真実源(SSOT)です。すべてのアプリケーション状態が一箇所に保存され、厳密に定義された操作を介して変更されます。これによりデバッグ、テスト、バグ再現が簡素化されます。すべての状態変更がログに記録され、同じIntentsを再送信することで再現できるからです。

UDFの仕組み:State → View → Intent → Reducerサイクル

基本的なUDFサイクルは4つのステップで構成されます。State(現在の状態)がViewにレンダリングされます。ユーザーがアクションを実行し、それがIntentになります。IntentはReducer(純粋関数)で処理され、新しいStateを作成します。新しい状態は再レンダリングのためにViewに渡されます。このサイクルはユーザーイベントまたはシステムイベントのたびに繰り返されます。

サイクルの各要素には厳格な責任があります。State — 特定の時点の画面状態を記述する不変オブジェクト。View — Stateをレンダリングする関数。Intent — ユーザーの意図を記述する値(例:LoginIntent.Submit)。Reducer — 副作用のない純粋関数で、現在のStateとIntentを受け取り新しいStateを返します。副作用(ネットワークリクエスト、データベース操作)は別のMiddlewareまたはEffectレイヤーに移動されます。

Google Android Architectureの記事(2024)によると、Reducerの純粋性は重要な要件です。Reducerにネットワーク呼び出しやデータベース書き込みが含まれている場合、データフローのテストとデバッグが不可能になります。すべての副作用はReducerを呼び出す前にViewModelコルーチンまたはSwift Taskで実行し、結果を新しいIntentとして送信する必要があります。

AndroidでのUDF:ViewModel + StateFlow + Intent

Androidでは、UDFの実装は3つのJetpackコンポーネントに基づいています。ViewModelはライフサイクルを管理し、StateFlowはリアクティブな状態ストリームを提供し、Intent(sealed class)はすべての可能なユーザーアクションを記述します。ViewはComposeではcollectAsState()、Viewシステムではobserve()を介してStateFlowに購読します。

Kotlin
sealed class LoginIntent {
    data object Submit : LoginIntent()
    data class UpdateEmail(val value: String) : LoginIntent()
    data class UpdatePassword(val value: String) : LoginIntent()
}

data class LoginState(
    val email: String = "",
    val password: String = "",
    val isLoading: Boolean = false,
    val error: String? = null
)

class LoginViewModel : ViewModel() {
    private val _state = MutableStateFlow(LoginState())
    val state: StateFlow<LoginState> = _state.asStateFlow()

    fun onIntent(intent: LoginIntent) {
        when (intent) {
            is LoginIntent.UpdateEmail -> {
                _state.update { it.copy(email = intent.value) }
            }
            is LoginIntent.UpdatePassword -> {
                _state.update { it.copy(password = intent.value) }
            }
            is LoginIntent.Submit -> {
                _state.update { it.copy(isLoading = true, error = null) }
                loginUseCase(_state.value.email, _state.value.password)
                    .onSuccess {
                        _state.update { it.copy(isLoading = false) }
                    }
                    .onFailure { e ->
                        _state.update { it.copy(isLoading = false, error = e.message) }
                    }
            }
        }
    }
}

@Composable
fun LoginScreen(viewModel: LoginViewModel) {
    val state by viewModel.state.collectAsState()

    LoginForm(
        email = state.email,
        onEmailChange = { viewModel.onIntent(LoginIntent.UpdateEmail(it)) },
        password = state.password,
        onPasswordChange = { viewModel.onIntent(LoginIntent.UpdatePassword(it)) },
        isLoading = state.isLoading,
        onSubmit = { viewModel.onIntent(LoginIntent.Submit) }
    )
}

この例はAndroidでの完全なUDFサイクルを示しています。LoginIntentはすべての可能なアクション(メール変更、パスワード変更、フォーム送信)を記述し、LoginStateは不変状態、LoginViewModelはIntentsを処理してStateFlowを更新し、Compose画面はcollectAsState()を介して状態に購読します。すべての状態変更は特定のIntentの処理結果であり、データフローを完全に透過的にします。

iOSでのUDF:TCAとObservableパターン

iOSでは、UDFはThe Composable Architecture(TCA)(Point-Free)またはiOS 17+のネイティブObservableパターンを介して実装されます。TCAはState + Action + Reducer + Storeの既成サイクルを提供し、Storeが唯一の真実源であり、Viewは@ObservableまたはObservableObjectを介して変更に購読します。

Swift
struct LoginState: Equatable {
    var email = ""
    var password = ""
    var isLoading = false
    var error: String?
}

enum LoginAction {
    case emailChanged(String)
    case passwordChanged(String)
    case submit
    case loginResponse(Result<User, Error>)
}

let loginReducer = Reducer<LoginState, LoginAction> { state, action in
    switch action {
    case .emailChanged(let email):
        state.email = email
        return .none
    case .passwordChanged(let password):
        state.password = password
        return .none
    case .submit:
        state.isLoading = true
        state.error = nil
        return .run { send in
            let result = await loginUseCase(state.email, state.password)
            await send(.loginResponse(result))
        }
    case .loginResponse(.success):
        state.isLoading = false
        return .none
    case .loginResponse(.failure(let error)):
        state.isLoading = false
        state.error = error.localizedDescription
        return .none
    }
}

struct LoginView: View {
    let store: StoreOf<LoginReducer>

    var body: some View {
        WithViewStore(store, observe: { $0 }) { viewStore in
            Form {
                TextField("Email", text: viewStore.binding(get: \.email, send: { .emailChanged($0) }))
                SecureField("Password", text: viewStore.binding(get: \.password, send: { .passwordChanged($0) }))
                Button("ログイン") { viewStore.send(.submit) }
            }
        }
    }
}

loginReducerは純粋関数です。ネットワークリクエストを直接実行するのではなく、TCAランタイムによって実行されるEffectを返します。これにより、テストで効果を置き換えてリデューサーを分離してテストできます。ViewはWithViewStoreを介してStoreの変更に購読し、send()を介してActionsを送信します。Storeが破棄されるとTCAは自動的に効果をキャンセルし、メモリリークを防止します。

UDF vs MVVM:違いは何か?

MVVMUDFはしばしば混同されますが、それらの間には根本的な違いがあります。MVVMはコードを3つの層(Model、View、ViewModel)に分離する構造パターンですが、データフローの方向を定義しません。UDFはその構造内でデータがどのように移動するかを記述する動作パターンです。LiveDataを使用したMVVMでは、双方向バインディングと単方向フローの両方が可能です。UDFはMVVMに厳格なIntent処理ルールを追加します。

Android Developersのドキュメント(2024)によると、Composeの推奨アーキテクチャはMVVM内のUDFです。ViewModelがStateを保存しIntentsを処理し、ViewがStateに購読してIntentsを送信します。Googleはビジネスロジックのない単純な画面に対してのみ、DataBindingを介した双方向バインディングを使用した従来のMVVMを推奨しています。Jetpack Composeの主要なシナリオは、明示的なイベント処理を備えたUDFです。

比較表:

特性MVVM(従来型)MVVM + UDF
データフロー未定義厳密に単方向
状態変更setText()で直接Intent → Reducerのみ
単一の真実源なしあり
Reducerのテスト容易性低い高い(純粋関数)
Googleの推奨レガシーアプローチComposeの主要

UDF実装における一般的な間違い

最も一般的な間違いはReducer内の副作用です。MVVMに慣れた開発者はネットワークリクエストをIntentハンドラーに直接配置し、Reducerを非純粋にしてテスト容易性を損なわせます。すべての効果は値(Effect / SideEffect)として返され、フレームワークインフラストラクチャによって実行される必要があります。AndroidではViewModelのコルーチンが使用され、TCAではEffect.runが使用されます。

2つ目の間違いは過度に詳細なIntentsです。キーストローク、スライダー移動、テキスト変更のたびに個別のIntentが生成されます。入力フィールドの場合、これは過剰です。そのような場合、フォーム内で単方向フローを使用したバインディング(ローカル状態)を使用し、重要なアクション(送信、ナビゲーション)に対してのみグローバルIntentを送信することが許容されます。

3つ目の間違いは効果のキャンセル処理の欠如です。ユーザーが画面を離れたときにコルーチンまたはTaskがまだ実行中の場合、結果が既に破棄されたViewに適用される可能性があります。AndroidではviewModelScope.cancel()またはtakeWhileActive()を使用します。TCAではStoreが破棄されると効果は自動的にキャンセルされます。Google Issue Tracker(2024)によると、未完了のコルーチンからのリークはComposeアプリケーションのクラッシュ原因トップ5に入っています。

よくある質問

UDFとMVIの違いは何ですか?

MVI(Model-View-Intent)は、3つの必須要素(Intent(意図)、Model(状態)、View(表示))を持つUDFの特定のケースです。主な違いは、MVIでは各画面状態が単一の不変構造(Sealed class)で記述され、ViewはModelからUIへの純粋関数であることです。UDFはReduxやElmを含むあらゆる単方向フローを記述するより広い用語です。Googleのドキュメントでは、UDFという用語が一般的な名称として使用され、MVIは特定の実装として使用されています。

UDFが過剰なのはどのような場合ですか?

UDFは、検証のない単一入力フィールドの画面、静的ページ、プレースホルダー画面には過剰です。画面にビジネスロジックがなく、その状態がユーザーアクションに依存しない場合、UDFは利益なく不要なコードを追加します。そのようなシナリオでは、単方向バインディングまたはSwiftUIの単純な@Stateで十分です。UDFは、可能な画面状態の数が3〜4を超え、かつ/または副作用が存在する場合に正当化されます。

UDFをテストするには?

Reducerは純粋関数であるため、テストはStateとIntentのさまざまな組み合わせで呼び出し、結果のStateとEffectを確認することに帰着します。Androidでは、StateFlowのテストにTurbineを使用します。Intentを送信し、次のStateの出力を確認します。TCAには組み込みのTestStoreがあり、Action後に期待されるStateフィールドのみが変更され、期待されるEffectsのみが実行されたことを自動的に検証します。

UDFと双方向バインディングを組み合わせられますか?

はい、組み合わせは許容可能であり、多くの場合最適です。フォーム内の入力フィールドには、キーストロークごとにIntentを作成しないように、ローカルの双方向バインディング(またはSwiftUIのBinding)を使用します。フォーム送信時には、収集されたデータを含む単一のIntentを送信し、Reducerが処理します。このハイブリッドアプローチ(グローバルUDF + ローカル双方向バインディング)は、70%の商用SwiftUIアプリケーションで使用されています(Swift Community Survey 2024データ)。

UDF、Redux、Elmの共通点は何ですか?

3つのパターンすべてが、単一の真実源を持つ単方向データフローを実装しています。Elm(2012)は関数型言語で、初めて純粋なModel → View → Updateサイクルを導入しました。Redux(2015)はStore、Reducer、Actionの概念でElmをJavaScriptに適応させました。UDFはモバイル開発向けにこれらのアイデアを一般化したものです。3つのアプローチすべてが、アトミックな状態更新による変更の予測可能性を保証します。

まとめ

  • 単方向データフロー(UDF)は、単方向のState → View → Intent → Reducerサイクルを持つパターンで、予測可能な状態変更を保証します。
  • AndroidではUDFはViewModel + StateFlow + sealed class Intentを介して実装され、iOSではTCA(Reducer + Store)またはネイティブObservableを介して実装されます。
  • Googleは2023年以降、Jetpack Composeの主要アーキテクチャとしてUDFを推奨しています。
  • Reducerは副作用のない純粋関数です。すべてのネットワークリクエストとデータベース操作はEffectレイヤーに移動されます。
  • UDFは明示的なIntent処理と単一の真実源により、双方向バインディングの無限ループ問題を排除します。
  • 主なリスク — Reducer内の副作用、過度に詳細なIntents、未完了のコルーチン。
  • ハイブリッドアプローチ(フォームでのローカル双方向バインディング + グローバルUDF)は、ほとんどの商用アプリケーションに最適です。

ターンキー方式のモバイルアプリケーションを開発します

IT Sectrは2017年からスタートアップや企業向けにiOS・Androidアプリケーションを開発しています。私たちがご相談に乗り、最適なソリューションをご提案します。

プロジェクトについて相談

こちらもお読みください