Compose는 MVI를 좋아해..

seunghee song·2024년 2월 20일

작년 이맘때쯤 부터 본격적으로 안드로이드를 깊게 파면서 겨우 MVVM패턴에 익숙해지고 편해진 상태였는데
어느새 트렌드가 바뀌어서 생이별을 하게되었다..

Android 개발 추세가 XML에서 Compose로 판도가 바뀌어져오고 있는 추세이다.
프론트 개발자가 유행을 따라가지 못하면 죽는것이기에 Compose를 공부하면서 Compose에 알맞는 아키텍처인 MVI 패턴에 대해서 공부해 보았다.

MVI 아키텍처를 표현한 아주 기본적인 그림이다. 하지만 그림으로만 보면 절대 이해가 가지 않는다. 적용하면서 알아보도록 하자.

정말 여러 예제를 보았는데 공통적으로

interface UiState
interface UiEvent
interface UiEffect

를 interface로 정의를 하였다.
하나씩 뜯어 보면

  • UiState: 현상태의 view를 의미한다.
  • UiEvent: 사용자의 액션을 의미한다.
  • UiEffect: side Effect를 의미한다.

❗️잠깐 sideEffect란?
컴포저블 함수가 재구성될 때 상태변화를 감지하고 부수적인 작업을 실행한다. ui와 직접적으로 관련되지 않은 외부작업을 수행한다.
(예시: 데이터베이스 업데이트, 네트워크 요청)


개인적으로 sideEffect의 개념을 아주 잘 표현한 구조도라고 생각한다.

돌아가서 이는 MVI 아키텍처에서 UI 상태, 이벤트, 사이드 이펙트를 관리하는데 중요한 역할을 한다.

1단계 [화면에 따른 Contract정의]
Contract class는 MVI 패턴이 구현되어 있는 곳이라면 존재하는 것을 심심치않게 보았을 것이다.
이 클래스는 특정 화면에 대한 관련 요소를 모두 정의하는 역할을 한다. 이를 통해 해당 화면의 state, event, effect를 관리하는 방법을 명시적으로 선언한다.

class MainContract{
    sealed class Event: UiEvent{
        object Increase : Event()
        object Decrease : Event()
    }

    data class State(
        val data : Int
    ):UiState


    sealed class Effect : UiEffect{
        object showToast : Effect()
    }
}
  1. State: 화면의 UI를 구성하기 위해 필요한 데이터를 모아놓는 data class이다. 지금은 예시코드라서 단순하게 정의되어 있지만 예를 들어 홈 화면에서 Loading 상태, 네트워크 에러상태, 데이터 호출에 실패한 상태, 데이터를 성공적으로 호출한 상태로 이루어져 있을 수도 있다.
  2. UiEffect: 단발성 이벤트를 전파하기 위한 side Effect의 모음이다. 예시에서는 Toast메세지가 나오도록 하였다.
    실제로는 API 호출, 네비게이션 등등이 여기에 정의될 수 있다.
  3. UiEvent: 유저의 행위를 담은 모음이다. 예시에서는 값이 증가하는 행위, 감소하는 행위로 나누었다.

2단계 [BaseViewModel 구성]
State, Event, SideEffect를 세팅할 수 있도록 BaseViewModel을 구성해야한다.
createInitialState 를 정의하여 최초 진입시 보여지는 값을 세팅하고 StateFlow를 통해 상태를 관리한다.

event의 경우 SharedFlow를 사용하여 관리하는데 이는 구독자가 없는 경우 즉시 이벤트를 삭제하기 위해서 SharedFlow를 사용하였다.
SideEffect의 경우 channel로 구현하여 연속적인 단발성 이벤트가 발생할 때 버퍼에 순차적으로 담고 먼저 발생한 이벤트를 방출할 수 있도록 구현했다.

❓왜 하필 Channel로 했을까

