[Android] MVI Pattern - (1)

문승연·2024년 1월 15일
1

Android-MVI

목록 보기
1/1

1. Compose + MVVM... is Good?

최근 진행 중인 사이드 프로젝트에서 Jetpack Compose를 다루기 시작하면서 Compose에 대해 이것 저것 알아보는 시간이 늘어나고 있다.

Compose로 개발을 하면서 느낀 건 바로 State 관리의 중요성이다. Compose는 기존의 안드로이드 xml처럼 명령형 UI가 아닌 선언형 UI다. 이미 선언된 UI에 표시되는 데이터를 변경하기 위해서는 State를 변경하여 Recomposition (재구현)을 진행해야한다.

즉, Compose를 사용하면서 State를 다루지 않겠다는 건 화면 UI에 초기 데이터 이후 어떤 데이터 변경도 표시하지 않겠다는 이야기다.

이렇게 State 관리의 중요성을 알다보니 생각보다 꽤 머리가 복잡해졌다. 여러가지 이유가 있지만 그 중 하나는 기존의 가장 보편적인 안드로이드 디자인 패턴인 MVVM 패턴이 Compose에 과연 잘 맞나? 하는 의문이 들기 시작한 것이다.

내가 가장 크게 느낀 부분은 바로 생명주기의 차이이다.

Composable 구성요소들은 State가 변경되는 Recomposition이 일어나면서 재구성이 일어나면서 새로 생성된다. 하지만 ViewModel은 종속된 Activity나 Fragment가 완전히 종료될때까지 동일한 인스턴스를 호출한다.

그렇기때문에 만약 ViewModel에서 특정 Composable의 생명주기에 의존하는 값이나 메소드를 가지고 있을 경우 의도치 않은 다른 결과를 불러일으킬 수도 있다.

실제로 안드로이드 공식 홈페이지에서도 이 점에 대해서 주의할 것을 명시하고 있다.

class GreetingActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent {
            MaterialTheme {
                Column {
                    GreetingScreen("user1")
                    GreetingScreen("user2")
                }
            }
        }
    }
}

@Composable
fun GreetingScreen(
    userId: String,
    viewModel: GreetingViewModel = viewModel(
        factory = GreetingViewModelFactory(userId)
    )
) {
    val messageUser by viewModel.message.observeAsState("")
    Text(messageUser)
}

class GreetingViewModel(private val userId: String) : ViewModel() {
    private val _message = MutableLiveData("Hi $userId")
    val message: LiveData<String> = _message
}

class GreetingViewModelFactory(private val userId: String) : ViewModelProvider.Factory {
    @Suppress("UNCHECKED_CAST")
    override fun <T : ViewModel> create(modelClass: Class<T>): T {
        return GreetingViewModel(userId) as T
    }
}

위처럼 GreetingScreen이 서로 다른 userId 값을 값고 2번 호출되어도 GreetingViewModelGreetingActivity가 완전히 종료되기 전까지 동일한 인스턴스를 반환하기 때문에 2번의 GreetingScreen은 모두 "user1"에 대한 인사말을 표시하게된다.

2. MVI 패턴이란?

이런저런 이유로 Compose에 대해서 좀 더 알아보고 있을 때 우연히 MVI 패턴에 대한 이야기를 들었다. 찾아보니 대충 MVI 패턴을 사용하면 State(상태) 관리를 좀 더 쉽게 다룰 수 있다는 이야기를 들었고 Compose로 UI를 구성한 앱에서 효과적인 디자인 패턴이라는 아닐까하는 생각이 들었다.

MVI 패턴의 구조

MVI 패턴은 Model, View, Intent 크게 3가지 구성요소로 이루어져있다.

  • Model: UI에 반영될 상태를 의미한다. 데이터를 의미하는 MVP, MVVM에서의 정의와는 다르다.
  • View: UI 그 자체. View, Activity, Fragment, Compose 등이 될 수 있다.
  • Intent: 우리가 흔히 안드로이드에서 다루는 그 Intent와는 다르다. MVI에서는 사용자 액션 및 시스템 이벤트에 따른 결과라고 해석할 수 있다.

