MVI

디자인 패턴

MVI

상태 관리


안드로이드는 어떠한 관점에선 다양한 상태의 집합인데, 화면에 나타나는 정보, 버튼 활성화 등 다양한 상태들로 구성되어 있다.

이러한 상태들이 많아질수록 관리하기 힘들어지고, 의도하지 않는 방향으로 제어가 된다면 네트워크 통신이 실패했음에도 프로그레스바가 지속적으로 보여진다든지와 같은 문제가 발생할 수 있다.

Compose 를 사용하게 된다면, Compose 의 업데이트는 상태의 변경(Recomposition)에 의해서 이루어지기 때문에 상태 관리의 중요성은 더욱 더 커지게 된다.


MVI


MVI 디자인 패턴은 Model, View, Intent 의 줄임말이며, 여기서 Intent 는 안드로이드의 Intent 를 말하는 것이 아니다.

MVI 는 단방향 구조를 가지고 있는데, 예를 들어, 사용자가 새롭게 View 를 갱신하고자 한다면, View 를 갱신하고자 하는 의도(Intent)가 새로운 Model 을 생성하게 되고, 이것이 상태를 업데이트 하게 되고 뷰에 반영되는 흐름을 가진다.

MVIModel 은 변경 불가한 데이터로, UI 에 상태를 표현하는 반영될 상태를 의미하며, 앱의 상태는 단방향 흐름에서 Intent 로부터 Model 을 생성할 때만 새로운 Model 을 생성한다.

이러한 구조를 가졌기 때문에 예측 가능한 상태를 설정할 수 있고, 디버깅이 쉬워진다.

Intent 는 앱의 상태를 바꾸고자 하는 의도를 말하는데, Model 은 이 Intent 를 통해서 새로운 상태로 변화할 수 있다.



Pure Cycle


MVI 는 순수 함수로 이루어진 Pure Cycle 로 표현할 수 있는데, 순수 함수는 어떤 함수에 동일한 인자를 주었을 때 항상 같은 값을 리턴하는 함수를 말한다.

Pure Cycleintent(event) == state 을 만족하는 사이클로, 어떠한 의도로 인해 발생하는 결과는 항상 상태이다.

// 1. 뷰에 렌더링 할 값인 total 을 가지는 Model(State)을 생성
data class NumberState (
    val num: Int = 0
)

// 2. ViewModel 생성 및 Intent 정의(Used Orbit)
class NumberViewModel : ViewModel(), ContainerHost<NumberState, Unit> {

    // ViewModel 에서 State 와, SideEffect 를 가지고 container 를 재정의
    // container 선언에는 State 와 SideEffect 를 명시해야 함
    override val container = 
        container<NumberState, Unit>(NumberState())

    // add 함수를 intent 에 담고, 연산된 결과를 reduce 를 통해 새로운 state 로 만듬
    // reduce 는 순수 함수로, 새로운 state 를 만듬
    fun add(num : Int) = intent { 
        reduce {
            state.copy(num = state.num + num)
        }
    }
}

// 3. Rendering
@Composable
fun NumberScreen(
    viewModel: NumberViewModel
) {
    // 상태 관찰
    val state by viewModel.container.stateFlow.collectAsState()
	
    Column {
        Button(
            onClick ={
                // 버튼의 이벤트로 viewModel.add() 를 전달하게 되면 
                // ViewModel 에서 Intent 로 감싸져 새로운 상태를 생성
                viewModel.add(1)
            }
        )
        Text("숫자: ${state.num}")
    }
}



Side Effect Cycle


MVIPure Cycle 에 부수 효과가 포함되어 있는 Side Effect Cycle 로도 표현할 수 있는데, 부수 효과는 외부의 상태를 변경하는 것으로, 함수로 들어온 인자의 상태를 직접 변경하는 것을 말한다.

Intent 를 통해 새로운 상태를 생성함과 동시에 부수 효과를 실행하면, 인텐트로 결과를 내보내거나 아무 일도 일어나지 않는다.

// 1. Side Effect 정의
sealed class NumberSideEffect {
    data class Toast(val text: String): NumberSideEffect()
}

data class NumberState (
    val num: Int = 0
)

// 2. Post SideEffect
class NumberViewModel : ViewModel(), ContainerHost<NumberState, NumberSideEffect> {

    // ViewModel 에서 State 와 SideEffect 를 가지고 container 를 재정의
    override val container = 
        container<NumberState, NumberSideEffect>(NumberState())

    // add 함수를 intent 에 담고, Toast 라는 SideEffect 에 
    // Toast 에 들어갈 메시지를 담아 SideEffect 를 보냄
    fun add(num : Int) = intent { 
        postSideEffect(
            NumberSideEffect.Toast("$number + ${state.num}")
        )

        // 연산된 결과를 reduce 를 통해 새로운 state 를 만들어 내는 과정은 Pure Cycle 과 동일
        reduce {
            state.copy(num = state.num + num)
        }
    }
}

// 3. Launch SideEffect
@Composable
fun NumberScreen(
    viewModel: NumberViewModel
) {
    /* ... */

    // Composable 함수에서 LaunchedEffect 블록 안에서, SideEffect를 수집하고, Toast를 실행
    LaunchedEffect(viewModel) {
        viewModel.container.sideEffectFlow.collect {
            when(it) {
                is CalculatorSideEffect.Toast -> {
                    Toast.makeText(context, it.text, Toast.LENGTH_SHORT).show()
                }
            }
        }
    }
}



MVI 장점 및 단점


장점

  • 상태 관리가 쉽다.
  • 데이터의 흐름이 단방향이기 때문에 흐름을 이해하고 관리하기 쉽다.
  • 각각 값이 불변하기 때문에, 스레드 안정성을 보장한다.
  • 디버깅 및 테스트가 쉽다.

단점

  • 러닝 커브가 높다.
  • 작은 변경도 Intent 로 처리해야 하기 때문에, 이로 인한 보일러 플레이트 코드가 양산된다.
  • Intent, State, Side Effect 등 모든 상태에 대한 객체를 생성해야 하므로 GC 가 빈번히 일어날 수 있어서 메모리 관리에 유의해야 한다.
essential