[Android] MVI 패턴을 알아볼까?
들어가기 전에
본격적으로 프로젝트를 시작하기 전에 적절한 UI 갱신 및 리컴포지션 관리에 대해 관심이 생겼다.
이는 Compose에서 성능을 위해 필수적 요소라고 생각한다..
나는 상태관리를 엉망으로 하고 있다는 걸 평소에 느꼈기 때문에 상태관리에 집중하고자 MVI 패턴을 공부하려 한다. (우연히 본 깃허브와 유튜브가 흥미를 돋게 한 것도 있다)
MVI 패턴
MVI 패턴은 Model, View, Intent로 크게 3가지 구성요소로 이루어져 있다.
- Model: UI에 반영될 상태
- View: UI
- Intent : 사용자 액션, 시스템 이벤트에 따른 결과
- (`android.content.Intent` 아님)
M-V-I의 관계는 순수함수 형식으로 표현 가능한데, 단방향 흐름을 나타내고 있다.
View에서 버튼 클릭과 같은 이벤트가 발생하면 그 버튼 클릭을 의도(Intent)로 보고 Model을 업데이트한다. 그럼 변경된 상태가 View에 반영되는 구조이다.


코드 작성
`orbit` 등 MVI를 위한 위대한 라이브러리가 존재하지만, 이해를 위해 차근차근 코드를 작성해서 공부해보려 한다.
State, Intent, SideEffect
일단 상태를 위한 클래스를 작성한다.
이 부분을 `interface` 로 작성하기도 하는데, 필자는 다중 상속이 불가능하다고 생각하여 `추상 클래스`로 다음과 같은 의미로 정의했다.
- `UiState`: 현재 상태
- `UiIntent` : 사용자의 의도(행동)
- `UiSideEffect` : 화면 이동, 로깅, 에러메시지 등과 같은 SideEffect
abstract class UiState
abstract class UiIntent
abstract class UiSideEffect
BaseViewModel
그리고 `BaseViewModel` 을 작성한다.
앞서 정의한 `UiState` ,`UiIntent` , `UiSideEffect` 를 상속한 타입만 오도록 제네릭 타입 파라미터로 선언해 준다. 그리고 상태는 초기 값이 필요하기 때문에 초기값도 함께 받아준다. 이는 뷰모델이기 때문에 당연히 뷰모델을 상속받는다.
abstract class BaseViewModel<State: UiState, Intent: UiIntent, SideEffect: UiSideEffect>(
initialState: State
):ViewModel() { .. }
여기서는 각각의 특징을 고려하여 다음과 같이 선언했다.
`State` - `StateFlow` : 초기값 설정 가능, 최신값을 필요로 함.
`Intent` - `SharedFlow` : 이벤트를 처리해야 하는 구독자가 존재하지 않는다면 무시될 필요가 있음.
`SideEffect` - `Channel` : 단발성 이벤트 처리, 각각의 이벤트가 오직 하나의 구독자에게만 전달.
val currentState: State
get() = uiState.value
private val _uiState: MutableStateFlow<State> = MutableStateFlow(initialState)
val uiState: StateFlow<State>
get() = _uiState.asStateFlow()
private val _intent: MutableSharedFlow<Intent> = MutableSharedFlow()
val intent = _intent.asSharedFlow()
private val _sideEffect: Channel<SideEffect> = Channel()
val sideEffect = _sideEffect.receiveAsFlow()
차례대로 `UiState` , `UiIntent` , `UiEffect` 의 `setter` 를 선언해 준다.
/**
* State 설정
*/
fun reduce(reducer: State.() -> State) { _uiState.value = currentState.reducer() }
/**
* Intent 설정
*/
fun postIntent(intent: Intent) = viewModelScope.launch { _intent.emit(intent) }
/**
* SideEffect 설정
*/
fun postSideEffect(builder: () -> SideEffect) = viewModelScope.launch { _sideEffect.send(builder()) }
그리고 `Intent` 를 처리하기 위해서 Flow를 collect 해줘야 한다. 그리고 Intent를 핸들링하는 부분은 모든 뷰모델에서 각각의 상황에 맞게 처리해줘야 하기 때문에 추상 함수로 정의해준다.
/**
* Intent 구독
*/
private fun subscribeIntent() = viewModelScope.launch {
intent.collect { handleIntent(it) }
}
/**
* Intent 핸들러
*/
abstract fun handleIntent(intent: Intent)
BaseViewModel 코드 전체 보기
/**
* State - StateFlow : 초기값을 가져야하고 항상 최신값을 필요로 함
* Intent - SharedFlow : 이벤트를 처리해야하는 구독자가 존재하지 않으면 무시될 필요가 있음
* Effect - Channel : 이벤트 공유 X
*
*/
abstract class UiState
abstract class UiIntent
abstract class UiSideEffect
abstract class BaseViewModel<State: UiState, Intent: UiIntent, SideEffect: UiSideEffect>(
initialState: State
):ViewModel() {
val currentState: State
get() = uiState.value
private val _uiState: MutableStateFlow<State> = MutableStateFlow(initialState)
val uiState: StateFlow<State>
get() = _uiState.asStateFlow()
private val _intent: MutableSharedFlow<Intent> = MutableSharedFlow()
val intent = _intent.asSharedFlow()
private val _sideEffect: Channel<SideEffect> = Channel()
val sideEffect = _sideEffect.receiveAsFlow()
init {
subscribeIntent()
}
/**
* Intent 구독
*/
private fun subscribeIntent() = viewModelScope.launch {
intent.collect { handleIntent(it) }
}
/**
* Intent 핸들러
*/
abstract fun handleIntent(intent: Intent)
/**
* State 설정
*/
fun reduce(reducer: State.() -> State) { _uiState.value = currentState.reducer() }
/**
* Intent 설정
*/
fun postIntent(intent: Intent) = viewModelScope.launch { _intent.emit(intent) }
/**
* SideEffect 설정
*/
fun postSideEffect(builder: () -> SideEffect) = viewModelScope.launch { _sideEffect.send(builder()) }
}
이로써 MVI의 적용의 준비의 준비의 준비가 끝났다.
준비의 준비 (ViewModel 구현)
필자는 예제로 간단한 화면을 구상하려는데,
- api로 post 받아오기 (로딩)
- refresh 버튼을 통해 새로고침하기
- 토스트 메시지 띄우기
이 세 가지 기능을 추가했다.
이 화면에 필요한 `State` , `Intent` , `SideEffect` 를 선언해 준다.
이렇게 되면 `상태`에는 데이터와 로딩이 존재하고, `의도`에는 클릭과 새로고침, `SideEffect`에는 토스트 띄우기가 있게 된다.
사실 `PostState`에서 기본값을 미리 선언해 줘도 되긴 하는데 유연한 대응을 위해 초기화 부분을 따로 만들었다.
data class PostState(
val post: List<ExamplePost>,
val isLoading: Boolean
): UiState() {
companion object {
fun init() = PostState(post = emptyList(), isLoading = false)
}
}
sealed class PostIntent : UiIntent() {
data class OnClick(val post: ExamplePost): PostIntent()
data object Refresh: PostIntent()
}
sealed class PostSideSideEffect: UiSideEffect() {
data object ShowErrorToast: PostSideSideEffect()
data class ShowToast(val message: String) : PostSideSideEffect()
}
그리고 이를 바탕으로 화면과 연동할 뷰모델을 구현해 준다. 그리고 추상 함수였던 `handleIntent` 를 `Intent`에 맞게 구현해 준다.
@HiltViewModel
class PostViewModel @Inject constructor(
private val repository: PostRepository
):BaseViewModel<PostState, PostIntent, PostSideSideEffect>(
PostState.init()
) {
..생략..
override fun handleIntent(intent: PostIntent) {
when(intent) {
is PostIntent.OnClick -> {
onClick(intent.post.title)
}
is PostIntent.Refresh -> {
onClick("Refresh")
fetchPost()
}
}
}
..생략..
}
그리고 생략된 부분에는 post를 가져오는 부분, 클릭이벤트를 처리하는 부분 등이 있다.
앞서 `BaseViewModel` 에서 선언한 함수들이 쓰인다. (`reduce` , `postSideEffect` 등..)
init {
fetchPost()
}
..생략..
private fun fetchPost() = viewModelScope.launch {
reduce { copy(isLoading = true) }
delay(1000)
repository.getPost()
.onSuccess {
reduce { copy(post = it) }
}.onFailure {
postSideEffect { PostSideSideEffect.ShowErrorToast }
}.also {
reduce { copy(isLoading = false) }
}
}
private fun onClick(message: String) {
println("onclick :$message")
postSideEffect { PostSideSideEffect.ShowToast(message) }
}
PostViewModel 전체 코드
data class PostState(
val post: List<ExamplePost>,
val isLoading: Boolean
): UiState() {
companion object {
fun init() = PostState(post = emptyList(), isLoading = false)
}
}
sealed class PostIntent : UiIntent() {
data class OnClick(val post: ExamplePost): PostIntent()
data object Refresh: PostIntent()
}
sealed class PostSideSideEffect: UiSideEffect() {
data object ShowErrorToast: PostSideSideEffect()
data class ShowToast(val message: String) : PostSideSideEffect()
}
@HiltViewModel
class PostViewModel @Inject constructor(
private val repository: PostRepository
):BaseViewModel<PostState, PostIntent, PostSideSideEffect>(
PostState.init()
) {
init {
fetchPost()
}
override fun handleIntent(intent: PostIntent) {
when(intent) {
is PostIntent.OnClick -> {
onClick(intent.post.title)
}
is PostIntent.Refresh -> {
onClick("Refresh")
fetchPost()
}
}
}
private fun fetchPost() = viewModelScope.launch {
reduce { copy(isLoading = true) }
delay(1000)
repository.getPost()
.onSuccess {
reduce { copy(post = it) }
}.onFailure {
postSideEffect { PostSideSideEffect.ShowErrorToast }
}.also {
reduce { copy(isLoading = false) }
}
}
private fun onClick(message: String) {
println("onclick :$message")
postSideEffect { PostSideSideEffect.ShowToast(message) }
}
}
준비
UI에 적용하면 MVI 적용 준비는 끝난 것이다.
`state` 는 다음과 같이 collect 했다
val uiState by viewModel.uiState.collectAsStateWithLifecycle(
lifecycleOwner = androidx.compose.ui.platform.LocalLifecycleOwner.current
)
그리고`intent`호출이 필요한 부분은 다음과 같이 해줬다.
viewModel.postIntent(PostIntent.OnClick(it))
`sideEffect` 는 다음과 같이 collect 했다
LaunchedEffect(key1 = viewModel) {
viewModel.effect.collect {sideEffect ->
when (sideEffect) {
is PostSideEffect.ShowToast -> {
showToast(context, sideEffect.message)
}
is PostSideEffect.ShowErrorToast -> {
showToast(context, "알 수 없는 에러입니다.")
}
}
}
}
`sideEffect` 를 저런 식으로 항상 호출하는 게 번거로울 것 같아 collect 하는 확장함수를 참고했다.
`collectAsStateWithLifecycle` 를 참고하여 Flow 확장함수를 작성했다.
- Flow(`flow`), 라이프사이클(`lifecycle`), 최소 활성 상태(`minActiveState`), 그리고 키(`keys`)에 따라 `LaunchedEffect` 가 실행된다.
- `repeatOnLifecycle` 을 사용하여 라이프사이클이 주어진 최소 활성 상태에 있는 동안 수집 작업을 반복한다.
- Flow에서 데이터를 수집하고, 수집된 각 항목에 대해 `currentCollector` 함수를 호출하여 데이터를 처리한다.
@Composable
fun <T> Flow<T>.collectInLaunchedEffectWithLifecycle(
vararg keys: Any?,
lifecycle: Lifecycle = androidx.compose.ui.platform.LocalLifecycleOwner.current.lifecycle,
minActiveState: Lifecycle.State = Lifecycle.State.STARTED,
collector: suspend CoroutineScope.(sideEffect: T) -> Unit,
) {
val currentCollector by rememberUpdatedState(collector)
LaunchedEffect(this, lifecycle, minActiveState, *keys) {
withContext(Dispatchers.Main.immediate) {
lifecycle.repeatOnLifecycle(minActiveState) {
this@collectInLaunchedEffectWithLifecycle.collect { currentCollector(it) }
}
}
}
}
출처
그럼 다음과 같이 간단하게 사용 가능하다
viewModel.sideEffect.collectInLaunchedEffectWithLifecycle { sideEffect ->
when (sideEffect) {
is PostSideSideEffect.ShowToast -> {
showToast(context, sideEffect.message)
}
is PostSideSideEffect.ShowErrorToast -> {
showToast(context, "알 수 없는 에러입니다.")
}
}
}
PostScreen 전체 코드
@Composable
fun PostScreen() {
val viewModel: PostViewModel = hiltViewModel()
val uiState by viewModel.uiState.collectAsStateWithLifecycle(
lifecycleOwner = androidx.compose.ui.platform.LocalLifecycleOwner.current
)
val context = LocalContext.current
Column {
Button(onClick = { viewModel.postIntent(PostIntent.Refresh) }) {
Text(text = "Refresh")
}
LazyColumn {
items(uiState.post) { post ->
PostCard(post = post) {
viewModel.postIntent(PostIntent.OnClick(it))
}
}
}
}
if (uiState.isLoading) {
Loading()
}
viewModel.sideEffect.collectInLaunchedEffectWithLifecycle { sideEffect ->
when (sideEffect) {
is PostSideSideEffect.ShowToast -> {
showToast(context, sideEffect.message)
}
is PostSideSideEffect.ShowErrorToast -> {
showToast(context, "알 수 없는 에러입니다.")
}
}
}
// LaunchedEffect(key1 = viewModel) {
// viewModel.effect.collect {sideEffect ->
// when (sideEffect) {
// is PostSideEffect.ShowToast -> {
// showToast(context, sideEffect.message)
// }
// is PostSideEffect.ShowErrorToast -> {
// showToast(context, "알 수 없는 에러입니다.")
// }
// }
//
// }
// }
}
@Composable
private fun PostCard(
post: ExamplePost,
onClick: (ExamplePost) -> Unit
) {
Column(
modifier = Modifier
.border(width = 1.dp, color = Color.Gray)
.clickable { onClick(post) }
) {
Text(text = "id: ${post.id}")
Text(text = "title: ${post.title}")
Text(text = "content: ${post.body}")
Text(text = "userId: ${post.userId}")
}
}
fun showToast(context: Context, message: String) {
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}
@SuppressLint("ComposableNaming")
@Composable
fun <T> Flow<T>.collectInLaunchedEffectWithLifecycle(
vararg keys: Any?,
lifecycle: Lifecycle = androidx.compose.ui.platform.LocalLifecycleOwner.current.lifecycle,
minActiveState: Lifecycle.State = Lifecycle.State.STARTED,
collector: suspend CoroutineScope.(sideEffect: T) -> Unit,
) {
val currentCollector by rememberUpdatedState(collector)
LaunchedEffect(this, lifecycle, minActiveState, *keys) {
withContext(Dispatchers.Main.immediate) {
lifecycle.repeatOnLifecycle(minActiveState) {
this@collectInLaunchedEffectWithLifecycle.collect { currentCollector(it) }
}
}
}
}
결론
이렇게 MVI를 적용하고 공부해 보았는데.. 장단점이 명확한 것 같다.
- 장점
- `State`, `Intent`, `SideEffect` 등의 모든 액션이 같은 파일에 있어 화면에서 일어나는 일을 쉽게 이해 가능
- 상태 관리가 쉬움
- 단방향으로 데이터가 흘러가기 때문에 추적이 쉬움
- 상태 객체는 불변해서 스레드로부터 안전함.
- 단점
- 진입 장벽이 높다..
- 아주 작은 변경도 intent를 통한 사이클이 필요하다..
- 상태 관리를 위해 비교적 많은 코드를 작성해야 한다.. → 코드 양 증가
- 많은 객체를 생성해야 하기 때문에 높은 메모리 관리가 필요하다..
‘MVI는 신이야’ 이러한 태도는 지양해야 한다. 각 상황에 맞는 아키텍처들이 존재하고, 프로젝트에 적합한 아키텍처를 사용하는 것이 좋다.
프로젝트에 적용해 보면서 이해도를 높여야겠다.
위에서 작성한 코드는 깃허브에서 볼 수 있다.
https://github.com/EASYhz/MVVM-vs-MVI
GitHub - EASYhz/MVVM-vs-MVI: MVVM vs MVI
MVVM vs MVI. Contribute to EASYhz/MVVM-vs-MVI development by creating an account on GitHub.
github.com
틀린 부분이나, 저의 생각과 다른 부분, 개선점 등이 있다면 공유 부탁드립니다!
참고자료
https://github.com/Kotlin-Android-Open-Source/MVI-Coroutines-Flow
GitHub - Kotlin-Android-Open-Source/MVI-Coroutines-Flow: Play MVI with Kotlin Coroutines Flow | MVI pattern on Android using Kot
Play MVI with Kotlin Coroutines Flow | MVI pattern on Android using Kotlin Coroutines Flow | Dagger Hilt DI | Koin DI | SharedFlow | StateFlow | Arrow.kt Android Sample - Kotlin-Android-Open-Source...
github.com
https://proandroiddev.com/mvi-architecture-with-kotlin-flows-and-channels-d36820b2028d
MVI Architecture with Kotlin Flows and Channels
MVVM is the recommend architecture and many developers use it. But just like other things, architecture patterns are also evolving.
proandroiddev.com
https://tech.gunosy.io/entry/2022/10/17/140000
Android MVI with Coroutines Flow - Gunosy Tech Blog
Hello, I am Liang the Android developer, who is mainly in charge of Gunosy Android App development. This article is about how we build the MVI design pattern based on Coroutines Flow and migrate from RxJava to Coroutines for development. Issues MVI with Co
tech.gunosy.io
Android MVI (Model-View-Intent) Architecture — Example code
As developers you may have heared about the terms like MVC,MVP and MVVM which are frequently discussed architectural patterns in android…
krishanmadushankadev.medium.com