먼저 Compose가 왜 MVI 패턴과 잘 어울리는지와 MVI 패턴에 대한 설명은 Compose에 MVI를 적용해야하는 이유를 참고해주시면 될 것 같습니다!
해당 예제코드는 버튼을 누르면 값이 증가, 감소하고 다른화면으로 전환하는 아주 간단한 프로그램입니다!
포스트 내용은 전체 코드 중 MVI 해당하는 일부 코드를 작성했고, 전체코드는 포스트 마지막에 깃허브 참고해주시면 될 것 같습니다 🤓
interface ViewEvent
interface ViewState
interface ViewSideEffect
먼저 3개의 인터페이스를 작성합니다!
각 인터페이스에 대한 역할을 말씀드리면,
Action의 역할을 한다고 생각하시면 될 것 같습니다.
예를 들어 예제코드에 해당되는 버튼을 클릭해 값을 바꿀 때 값의 변화를 일으키는 기능을 한다고 생각하시면 됩니다!
애플리케이션의 UI를 표현하는 상태를 나타냅니다
State는 View에서 사용되는 데이터의 집합이며 불변의 속성을 가집니다!
ViewModel의 LiveData나 StateFlow와 같은 역할을 한다고 보시면 됩니다!
SideEffect는 State와 별개로 일회성으로 발생하는 이벤트라고 생각하시면 됩니다.
예를 들어서 스낵바 메시지, Navigation이동과 같이 State가 상관이 없는 UI 이벤트를 통해 어플리케이션의 사용자 경험을 향상시키는 역할을 한다고 보시면 됩니다!
abstract class BaseViewModel<Event: ViewEvent, UiState: ViewState, Effect: ViewSideEffect>: ViewModel() {
abstract fun setInitialState(): UiState
abstract fun handleEvents(event: Event)
private val initialState: UiState by lazy { setInitialState() }
private val _viewState: MutableState<UiState> = mutableStateOf(initialState)
val viewState: State<UiState> = _viewState
private val _event: MutableSharedFlow<Event> = MutableSharedFlow()
private val _effect: Channel<Effect> = Channel()
val effect = _effect.receiveAsFlow()
init {
subscribeToEvents()
}
private fun subscribeToEvents() {
viewModelScope.launch {
_event.collect{
handleEvents(it)
}
}
}
fun setEvent(event: Event) {
viewModelScope.launch { _event.emit(event) }
}
protected fun setState(reducer: UiState.() -> UiState) {
val newState = viewState.value.reducer()
_viewState.value = newState
}
protected fun setEffect(builder: () -> Effect) {
val effectValue = builder()
viewModelScope.launch { _effect.send(effectValue) }
}
}
먼저, MVI 패턴을 사용하기 위해 기본적인 코드인 BaseViewModel을 작성합니다! Compose에 적용하는 MVI 패턴에서 ViewModel의 역할은 Intent의 역할을 수행한다고 생각하시면 됩니다!
BaseViewModel은 ViewModel를 상속받고 ViewState, ViewEvent, ViewEffect의 제네릭타입을 가진 클래스입니다.
코드를 읽으면 알 수 있듯이, state, event, effect에 대한 기본설정이 담겨져있습니다.
주요 함수를 보겠습니다
private fun subscribeToEvents() {
viewModelScope.launch {
_event.collect{
handleEvents(it)
}
}
}
subscribeToEvent함수를 보겠습니다!
먼저 event는 Flow로 설정되어있기 때문에 ColdStream 특징을 가집니다.
즉 생산자와 소비자가 1:1 관계를 가지며 이벤트를 수집하고, 전달하는 역할을 수행합니다!
fun setEvent(event: Event) {
viewModelScope.launch { _event.emit(event) }
}
setEvent함수는 이벤트를 발생시키는 함수입니다
즉 어떤 이벤트가 발생하면 해당 함수를 호출한다고 생각하시면 됩니다!
해당 event역시 Flow로 설정되어있기 때문에 이벤트를 발행하는 의미인 emit을 사용하는 것을 볼 수 있습니다
protected fun setState(reducer: UiState.() -> UiState) {
val newState = viewState.value.reducer()
_viewState.value = newState
}
먼저 생소한 표현인 reducer: UiState.() -> UiState이 보입니다
reducer는 UiState의 확장함수로 사용됩니다
해당 함수를 통해 새로운 상태를 생성함으로써 상태를 업데이트 시킬 수 있습니다.
또한 protected 키워드를 사용한 이유가 뭘까요?
BaseViewModel 클래스를 상속받은 클래스 내에서만 접근가능하게 함으로써 안정성을 높여주기 때문입니다!
즉, 외부에서 상태를 변화시킬 이유는 전혀 없습니다
private val _effect: Channel<Effect> = Channel()
val effect = _effect.receiveAsFlow()
protected fun setEffect(builder: () -> Effect) {
val effectValue = builder()
viewModelScope.launch { _effect.send(effectValue) }
}
설명드리기 앞서, effect는 Channel로 설정되어있습니다.
Channel은 코틀린의 코루틴 라이브러리에서 제공하는 통신 매커니즘입니다.
즉, 코루틴 간에 안전하게 데이터를 주고받을 수 있는 역할을 한다고 볼 수 있습니다.
Channel은 Flow와 유사하게 생산자(send를 통해 보냄)와 소비자(receive를 이용해 소비) 개념이 존재합니다
setEffect함수는 상태(State)와 별개인 UI 업데이트에 대한 생산자의 역할을 수행합니다.
FirstScreen()은 앱을 실행하자마자 보이는 화면으로, 값을 증가하고 감소하는 화면입니다.
해당 화면에 대한 Model를 정의해보겠습니다
class FirstContract {
sealed class Event: ViewEvent {
data object Retry : Event()
data class Action(val num: String): Event()
data object OnIncreaseCount: Event()
data object OnDecreaseCount: Event()
}
data class State(
val count: Int,
val isLoading: Boolean,
val isError: Boolean
): ViewState
sealed class Effect: ViewSideEffect {
sealed class Navigation: Effect() {
data class ToSecond(val num: String): Navigation()
}
}
}
FirstContract 클래스에서 Contract라는 네이밍이 MVI 패턴을 적용하는 프로젝트에서 흔히 볼 수 있습니다
이유를 곰곰히 생각해보니, Model 계층은 앱 화면 한개의 데이터뿐만 아니라 상태도 담당하는 역할을 하다보니 Contract(계약)을 넣은 것 같습니다 (개인적인 추측이지만요ㅎㅎㅎㅎ..)
sealed class Event: ViewEvent {
data object Retry : Event()
data class NavigateAction(val num: String): Event()
data object OnIncreaseCount: Event()
data object OnDecreaseCount: Event()
}
Retry는 에러상황이 발생할때 재요청을 위해 넣긴했는데,
예제코드에서는 활용되지 않습니다 (추후에 API 통신을 이용할때 사용하면 될 것 같습니다)
NavigationAction은 다음화면을 넘어가기 위한 버튼을 클릭 할 때 이벤트를 정의한 것이며 OnIncreseCount, OnDecreaseCount는 각각 숫자를 증가, 감소시키는 이벤트를 정의한 것입니다.
data class State(
val count: Int,
val isLoading: Boolean,
val isError: Boolean
): ViewState
State 클래스는 숫자, 에러, 로딩의 상태를 정의합니다.
sealed class Effect: ViewSideEffect {
sealed class Navigation: Effect() {
data class ToSecond(val num: String): Navigation()
}
}
실습에 해당하는 Effect는 Navigation 이동관련 뿐이기 때문에 SecondScreen으로 넘어가는 클래스를 정의했습니다.
@Composable
fun FirstScreenDestination(navController: NavController) {
val viewModel: FirstViewModel = remember { FirstViewModel() }
FirstScreen(
state = viewModel.viewState.value,
effectFlow = viewModel.effect,
onEventSent = viewModel::setEvent,
onNavigationRequested = { navigationEffect ->
if(navigationEffect is FirstContract.Effect.Navigation.ToSecond) {
navController.navigateToSecond(navigationEffect.num)
}
}
)
}
@Composable
fun FirstScreen(
state: FirstContract.State,
effectFlow: Flow<FirstContract.Effect>?,
onEventSent: (event: FirstContract.Event) -> Unit,
onNavigationRequested: (navigationEffect: FirstContract.Effect.Navigation) -> Unit
) {
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
// Navigation Effect 발생 시 수행
LaunchedEffect(effectFlow) {
effectFlow?.collect { effect ->
when(effect) {
is FirstContract.Effect.Navigation.ToSecond -> onNavigationRequested(effect)
}
}
}
// count의 상태가 변화될때마다 수행
LaunchedEffect(state.count) {
if(state.count < 0){
snackbarHostState.showSnackbar(
message = "숫자가 0보다 작아졌어요",
duration = SnackbarDuration.Short
)
}
}
Scaffold(
snackbarHost = { SnackbarHost(hostState = snackbarHostState) }
) { paddingValues ->
when {
state.isLoading -> Progress(paddingValues = paddingValues)
state.isError -> NetworkError { onEventSent(FirstContract.Event.Retry) }
else -> FirstContent(
uiState = state,
paddingValues = paddingValues,
onSecClick = { onEventSent(FirstContract.Event.NavigateAction(it)) },
onEventSent = onEventSent
)
}
}
}
@Composable
fun FirstContent(
uiState: FirstContract.State,
onSecClick: (num: String) -> Unit,
onEventSent: (FirstContract.Event) -> Unit,
paddingValues: PaddingValues
) {
Column(
modifier = Modifier
.padding(paddingValues)
.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = "${uiState.count}")
Row {
Button(onClick = { onEventSent(FirstContract.Event.OnIncreaseCount) }) {
Text(text = "+1")
}
Button(onClick = { onEventSent(FirstContract.Event.OnDecreaseCount) }) {
Text(text = "-1")
}
}
Button(onClick = { onSecClick("2") }) {
Text(text = "Second로 이동")
}
}
}
View 코드 중 주요깊게 볼 부분은 다음과 같습니다.
@Composable
fun FirstScreen(
state: FirstContract.State,
effectFlow: Flow<FirstContract.Effect>?,
onEventSent: (event: FirstContract.Event) -> Unit,
onNavigationRequested: (navigationEffect: FirstContract.Effect.Navigation) -> Unit
)
여기서 MVI의 장점을 느낄 수 있습니다!!
어떤 기능을 구현하든 MVI패턴을 활용하면 Composable 파라미터의 개수는 MVVM 패턴보다 훨씬 줄어듭니다!
Compose를 프로젝트에 적용해보신 분들은 아시겠지만, 프로젝트 규모가 커질수록 컴포즈 내부에 파라미터 값의 개수가 기하급수적으로 많아지는 경험 겪어보셨을 겁니다
// 이전 프로젝트 중 코드 일부분 (예제코드와는 관련 x)
@Composable
fun UpLoadFormScreen(
viewModel: UpLoadFormViewModel,
upLoadFormScreenData: UpLoadFormScreenData,
isNextEnabled: Boolean,
onClosePressed: () -> Unit,
onPreviousPressed: () -> Unit,
onNextPressed: () -> Unit,
onCompletePressed: () -> Unit,
purchaseHelper: PurchaseHelper,
content: @Composable (PaddingValues) -> Unit
)
실제로 제가 이전에 진행한 프로젝트 중 일부 Composable을 찾아봤는데(MVVM 패턴 적용), 파라미터값이...정말..많죠?
해당 코드를 사용하면 유지보수도 어렵고, 코드를 읽고 이해하는 시간이 매우 오래걸리는 치명적인 단점이 존재합니다.
마치 콜백지옥과 같은 경험을 겪을 수 있습니다.
하지만 Model, Intent 정의와 같은 보일러 플레이트 코드가 발생하는 단점이 존재합니다 (역시 뭐든 장점만 존재하는 것은 없습니다)
Compose를 활용해 프로젝트를 진행하다보면 Composable 함수를 최대한 컴포넌트별로 분리시키는 작업을 많이 하게됩니다.
여기서 Composable끼리 엮이는 것을 피할 수 없지만 MVI 패턴을 활용한다면 코드의 유지보수성과 생산성을 크게 올려줍니다
(MVVM에서 MVI로 넘어가는 큰 이유 중 하나라고 볼 수 있습니다 🧐)
class FirstViewModel: BaseViewModel<FirstContract.Event, FirstContract.State, FirstContract.Effect>() {
init { initScreen() }
// 초기상태 정의
override fun setInitialState() = FirstContract.State(
count = 0,
isLoading = true,
isError = false
)
// 이벤트 발생 정의
override fun handleEvents(event: FirstContract.Event) {
when(event) {
is FirstContract.Event.NavigateAction -> setEffect { FirstContract.Effect.Navigation.ToSecond(event.num) }
is FirstContract.Event.Retry -> {}
is FirstContract.Event.OnIncreaseCount -> { increaseCount() }
is FirstContract.Event.OnDecreaseCount -> { decreaseCount() }
}
}
private fun increaseCount() {
setState { copy(count = count + 1) }
}
private fun decreaseCount() {
setState { copy(count = count - 1) }
}
// 초기화면 정의
private fun initScreen() {
viewModelScope.launch {
try {
setState { copy(isLoading = true, isError = false) }
delay(1000) // 임의적으로 로딩 시간 설정
setState { copy(isLoading = false, isError = false) }
}catch (e: Exception){
setState { copy(isLoading = false, isError = true) }
}
}
}
}
ViewModel를 살펴보면 MVVM과 다르게 LiveData, StateFlow가 정의되어있지 않고 위에서 정의한 Model과 Intent의 중계역할을 한다고 볼 수 있습니다!
MVI 패턴을 Compose에 적용해보며 느낀점은 React의 리덕스와 정말 비슷하다는 점입니다.
리덕스 또한 상태관리를 용이하게 해주는 라이브러리인데, MVI 패턴에서 영감을 받지 않았나 싶은 생각이듭니다!
또한 Compose에 가장 알맞는 디자인 패턴은 MVI 패턴이라고 확신이 드는 시간을 가지게 되었습니다.
앞서 말씀드렸지만 기존 MVVM을 적용한 프로젝트에선 Composable 내부에 너무 많은 파라미터들이 존재해 힘든 경험을 겪은적이 있었고, ViewModel에 StateFlow를 덕지덕지 붙이는 것도 보기 너무 불편했거든요..
MVI 패턴을 적용하기 위해 기본적으로 작성해야 할 코드들이 많은 것과 어느정도의 러닝커브가 단점이 될 수 있겠지만 해당 단점을 감수하더라도 충분히 매력적이고 Compose에 최적화된 디자인 패턴이라고 생각합니다!
https://github.com/JunYeong0314/MVI-Compose
https://engineering.teknasyon.com/how-to-implement-mvi-with-delegates-on-android-f2aa1a842b73