이 글은 해당 글에 대한 번역 및 이해를 바탕으로 작성한다.
MVVM
은 권장하는 아키텍쳐이며 많은 개발자가 사용한다. 그러나 다른것들과 마찬가지로 아키텍쳐 패턴도 진화하고 있으며, MVI
는 MVX 패턴들의 마지막 패턴이다. MVVM과 공통점이 많지만, 보다 구조화된 상태 관리 방식이 있다.
MVI는 3가지로 구성된다. Model - View - Intent
먼저, 클래스 유형을 설명하는 인터페이스를 만들어보자.
interface UiState
interface UiEvent
interface UiEffect
이제 ViewModel의 Base Class를 만들어보자.
abstract class BaseViewModel<Event : UiEvent, State : UiState, Effect : UiEffect> : ViewModel() {
// Create Initial State of View
private val initialState: State by lazy { createInitialState() }
abstract fun createInitialState(): State
// Get Current State
val currentState: State
get() = uiState.value
private val _uiState: MutableStateFlow<State> = MutableStateFlow(initialState)
val uiState = _uiState.asStateFlow()
private val _event: MutableSharedFlow<Event> = MutableSharedFlow()
val event = _event.asSharedFlow()
private val _effect: Channel<Effect> = Channel()
val effect = _effect.receiveAsFlow()
}
SharedFlow
을 사용하면 이벤트가 알 수 없는 수(0개 이상)의 구독자에게 보내진다. 구독자가 없으면 게시된 모든 이벤트가 즉시 삭제된다. 즉시 처리해야 하거나 전혀 처리하지 않아야 하는 이벤트에 사용하는 디자인 패턴이다. (SharedFlow는 중복 처리가 가능하다.)
Channel
을 사용하면 각 이벤트가 단일 구독자에게 전달된다. 구독자 없이 이벤트를 게시하려는 시도는 채널 버퍼가 가득차는 즉시 중단되어 구독자가 나타날 때까지 기다린다. 게시된 이벤트는 기본적으로 삭제되지 않는다.
UiState
를 처리하기 위해 StateFlow를 사용한다. StateFlow는 LiveData와 비슷하지만, 초기값이 있다. 그래서 항상 상태를 가질 수 있다. 일종의 SharedFlow이기도 하다. UI가 표시될 때 항상 마지막 View 상태를 수신하려고 한다.
UiEvent
를 처리하기 위해 SharedFlow를 사용한다. 구독자가 없으면 이벤트를 삭제하기를 원한다.
마지막으로 UiEffect
를 처리하기 위해 Channel을 사용한다. Channel이 Hot이고 방향이 변경되거나 UI가 다시 표시될 때 부작용을 다시 표시할 필요가 없기 때문이다. 단순히 SingleLiveEvent 동작을 복제하려고 한다.
그러면 UiState, UiEvent, UiEffect에 대한 setter 메소드를 정의해보자.
/**
* Set new event
* */
fun setEvent(event: Event) {
val newEvent = event
viewModelScope.launch { _event.emit(newEvent) }
}
/**
* Set new Ui State
* */
protected fun setState(reduce: State.() -> State) {
val newState = currentState.reduce()
_uiState.value = newState
}
/**
* Set new Effect
* */
protected fun setEffect(builder: () -> Effect) {
val effectValue = builder()
viewModelScope.launch { _effect.send(effectValue) }
}
Events를 처리하기 위해서는 event Flow를 ViewModel의 init block에서 collect 해야 한다.
init {
subscribeEvents()
}
/**
* Start listening to Event
*/
private fun subscribeEvents() {
viewModelScope.launch {
event.collect {
handleEvent(it)
}
}
}
/**
* Handle each event
*/
abstract fun handleEvent(event : Event)
그럼 BaseViewModel의 구현을 완료했으므로 MainActivity와 MainViewModel 간의 계약인 MainContract를 확인해보자.
class MainContract {
// Events that user performed
sealed class Event : UiEvent {
object OnRandomNumberClicked : Event()
object OnShowToastClicked : Event()
}
// Ui View States
data class State(
val randomNumberState: RandomNumberState
) : UiState
// View State that related to Random Number
sealed class RandomNumberState {
object Idle : RandomNumberState()
object Loading : RandomNumberState()
data class Success(val number : Int) : RandomNumberState()
}
// Side effects
sealed class Effect : UiEffect {
object ShowToast : Effect()
}
}
Event는 2개가 있다. OnRandomNumberClicked 이벤트는 사용자가 임의의 숫자 버튼을 생성하기 위해 클릭할 때, 발생한다. 또한, 토스트 메시지를 표시하고 SingleLiveEvent 동작을 시뮬레이트 하는 간단한 토스트 버튼도 있다.
RandomNumberState
는 Idle, Loading, Success와 같은 다양한 난수 상태를 유지하는 Sealed Class이다. 나중에 작성할 Random Generator 메소드는 네트워크 호출의 시뮬레이션이다.
State
는 UI 요소의 상태에 해당하는 간단한 데이터 클래스이다.
Effect
는 Event 결과에 따라 한 번만 보여주고 싶은 단순한 행동이다.
View State에서 Sealed Class를 사용할 필요는 없으며, 아래와 같이 간단한 변수를 사용할 수도 있다.
data class State(
val isLoading: Boolean = false,
val randomNumber: Int = -1,
val error: String? = null
) : UiState
MainContract를 완료했으므로 실제 로직을 처리하는 MainViewModel을 조금 더 살펴보자.
/**
* Create initial State of Views
*/
override fun createInitialState(): MainContract.State {
return MainContract.State(
MainContract.RandomNumberState.Idle
)
}
위와 같이 초기 view 상태를 만들어야 한다. 이번 예제에서는 비어있는 상태이다.
/**
* Handle each event
*/
override fun handleEvent(event: MainContract.Event) {
when (event) {
is MainContract.Event.OnRandomNumberClicked -> { generateRandomNumber() }
is MainContract.Event.OnShowToastClicked -> {
setEffect { MainContract.Effect.ShowToast }
}
}
}
handleEvent() 메소드에서 각 이벤트를 처리한다. Contract에 이벤트를 추가할 때마다 여기에다 추가해야 한다. 따라서 모든 이벤트를 같은 장소에서 관리할 수 있도록 일원화할 수 있다.
/**
* Generate a random number
*/
private fun generateRandomNumber() {
viewModelScope.launch {
// Set Loading
setState { copy(randomNumberState = MainContract.RandomNumberState.Loading) }
try {
// Add delay for simulate network call
delay(5000)
val random = (0..10).random()
if (random % 2 == 0) {
// If error happens set state to Idle
// If you want create a Error State and use it
setState { copy(randomNumberState = MainContract.RandomNumberState.Idle) }
throw RuntimeException("Number is even")
}
// Update state
setState { copy(randomNumberState = MainContract.RandomNumberState.Success(number = random)) }
} catch (exception : Exception) {
// Show error
setEffect { MainContract.Effect.ShowToast }
}
}
}
마지막 단계로 UI에 뷰 상태를 표시해야 한다.
binding.generateNumber.setOnClickListener {
viewModel.setEvent(MainContract.Event.OnRandomNumberClicked)
}
binding.showToast.setOnClickListener {
viewModel.setEvent(MainContract.Event.OnShowToastClicked)
}
버튼을 클릭할 때마다, 우리는 그에 상응하는 이벤트를 발행한다.
// Collect ui state
lifecycleScope.launchWhenStarted {
viewModel.uiState.collect {
when (it.randomNumberState) {
is MainContract.RandomNumberState.Idle -> { binding.progressBar.isVisible = false }
is MainContract.RandomNumberState.Loading -> { binding.progressBar.isVisible = true }
is MainContract.RandomNumberState.Success -> {
binding.progressBar.isVisible = false
binding.number.text = it.randomNumberState.number.toString()
}
}
}
}
// Collect side effects
lifecycleScope.launchWhenStarted {
viewModel.effect.collect {
when (it) {
is MainContract.Effect.ShowToast -> {
binding.progressBar.isVisible = false
// Simple method that shows a toast
showToast("Error, number is even")
}
}
}
}
UI 업데이트를 위해 우리는 UiState를 collect하고 state data로 View를 refresh 한다.
그리고 복제 LiveData 동작의 경우, launchWhenStarted를 사용했다. 이렇게 하면 생명주기가 최소한 STARTED 상태일 때, flow가 collect 된다.
이제 Sample 앱을 완성할 수 있다. Sample 앱의 흐름은 아래와 같다.
오류가 있고 토스트 또는 경고 Dialog와 같은 일회성 메시지를 표시해야 하는 경우, 새로운 Effect를 설정한다.
[장점]
[단점]
MVI는 MVVM과 공통점이 많지만, 보다 구조화된 상태 관리 방식이 있다.
글이 많은 도움이 되었습니다, 감사합니다.