라는 의문점이 들어서 찾아보니 channel의 경우 단발성 이벤트 즉 한번더 보여줄 필요가 없기때문에 channel buffer가 가득차면 작업중단되고 구독자가 나타날때 까지 기다리는 channel을 사용한 것이다.

interface UiState
interface UiEffect
interface UiEvent

abstract class BaseViewModel<Event: UiEvent, State : UiState, Effect : UiEffect> : ViewModel() {
    private val initialState : State by lazy {createInitialState()}
    abstract fun createInitialState() : State

    private val _uiState : MutableStateFlow<State> = MutableStateFlow(initialState)
    val currentState: State get() = _uiState.value

    val uiState =  _uiState.asStateFlow()

    private val _event : MutableSharedFlow<Event> = MutableSharedFlow()
    val event = _event.asSharedFlow()

    private val _effect : Channel<Effect> = Channel()
    val effect = _effect.receiveAsFlow()

    init {
        subscribeEvents()
    }

    private fun subscribeEvents(){
        viewModelScope.launch {
            event.collect{
                handleEvent(it)
            }
        }
    }
    abstract fun handleEvent(event : Event)

    fun setEvent(event:Event){
        val newEvent = event
        viewModelScope.launch {
            _event.emit(newEvent)
        }
    }
    protected fun setState(state: State){
        _uiState.value = state
    }
    //부수효과 처리하기 위해 정의된 메서드
    //이 함수를 정의한 클래스 또는 하위클래스에서만 접근할 수 있다
    protected fun setEffect(effect : Effect){
        viewModelScope.launch {
            _effect.send(effect)
        }
    }
}

3단계 [BaseViewModel을 실제 ViewModel에 적용해주기]
viewModel에서는 화면의 데이터를 핸들하기 위해 값을 변경하고 effect를 주는 로직을 정의한다.

@HiltViewModel
class MainViewModel @Inject constructor(): BaseViewModel<MainContract.Event, MainContract.State, MainContract.Effect>() {
    override fun createInitialState(): MainContract.State {
        return MainContract.State(data = 0)
    }

    override fun handleEvent(event: MainContract.Event) {
        when(event){
            is MainContract.Event.Increase -> {
                val newState = currentState.copy(data = currentState.data + 1)
                setState(newState)
            }
            is MainContract.Event.Decrease -> {
                val newState = currentState.copy(data = currentState.data-1)
                setState(newState)
            }
        }
    }

    fun showEffect(){
        setEffect(MainContract.Effect.showToast)
    }


}

4단계 [viewModel 적용기]
정의한 viewModel을 hilt의존성 주입을 통해 MainActivity에 주입시켜준다.

@AndroidEntryPoint
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            ArchitecturePracticeTheme {
                // A surface container using the 'background' color from the theme
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colorScheme.background
                ) {
                    MainScreen()
                }
            }
        }
    }
}

@Composable
fun MainScreen(viewModel: MainViewModel = hiltViewModel()){
    val state by viewModel.uiState.collectAsState()
    Column (
        Modifier.fillMaxSize(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ){
        Text(text = state.data.toString())
        Button(onClick = { viewModel.setEvent(MainContract.Event.Increase) }) {
            Text("Increase")
        }
        Button(onClick = { viewModel.setEvent(MainContract.Event.Decrease) }) {
            Text("Decrease")
        }
    }
}

@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
    ArchitecturePracticeTheme {
        MainScreen()
    }
}

이렇게 해서 데이터가 단방향으로 전달되는 MVI 아키텍처를 연습해보았다.
Compose에 적합한 패턴이라는 생각이 들면서 다소 러닝커브가 높은거 같다.. 좀더 공부해 봐야겠다..

신기술이 우르르 쏟아지는 카오스 속에서 안드개발자 화이팅...!

참고

https://proandroiddev.com/mvi-architecture-with-kotlin-flows-and-channels-d36820b2028d

profile
안드로이드 개발자

1개의 댓글

comment-user-thumbnail
2025년 3월 10일

덕분에 잘 보고 갑니다~!!

답글 달기