쉽게 설명하자면, User(사용자)가 View를 클릭하거나 하는 등의 행동(Action)을 취하면 이 행동이 Intent(의도)가 되어서 Model에 전달된다. 이 Intent가 Model, 즉 상태를 업데이트한다. 그리고 이 변경된 상태가 다시 View에 반영된다.

MVI의 핵심은 단방향 흐름(Uni-directional Flow) 구조이다. 그래서 아래 그림처럼 표현하기도 한다.

기존의 MVVM 관점에서 MVI 패턴을 계층 구조로 나누어 보자면 아래 그림처럼 표시할 수 있다.

그렇다! 나는 MVVM과 MVI를 완전히 다른 새로운 개념이라고 인식했는데 그게 아니었다. MVI에서도 여전히 상태 관리를 위해 ViewModel을 사용하며 기존의 MVVM에서 Compose의 상태 관리를 좀 더 수월하게 하기 위해 나온 개념이라고 생각하는 게 맞는 거 같다.

3. 예제

UiState, UiEvent, UiEffect 인터페이스

먼저 3개의 인터페이스가 필요하다.

interface UiState

interface UiEvent

interface UiEffect
  • UiState: View의 상태(State)를 나타낸다.
  • UiEvent: 사용자(User)의 행동(Action)을 나타낸다.
  • UiEffect: 에러 메세지 표시와 같이 단 한번만 보여주고자 하는 Side Effect를 나타낸다.

BaseViewModel 구현

다음은 ViewModel의 Base 클래스를 구성한다.

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()
  
}

특이한 점은 State, Event, Effect를 각각 StateFlow, SharedFlow, Channel로 다르게 관리한다는 것이다.

StateFlow vs SharedFlow vs Channel

StateFlow는 초기값을 가지고 있다는 점만 제외하면 기존의 LiveData와 크게 다르지 않다. 초기값을 가져야하고 항상 최신 값을 필요로 하는 UiState에 적절하다.

SharedFlow는 발생하는 이벤트 구독자(Subscribers)가 0명일수도 있고 여러명일수도 있다.(이벤트 공유) 만약 구독자가 한명도 없다면, 이벤트는 그대로 무시된다(dropped). 이벤트를 처리해야하는 구독자(subscriber)가 존재하지 않는다면 무시될 필요가 있는 UiEvent에 적절하다.

반면에 Channel은 각각의 이벤트가 오직 하나의 구독자에게만 전달된다.(이벤트 공유X) 만약 구독자가 없을 때 이벤트가 발생했다면 채널 버퍼가 가득차자마자 구독자가 나타날때까지 일시중지된다(suspend). 따라서 이벤트가 무시되지 않는다.
Channel은 Hot Stream이기도 하고 방향이 변경되거나 UI가 다시 표시될 때 Side Effect를 다시 표시할 필요가 없다. 단순하게 SingleLiveEvent 동작을 복제하고 싶기 때문에 Channel을 사용한다.

다음은 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를 수집(collect)해야 한다. 이는 ViewModelinit 블럭에서 처리한다.

    init {
        subscribeEvents()
    }

    /**
     * Start listening to Event
     */
    private fun subscribeEvents() {
        viewModelScope.launch {
            event.collect {
                handleEvent(it)
            }
        }
    }

    /**
     * Handle each event
     */
    abstract fun handleEvent(event : Event)

이렇게 기본적인 BaseViewModel 구현은 끝났다. 이제 MainActivityMainViewModel사이를 이어줄 MainContract 구현을 할 차례다.

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()

    }

}

이 예제에서는 2개의 이벤트만이 존재한다. OnRandomNumberClicked는 사용자가 임의의 숫자 버튼을 클릭했을 때 호출된다. 물론 토스트 메세지를 호출하고 SingleLiveEvent 동작을 시뮬레이트하는 토스트 버튼도 있다.

RandomNumberStateIdle, Loading 그리고 Success라는 각기 다른 State를 갖는 StateHolder 클래스이다.

State는 UI 상태를 따르는 간단한 데이터 클래스이다.
EffectEvent 결과에 따라 한번만 보여주고 싶은 Side Effect들이 있는 클래스이다.

RandomNumberState처럼 View State를 sealed 클래스로 사용하지 않고 State 데이터 클래스에 변수로써 표현해도 된다.

