[Android] MVI Architecture with Kotlin Flows and Channels 번역

이승우·2023년 7월 17일
1

이 글은 해당 글에 대한 번역 및 이해를 바탕으로 작성한다.

MVVM은 권장하는 아키텍쳐이며 많은 개발자가 사용한다. 그러나 다른것들과 마찬가지로 아키텍쳐 패턴도 진화하고 있으며, MVI는 MVX 패턴들의 마지막 패턴이다. MVVM과 공통점이 많지만, 보다 구조화된 상태 관리 방식이 있다.

MVI는 3가지로 구성된다. Model - View - Intent

  • Model : 모델은 UI의 상태를 나타낸다. 예를 들어, UI는 Idle, Loading, Loaded와 같은 다양한 상태를 가질 수 있다.
  • View : View는 기본적으로 ViewModel 및 UI 업데이트에서 오는 불변 상태를 설정한다.
  • Intent : Intent는 전통적인 Android Intent가 아니다. UI와 상호작용할 때, 사용자의 의도를 나타낸다. ex) 버튼 클릭

적용

먼저, 클래스 유형을 설명하는 인터페이스를 만들어보자.

interface UiState

interface UiEvent

interface UiEffect
  • UiState : View의 현재 상태
  • UiEvent : 유저 액션
  • UiEffect : 한번만 표시하고 싶은 오류 메시지와 같은 부작용

이제 ViewModel의 Base Class를 만들어보자.

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

What is the difference between StateFlow — SharedFlow — Channel?

SharedFlow을 사용하면 이벤트가 알 수 없는 수(0개 이상)의 구독자에게 보내진다. 구독자가 없으면 게시된 모든 이벤트가 즉시 삭제된다. 즉시 처리해야 하거나 전혀 처리하지 않아야 하는 이벤트에 사용하는 디자인 패턴이다. (SharedFlow는 중복 처리가 가능하다.)

Channel을 사용하면 각 이벤트가 단일 구독자에게 전달된다. 구독자 없이 이벤트를 게시하려는 시도는 채널 버퍼가 가득차는 즉시 중단되어 구독자가 나타날 때까지 기다린다. 게시된 이벤트는 기본적으로 삭제되지 않는다.

UiState를 처리하기 위해 StateFlow를 사용한다. StateFlow는 LiveData와 비슷하지만, 초기값이 있다. 그래서 항상 상태를 가질 수 있다. 일종의 SharedFlow이기도 하다. UI가 표시될 때 항상 마지막 View 상태를 수신하려고 한다.

UiEvent를 처리하기 위해 SharedFlow를 사용한다. 구독자가 없으면 이벤트를 삭제하기를 원한다.

마지막으로 UiEffect를 처리하기 위해 Channel을 사용한다. Channel이 Hot이고 방향이 변경되거나 UI가 다시 표시될 때 부작용을 다시 표시할 필요가 없기 때문이다. 단순히 SingleLiveEvent 동작을 복제하려고 한다.

그러면 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를 ViewModel의 init block에서 collect 해야 한다.

    init {
        subscribeEvents()
    }

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

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

그럼 BaseViewModel의 구현을 완료했으므로 MainActivity와 MainViewModel 간의 계약인 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()

    }

}

Event는 2개가 있다. OnRandomNumberClicked 이벤트는 사용자가 임의의 숫자 버튼을 생성하기 위해 클릭할 때, 발생한다. 또한, 토스트 메시지를 표시하고 SingleLiveEvent 동작을 시뮬레이트 하는 간단한 토스트 버튼도 있다.

RandomNumberState는 Idle, Loading, Success와 같은 다양한 난수 상태를 유지하는 Sealed Class이다. 나중에 작성할 Random Generator 메소드는 네트워크 호출의 시뮬레이션이다.

State는 UI 요소의 상태에 해당하는 간단한 데이터 클래스이다.

Effect는 Event 결과에 따라 한 번만 보여주고 싶은 단순한 행동이다.

View State에서 Sealed Class를 사용할 필요는 없으며, 아래와 같이 간단한 변수를 사용할 수도 있다.

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

MainContract를 완료했으므로 실제 로직을 처리하는 MainViewModel을 조금 더 살펴보자.

    /**
     * Create initial State of Views
     */
    override fun createInitialState(): MainContract.State {
        return MainContract.State(
            MainContract.RandomNumberState.Idle
        )
    }

위와 같이 초기 view 상태를 만들어야 한다. 이번 예제에서는 비어있는 상태이다.


    /**
     * Handle each event
     */
    override fun handleEvent(event: MainContract.Event) {
        when (event) {
            is MainContract.Event.OnRandomNumberClicked -> { generateRandomNumber() }
            is MainContract.Event.OnShowToastClicked -> {
                setEffect { MainContract.Effect.ShowToast }
            }
        }
    }

handleEvent() 메소드에서 각 이벤트를 처리한다. Contract에 이벤트를 추가할 때마다 여기에다 추가해야 한다. 따라서 모든 이벤트를 같은 장소에서 관리할 수 있도록 일원화할 수 있다.

    /**
     * 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 }
            }
        }
    }
  • OnRandomNumberClicked 이벤트가 트리거될 때마다 generateRandomNumber() 메소드를 호출한다.
  • Loading 상태로 시작한 다음 결과에 따라 상태를 Success 또는 Idle 상태로 변경한다.
  • UseCase에 따라 오류 상태를 생성하고 숫자가 짝수일 때, 설정하려고 한다.
  • 오류가 발생한다면 Effect를 설정하고 토스트 메시지를 보여준다.

마지막 단계로 UI에 뷰 상태를 표시해야 한다.

        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를 collect하고 state data로 View를 refresh 한다.

그리고 복제 LiveData 동작의 경우, launchWhenStarted를 사용했다. 이렇게 하면 생명주기가 최소한 STARTED 상태일 때, flow가 collect 된다.

이제 Sample 앱을 완성할 수 있다. Sample 앱의 흐름은 아래와 같다.

  • 먼저, 버튼 클릭과 같은 사용자 액션과 관련된 이벤트를 시작한다.
  • 그런 다음 이 이벤트의 결과로 새로운 불변 상태를 설정한다.
  • 이 상태는 Idle, Loading, Success 일 수 있다.
  • StateFlow를 사용하기 때문에 새로운 State가 오자마자 UI가 업데이트된다.

오류가 있고 토스트 또는 경고 Dialog와 같은 일회성 메시지를 표시해야 하는 경우, 새로운 Effect를 설정한다.

[장점]

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

[단점]

  • 많은 보일러플레이트 코드가 발생한다.
  • 많은 객체를 생성해야 하기 때문에 높은 메모리 관리가 필요하다.
  • 때때로 우리는 많은 뷰와 복잡한 로직을 갖고 있다. 이런 종류의 상황에서 State는 거대해지고 우리는 이 State를 단지 하나를 사용하는 대신 StateFlow를 추가 사용하여 더 작은 것으로 분할할 수 있다.

MVI는 MVVM과 공통점이 많지만, 보다 구조화된 상태 관리 방식이 있다.

Ref

profile
Android Developer

1개의 댓글

comment-user-thumbnail
2023년 7월 18일

글이 많은 도움이 되었습니다, 감사합니다.

답글 달기