본문 바로가기

Android/Architecture

[Android] MVI Pattern을 적용해보자.

728x90

 

본 게시글은 이전 게시글에서 이어서 작성된 부분입니다.

2022.04.30 - [Android/Architecture] - [Android] MVI Pattern

 

MVI Pattern에 대한 기본적인 정리를 해보았으니,

이번에는 실제로 예제를 만들어보면서 조금 더 확실하게 이해해보고자 한다.

 

필자는 MVVM + MVI 형태의 샘플 코드를 만들어보았다.

필자가 샘플 코드를 작성하면서 진행했던 흐름대로 글을 작성할 생각이다.

 

프로젝트의 기본적인 구조는 Clean Architecture Sample에 사용된 것들을 가져왔으며,

Module을 나누어서 생성해도 됐겠지만, 조금 더 기본적인 구조에 초점을 맞춰서 이해하기 위하여 하나의 모듈로 만들었다.

기본적인 구조에 대한 것은 생략하고, MVI를 적용하는데 필요한 부분만 작성하려고 한다.


우선,

MVVM과 MVI를 합쳐서 사용할 예정이기 때문에 Intent에 대한 부분을 만들어 주어야 한다.

 

Base로 사용되는 Interface를 만들어 주도록 하자.

 

 

이전 MVI Pattern에 대한 설명을 할 때 그렸던 그림을 생각해보자.

User는 실 사용자이기 때문에 제외하고 Intent, Side Effect, Model, View와 추가로 State가 있는 것을 볼 수 있다.

 

interface IIntent
interface ISideEffect
interface IState

 

intent, SideEffect, State는 각 Activity에서 필요한 함수를 선언해서 사용할 부분이고,

 

interface IView<S: IState, SE: ISideEffect> {
    fun render(state: S)
    fun navigate(from: String)
}

 

View에서는 화면 처리를 위한 함수들을,

 

interface IModel<S: IState, I: IIntent, SE: ISideEffect> {
    val intents: Channel<I>
    val sideEffect: Channel<SE>
    val state: LiveData<S>
}

 

Model에서는 Intent에 따른 View를 변경하기 위해 필요한 값들을 가지고 있게 된다.

 

각 인터페이스들을 자세히 확인해보자.

 

Intent, Side Effect, State와 같은 경우에는 선언만 되어있고 아무런 코드가 존재하지 않는다.

이렇게 작성하는 이유는, 추후에 해당 Interface를 상속받아서 사용하게 되면 해당 Interface가 parent type이 될 수 있다.

따라서 Model, View 부분을 보면 알 수 있듯이, 처리할 때 해당 값들을 넣어 사용함으로써 어느 view에서 사용되는지 상관하지 않고 같은 형태의 구조로 구현이 가능하게 된다.

또한, 해당 interface로 선언한 경우에만 사용하겠다 라는 제한적인 의미도 가지고 있다고 생각한다.

 

View와 같은 경우에는, View를 변경시키기 위한 함수들이 들어가있다.

VIew는 Model의 상태인 State를 기준으로 변경이 이루어지게 되기 때문에 IState 값이 필요하게 되고,

Side Effect는 이전 글에서 설명했다시피, Intent에 의해서 Side Effect가 실행되고, 이 Side Effect가 다시 Intent가 될 수 있는 구조이기 때문에 해당 값이 필요할 경우가 생길 수 있기 때문에 추가해 두었다.

본 예제에서는 해당 값이 없어도 상관은 없다.

그리고 선언된 함수를 override 하여 State에 맞춰서 화면을 보여주면 되는 것이고, 여기에는 주로 render 함수를 사용한다.

 

Model에서는 Intent(Intent, Side Effect)를 받아서 View를 보여줄 데이터(Model)를 만들어서 State를 갱신시켜준다.

intents와 sideEffect는 Channel로 만들어 이벤트가 발생 시 수신하여 처리할 수 있게 해 주고,

state는 LiveData로 만들어, Intent에 따라 데이터를 처리하고 state를 변경하여 View를 변경할 수 있게끔 도와주는 것이다.

 

이게 무슨 소리겠거니 싶겠지만,

해당 인터페이스들의 사용에 대해서는 class를 확인해보면 이해가 갈 것이다.


sealed class MovieIntent : IIntent {
    object SearchMovie : MovieIntent()
    object NavigateToMainActivity : MovieIntent()
}

 

우선 Intent 인터페이스를 사용한 Class이다.

 

추상 클래스인 Sealed class로 선언하였고, 수행되는 이벤트를 Object로 만들어서 제한을 두고 사용할 수 있도록 한다.

