프로젝트에서 MVI를 어떻게 사용했을까요?
일단 세가지 Interface를 구현해야 합니다!
package com.depromeet.team6.presentation.util.base
interface UiState
interface UiEvent
interface UiSideEffect
State는 UI에 표현되는 값 또는 데이터의 집합이며 불변의 속성(Immutable)을 가집니다!
기존에 MVVM 패턴을 많이 사용하셨을텐데요, MVVM 패턴에서는 LiveData나 StateFlow를 통해 값을 구독하고 뷰모델에서 관리하였는데요 UiState가 이와 같은 역할을 하고 이 State(상태)가 업데이트 되면 Recomposition(화면의 재구성)이 일어나게 됩니다.
Event는 말 그대로 발생하는 동작인데요, SideEffect와 가장 큰 차이점은 State를 변화시킨다는 겁니다.
이벤트는 Contract에서 정의하고, 뷰모델에서 로직처리를 담당합니다. 간단히 흐름을 보면 아래와 같습니다.
class OnboardingContract {
data class OnboardingUiState(val searchText: String = "")
sealed class OnboardingEvent : UiEvent {
data class UpdateSearchText(val text: String) : OnboardingEvent()
}
}
@HiltViewModel
class OnboardingViewModel @Inject constructor() : BaseViewModel<OnboardingContract.OnboardingUiState, OnboardingContract.OnboardingSideEffect, OnboardingContract.OnboardingEvent>() {
override fun createInitialState(): OnboardingContract.OnboardingUiState =
OnboardingContract.OnboardingUiState()
override suspend fun handleEvent(event: OnboardingContract.OnboardingEvent) {
when (event) {
is OnboardingContract.OnboardingEvent.UpdateSearchText ->
setState { copy(searchText = event.text) }
}
}
}
예를 들어 “사용자가 검색창에 단어를 입력한다” 와 같은 사용자의 동작을 의미하는데요, 예시의 Event에 의해서 searchText 라는 State의 값이 변하게 되고 화면은 변경된 State값을 반영해야 하기 때문에 Recomposition이 일어나게 될 것 입니다.

SideEffect는 State와 별개의 이벤트입니다. 즉, State에 영향을 주지 않는 동작입니다. 예를 들어 스낵바, 토스트 메시지나 화면 이동(Navigate)이 이에 해당합니다.
SideEffect도 똑같이 Event와 마찬가지로 Contract에서 선언하고 setSideEffect에 의해 트리거 되면 로직이 실행되는 흐름이에요!
sealed interface BusCourseSideEffect : UiSideEffect {
data object NavigateToBackStack : BusCourseSideEffect
}
LaunchedEffect(viewModel.sideEffect, lifecycleOwner) {
viewModel.sideEffect.flowWithLifecycle(lifecycle = lifecycleOwner.lifecycle)
.collect { sideEffect ->
when (sideEffect) {
is BusCourseContract.BusCourseSideEffect.NavigateToBackStack
-> navigateToBackStack()
}
}
}
BusCourseScreen(
backButtonClicked = {
viewModel.setSideEffect(BusCourseContract.BusCourseSideEffect.NavigateToBackStack) })
State는 분명 불변이라고 했는데…? 라고 생각하실 수 있습니다! 정확합니다~
MVI(Mutation → View → Intent) 아키텍처의 기본 전제:
“상태는 절대 직접 변경하지 않는다”
상태는 항상 새로운 객체로 만들어서 교체해야 한다.
를 따르고 있기 때문에 아래와 같이 copy( ) 를 사용합니다~
setState { copy(state = event.newStateValue) }
AtCha 프로젝트에서는 MVI 구조를 더욱 깔끔하게 관리하기 위해, 공통된 부분을 묶어서 추상 클래스(BaseViewModel) 로 관리합니다.
아래는 실제 AtCha에서 사용하는 BaseViewModel 코드 예시입니다.
abstract class BaseViewModel<State : UiState, SideEffect : UiSideEffect, Event : UiEvent>() :
ViewModel() {
private val initialState: State by lazy { createInitialState() }
abstract fun createInitialState(): State
private val _uiState = MutableStateFlow<State>(initialState)
val uiState: StateFlow<State>
get() = _uiState.asStateFlow()
val currentState: State
get() = uiState.value
private val _event: MutableSharedFlow<Event> = MutableSharedFlow()
val event: SharedFlow<Event>
get() = _event.asSharedFlow()
private val _sideEffect: MutableSharedFlow<SideEffect> = MutableSharedFlow()
val sideEffect: Flow<SideEffect>
get() = _sideEffect.asSharedFlow()
fun setState(reduce: State.() -> State) {
_uiState.value = currentState.reduce()
}
open fun setEvent(event: Event) {
dispatchEvent(event)
}
private fun dispatchEvent(event: Event) = viewModelScope.launch {
handleEvent(event)
}
protected abstract suspend fun handleEvent(event: Event)
fun setSideEffect(sideEffect: SideEffect) {
viewModelScope.launch { _sideEffect.emit(sideEffect) }
}
}
이 BaseViewModel은 위에서 말씀드린 세 가지를 효율적으로 관리합니다.
사용하면서도 Compose는 MVVM보다 MVI가 적합하다고 생각했습니다!
이라는 말은 너무 광범위 하다고 생각해요~
Compose Compiler에서 제공하는 Stability Report (Compose Compiler Metrics) 라는 녀석이 있는데요, 이 기능을 사용하면 State의 안정성(Stability) 여부를 확인하고 불필요한 Recomposition(리컴포지션) 이 발생하는 원인을 쉽게 분석할 수 있습니다.
아무래도 Compose를 사용하며 중요한 요소 중 하나가 불필요한 Recomposition을 방지하는 것!
라고 생각해서 최대한 안전하게(Stable), 최대한 효율적이게 (필요할 때만 Recomposition) 코드를 개선하는것이 계획입니다~