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