date class State(
	val isLoading: Boolean = false,
    val randomNumber: Int = -1,
    val error: String? = null
): UiState

MainContract도 구현 완료됐으니 이제 MainViewModel에서 실제 로직에 적용할 차례다.

MainViewModel 구현

class MainViewModel<E: Event, S: State, E: Effect> : BaseViewModel() {
    /**
     * Create initial State of Views
     */
    override fun createInitialState(): MainContract.State {
    	return MainContract.State(
        	MainContract.RandomNumberState.Idle
        )
    }
    
    /**
    * Handle each event
    */
    override fun handleEvent(event: MainContract.Event) {
    	when (event) {
        	is MainContract.Event.OnRandomNumberClicked -> {
            	generateRandomNumber() 
            }
            is MainContract.Event.OnShowToastClicked -> {
            	setEffect { MainContract.Effect.ShowToast }
            }
        }
    }
    
    /**
     * 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 }
            }
        }
    }
}

handleEvent에서 이벤트에 대한 처리를 진행한다. 만약 Contract에 새로운 이벤트가 추가되면 handleEvent에도 해당 이벤트에 대한 처리 코드를 추가해줘야한다.

OnRadomNumberClicked 이벤트가 발생하면 generateRandomNumber 메소드를 호출한다. 이 메소드에서는 먼저 State를 Loading으로 바꾼 다음에 결과값에 따라서 State를 다시 Success 또는 Idle로 변경한다.

만약 에러가 발생했을 경우 토스트 메세지를 띄우는 Effect를 설정할 수도 있다.

이제 마지막으로 UI 단계에 적용하는 일만 남았다.

MainActivity

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를 구독해 최신 State를 collect하고 이에 맞춰 View를 업데이트 한다.

LiveData처럼 동작하기 위해서 launchWhenStarted 메소드를 사용한다. 이를 통해 Flow는 생명주기가 최소 STARTED 상태가 되고 나서야 State를 collect할 것이다.

요약

위에 구현된 예제 앱을 요약해보자.

먼저, 버튼 클릭과 같은 사용자 액션 Event를 발생시킨다. 그러면 그 이벤트의 결과로 변경되지않는 새로운 State를 설정한다. 이 State는 Idle, Loading, Success가 될 수 있다. StateFlow를 사용했기 때문에 새로운 State가 설정되자마자 UI를 이에 맞춰 업데이트한다.

만약 에러가 발생하거나 Toast 메세지 등을 띄워야한다면 새로운 Effect를 설정해 처리한다.

4. MVI 패턴의 장단점

장점
1. 상태 객체는 불변이므로 스레드로부터 안전하다.
2. State, Event, Effect 등의 모든 액션이 같은 파일에 있어 화면에서 일어나는 일을 한눈에 쉽게 이해할 수 있다.
3. 상태를 유지하는 것이 쉽다.
4. 데이터 흐름이 단방향으로 흐르기 때문에 추적이 쉽다.

단점
1. 많은 보일러플레이트 코드가 발생한다.
2. 많은 객체를 생성해야 하기 때문에 높은 메모리 관리가 필요하다.
3. 하나의 화면에서 많은 뷰와 복잡한 로직을 가지게 될 경우, State는 거대해지고 이 State를 단지 하나를 사용하는 대신 StateFlow를 추가 사용하여 더 작은 것으로 분할할 수 있다.

5. 결론

MVI 패턴은 MVC, MVVM과 같은 MVx 패턴에 가장 최근에 추가된 패턴이다. MVVM과 공통점이 많지만 상태 관리 측면에서 좀 더 구조화된 방법을 갖고 있다. 전체 예제 소스 코드는 링크에서 확인할 수 있다.

추가로 MVI 패턴을 쉽게 다룰 수 있게 해주는 라이브러리도 있다니 시간나면 한번 살펴보자
라이브러리 Github 링크

레퍼런스
1. Compose를 기존 앱 아키텍처와 통합
2. Compose 및 기타 라이브러리
3. Android 프로젝트에 MVI 도입하기
4. MVI Architecture with Kotlin Flows and Channels

profile
"비몽(Bemong)"이라는 앱을 개발 및 운영 중인 안드로이드 개발자입니다.

0개의 댓글