✏️

Segが学んだMVI ~個人の意見を添えて~

に公開

まえがき

どうも、segです。まえがきは読まなくていいです。

普段はJetpack Composeを使ってアプリ開発をしていく際に、クライアント側をClean ArchitectureとMVIを用いています。

ただ、Clean ArchitectureとMVIを人に説明できるほどの理解がなっていないので、今回はMVIについて色々理解を深ぼっていきたいと思います。
(やる気があればClean Architectureについても出す)

あと初心者が書いている無いようなので、鼻で笑いながら読んでやって下さい。
ということでさっそく

1. MVIとは

Cycle.jsが発案したUI層に関するアーキテクチャ。
Model-View−Intentという要素に分解することで、
Intent -> Model -> Viewのように単方向データフローを実現している。

  • Model -> 状態を表す
  • View -> モデルの状態を視覚的(UI)に表す
  • Intent -> ユーザーの意図を表す

単方向データフローというか、循環型データフローのほうが明快?
User -> Intent -> Model -> View -> User -> ...みたいな?

個人の意見↓

ざっくりとした説明をしたのですが、MVIを紹介している記事ではよくReducerというものが出てきます。
これはIntentとModelを受け取り、新しいModelを生成する純粋関数です。
ただ、これに関してはMVIにはReducerが必須なのかと言われればそうではないと考えています。

元はCycle.jsがJavaScript向けに純関数的でリアクティブなフレームワークを提供しているのですが、そのフレームワークとして実現している純粋関数をそのままMVIに適用している結果、MVIにはReducerが必要不可欠だと考えている人がちらほらいます。(多分?)

なので、結論MVIは単方向データフローを実現しているだけあって、純粋関数を実現するアーキテクチャではないです。
(ですがReducerあったほうがテストとかで楽なので、まぁ実質必要不可欠だと思います)

2. 具体例

まずはMVIについて簡単な具体例を持ちいて、その次に実践でも通用しそうなぼくがかんがえたさいきょうのMVIを説明していきます()

簡単ver

Model

data class CounterModel(val count: Int = 0)

Intent

sealed interface CounterIntent {
    object Increment : CounterIntent
    object Decrement : CounterIntent
    
    fun updateModel(model: CounterModel): CounterModel = when(this) {
        Increment -> model.copy(count = model.count + 1)
        Decrement -> model.copy(count = model.count - 1)
    }
}

View

@Composable
fun CounterScreen() {

	//Model
	var model by remember { mutableStateOf(CounterModel()) }
	
	//Intent
    fun intent(i: CounterIntent) {
        model = i.updateModel(model)
    }
	
	//View
    CounterView(
	    state = state,
	    intent,
    )
    
}

@Composable
private fun CounterView(\model: CounterModel, intent: (CounterIntent) -> Unit) {
    Column(horizontalAlignment = Alignment.CenterHorizontally) {
        Text("Count: ${state.count}", fontSize = 24.sp)
        Row {
            Button(onClick = { intent(CounterIntent.Decrement) }) {
                Text("-")
            }
            Button(onClick = { intent(CounterIntent.Increment) }) {
                Text("+")
            }
        }
    }
}

解説

ModelはCounterScreenのモデルを表します。
今回はシンプルなので、counterしかプロパティに持っていません。

ViewはCounterScreenのUIを表します。
今回はModelを受け取り、ユーザーにUIを表示しています。(Model -> View)...①

ユーザーはViewから受け取った視覚的情報から、画面に向けて行いたい意図(アクション)を表します。(View -> Intent)...②

IntentはCounterScreenで受け取れるユーザーの意図(アクション)を表します。
CounterScreenでは値を増やすか減らすかの2つの意図を表せます。
その意図を受け取ってModelを更新します。(Intent -> Model)...③

①、②、③より、Model -> View -> Intentと単方向データフローを実現しています。

これを実践でも使えるようなコードにしていきます。

ぼくのかんがえたさいきょうのMVI

⚠ここからは我流の書き方が増えます。

Model

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

Intent

sealed interface LoginIntent {
    data class UsernameChanged(val text: String) : LoginIntent
    data class PasswordChanged(val text: String) : LoginIntent
    object Submit : LoginIntent
}

Reducer

fun loginReducer(state: LoginState, intent: LoginIntent): LoginState {
    return when(intent) {
        is LoginIntent.UsernameChanged -> state.copy(username = intent.text)
        is LoginIntent.PasswordChanged -> state.copy(password = intent.text)
        is LoginIntent.Submit -> state.copy(loading = true, error = null)
    }
}

Effect

sealed interface LoginSideEffect {
    object NavigateToHome : LoginSideEffect
    data class ShowToast(val message: String) : LoginSideEffect
}

EffectはUI層に関する、一度だけ実行する副作用を表します。

ViewModel

class LoginViewModel(
    private val loginUseCase: LoginUseCase
) : ViewModel() {

    private val _state = MutableStateFlow(LoginState())
    val state: StateFlow<LoginState> = _state

    private val _sideEffect = Channel<LoginSideEffect>()
    val sideEffect = _sideEffect.receiveAsFlow()

    fun onIntent(intent: LoginIntent) {
        _state.update { current -> loginReducer(current, intent) }

        when(intent) {
            LoginIntent.Submit -> viewModelScope.launch {
                try {
                    val user = loginUseCase.login(
                        _state.value.username,
                        _state.value.password
                    )
                    _sideEffect.send(LoginSideEffect.NavigateToHome)
                } catch (e: Exception) {
                    _state.update { it.copy(loading = false) }
                    _sideEffect.send(LoginSideEffect.ShowToast(e.message ?: "Error"))
                }
            }
            else -> {}
        }
    }
}

View

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

    LoginView(
        state = state,
        onIntent = viewModel::onIntent
    )

    LaunchedEffect(Unit) {
        viewModel.sideEffect.collect { effect ->
            when(effect) {
                LoginSideEffect.NavigateToHome -> ...
                is LoginSideEffect.ShowToast -> ...
            }
        }
    }
}

@Composable
fun LoginView(state: LoginState, onIntent: (LoginIntent) -> Unit) {
    Column {
        TextField(
            value = state.username,
            onValueChange = { onIntent(LoginIntent.UsernameChanged(it)) }
        )
        TextField(
            value = state.password,
            onValueChange = { onIntent(LoginIntent.PasswordChanged(it)) }
        )
        Button(onClick = { onIntent(LoginIntent.Submit) }) {
            Text("Login")
        }
        if (state.loading) { CircularProgressIndicator() }
        state.error?.let { Text(it, color = Color.Red) }
    }
}

だいたいこんな形でMVIを用いたUI層のアーキテクチャを構築しています。

基底クラスを作成してMVI用のViewModelクラスを作成したりします。

3. さいごに

いかがだったでしょうか。ちょっと面倒くさくなって最後の方は説明飛ばしていましたが()、
自分が理解しているMVIはこんなかんじです。
正直この記事を書いた大きな理由の一つが、自分が理解しているMVIが間違っていないかを確認するためなので、コメントでの指摘や意見めっちゃまってます。(待ってます)

なので、何か些細なことがあれば是非コメントして下さい。
あとこの記事を見て学習する際には、Cycle.jsを読むことを強くおすすめします。

では、ばいきち〜

Discussion