즉, 해당 MovieIntent가 사용되는 부분은 2가지의 Intent만 존재한다.라는 뜻이 된다.

 

sealed class MovieSideEffect : ISideEffect {
    object NavigateToMainActivity : MovieSideEffect()
}

 

Side Effect 인터페이스를 사용한 Class이다.

샘플 예제에서는 Side Effect로 따로 뺄 필요 없이 Intent로만 사용해도 상관없지만, Side Effect를 사용했을 때의 data flow를 확인하기 위하여 넣어두었다.

Side Effect는 Intent에 의해서 호출되는 부분이기 때문에 object를 동일한 이름으로 선언해 두었다.

 

data class MovieState(
    val movies: List<MovieEntity> = listOf(),
    val isLoading: Boolean = false,
    val errorMessage: String? = null,
) : IState

 

State 인터페이스를 사용한 Data Class이다.

State는 View를 갱신할 때 사용되는 데이터이기 때문에 Data Class로 만들어서 사용하였다.

movies, loading, error의 3가지 변수 값에 따라서 View에서 보이는 것이 다르게 되는 것이다.

또한, View를 변경하는 Model이기 때문에, 해당 부분이 화면에 따라 1:1로 만들어져서 사용되는 부분이다.

 


다음으로,

해당 Interface와 Class가 사용되는 부분을 확인해보자.

 

우선 ViewModel이다.

 

class MovieSearchViewModel(
    private val movieApi: ApiInterface,
    private val networkManager: NetworkManager,
) :
    ViewModel(), IModel<MovieState, MovieIntent, MovieSideEffect>

 

Api와 NetworkManager는 상관없는 부분이라 무시하고,

ViewModel에서는 View에 보여줄 Data를 컨트롤하는 부분이기 때문에 IModel Interface를 사용할 수 있도록 하였다.

 

override val intents: Channel<MovieIntent> = Channel(Channel.UNLIMITED)
override val sideEffect: Channel<MovieSideEffect> = Channel(Channel.UNLIMITED)

private val _state = MutableLiveData<MovieState>().apply { value = MovieState() }
override val state: LiveData<MovieState>
    get() = _state

private val _navigation = MutableLiveData<String>().apply { value = "" }
val navigation = _navigation

 

IModel에 선언되어 있는 3가지 변수 값들을 모두 override 하였고, side Effect부분에 필요한 navigation도 선언해 두었다.

 

Channel로 선언된 두 개의 변수는, 사용자의 이벤트에 따라 Activity에서 Intent를 만들어서 던지고, 그것을 ViewModel에서 받아 사용하기 위하여 Channel로 선언하였다.

 

State는 Channel과 반대로, Activity에서 해당 값을 Observing 하고 있다가 ViewModel에서 값을 만들어서 갱신시키면 그것을 감지하고 화면을 변경시켜주기 위하여 LiveData로 선언하였다.

 

즉, 사용자의 이벤트에 따라 Intent가 발생하면 Channel을 사용하여 ViewModel에서 감지하고,

감지한 Intent를 토대로 Data를 변경하여 State를 갱신하면 Activity에서 State를 감지하여 View를 Update 시켜주는 구조이다.

 

 

private fun intentConsumer() {
    viewModelScope.launch {
        intents.consumeAsFlow().collect { userIntent ->
            if (!networkManager.checkNetworkState()) {
                networkError()
            } else {
                when (userIntent) {
                    MovieIntent.SearchMovie -> fetchData()
                    MovieIntent.NavigateToMainActivity -> sideEffect.send(MovieSideEffect.NavigateToMainActivity)
                }
            }
        }
    }
}

 

Intent를 감지하셔 사용하는 부분이다.

consumeAsFlow를 사용하여 Intent를 Channel을 사용하여 Send 하게 되면 해당 부분에서 받아서 바로 처리하게 된다.

else 문 안의 when 절을 보면 발생한 Intent의 종류에 따라 다른 동작을 하도록 되어있는 것을 볼 수 있을 것이며,

Navigate~ 부분을 보면 알 수 있겠지만, Side Effect를 호출하는 작업도 Intent에서 처리를 한다.

 

private fun fetchData() {
    viewModelScope.launch(Dispatchers.IO) {
        try {
            updateState { it.copy(isLoading = true, errorMessage = null) }

            flow {
                emit(movieApi.getSearchMovieFlow(searchText ?: "null"))
            }.collect { movie ->
                if (movie.movies.isNotEmpty()) {
                    updateState {
                        it.copy(isLoading = false, movies = movie.movies, errorMessage = null)
                    }
                } else {
                    updateState { it.copy(isLoading = false, errorMessage = "Do not found") }
                }
            }
        } catch (e: Exception) {
            updateState { it.copy(isLoading = false, errorMessage = e.message) }
        }
    }
}

 

API를 통해 Data를 가져오는 부분이다.

Loading에 관련된 Progress를 만들어두지 않았기 때문에 isLoading이 true일 때의 처리는 하지 않지만 프로그래스 바를 사용하면 해당 부분에서 visible로 설정하고, 다음 model을 만들 때 invisible 처리하면 되는 것이다.

 

api에서 데이터를 가져오고, 정상적으로 데이터가 있다면 movies에 데이터를 넣어서 state를 만들어주고, 그렇지 않으면 errorMessgae에 값을 넣도록 구현해주었다.

 

private suspend fun updateState(handler: suspend (intent: MovieState) -> MovieState) {
    _state.postValue(handler(state.value!!))
}

 

updateState는 liveData로 선언한 state 값을 갱신해주는 메서드이다.

위의 fetchData에서 Data에 따라서 다른 state를 만들어서 갱신해주고,  activity에서 data에 따라 다른 view 처리를 해주면 되는 것이다.

 

다음으로 activity를 확인해보자.

 

class MovieSearchActivity : AppCompatActivity(), IView<MovieState, MovieSideEffect>

 

View를 보여주는 부분이기 때문에 View에 대한 Interface를 사용하도록 하였다.

앞서 말했다시피, SideEffect부분은 해당 예제에서는 사용되지 않으므로 생각하지 않아도 된다.

 

viewModel.state.observe(this, Observer {
    render(it)
})

viewModel.navigation.observe(this, Observer {
    navigate(it)
})

 

우선, 앞서 ViewModel에서 선언했던 LiveData에 대한 Observe를 선언하여 해당 값들의 변화를 감지하도록 해준다.

Model이 변경되어 View의 갱신이 필요할 때 해당 값들이 갱신되기 때문에, 갱신되는 State를 사용하여 View를 갱신하도록 한다.

 

override fun render(state: MovieState) {
    with(state) {
        movieAdapter.submitList(movies)

        if (errorMessage != null) {
            Toast.makeText(this@MovieSearchActivity, "$errorMessage", Toast.LENGTH_SHORT).show()
        }
    }
}

 

render는 MovieState 값을 가져와서 state안에 저장된 값에 따라서 view를 갱신해주도록 한다.

movies값이 있으면 recyclerView에 추가하도록 하고, errorMessage에 값이 있으면 Toast를 띄워주도록 한다.

물론, 이 부분에 추가적인 조건을 넣어서 Loading이 true일 때 Progress를 보이도록 하는 작업을 추가하면 된다.

 

이처럼 state값이 갱신되면 그것을 토대로 Activity에서 View를 Update 함으로써 MVI Pattern에서의 한 사이클이 완성되는 것이다.

 

사이클을 간단하게 정리해보자.

  1. View에서 User로부터 Event를 받는다.
  2. Intent(UserEvent)를 생성하여 ViewModel을 호출한다.
  3. ViewModel에서 Intent에 따라 필요한 DataFlow를 호출한다.
  4. Data Flow를 끝내고 필요한 데이터를 사용하여 UserState를 갱신한다.
  5. View(Activity,Fragment)에서 Observing 하고 있던 State의 변화를 탐지한다.
  6. State의 값에 따라 UI를 Update 한다.

MVI Pattern에 대한 기본적인 구조를 한번 작성해 보았는데, 다른 Pattern에 비하여 생각보다 러닝 커브가 높다고 생각되었다.

해당 글을 작성하면서, 어떤 식으로 사이클이 돌고 있는지 확인하고 조금 더 이해를 할 수 있는 기회가 되었다고 생각하지만,

여전히 아직 명확하게 해당 패턴을 사용할 수 있을 것이라고는 말하기 힘들 것 같다.

 

언제 기회가 될지는 모르겠지만, MVI Pattern를 사용한 조금 더 심화된 샘플을 만들어보면서 해당 패턴에 대한 이해도를 높여야겠다는 생각을 한다.

 

해당 게시글에 사용한 예제는 Github에 올려두었다.

https://github.com/HeeGyeong/MviArchitectureSample

 

GitHub - HeeGyeong/MviArchitectureSample

Contribute to HeeGyeong/MviArchitectureSample development by creating an account on GitHub.

github.com

 

728x90