Android Compose에서의 MVI 패턴 (with orbit)

pass·2024년 6월 6일
0

Android

목록 보기
37/41

xml로 레이아웃을 작성할 때는 MVVM 패턴과 함께 Databindnig을 사용하면 코드가 간결해지면서 가독성이 높아지는 효과가 있어 자주 사용하였다.

하지만, 최근 Compose를 주로 사용해보며 상태 관리의 중요성이 느껴져 MVI 패턴을 적용하였고, MVI 적용을 도와주는 Orbit 라이브러리가 있어 적용해보았다.

이번 글에서는 Compose -> State Hoisting -> MVI -> Orbit 을 적용해보며 장점과 결과를 정리하고자 한다.


✔ Compose 상태 관리의 중요성

Compose는 Android에서의 선언형 UI로 State(상태)를 기반으로 UI를 구성한다.
State가 변경되면 Recomposition이 진행되면서 State가 반영된 UI가 재구성된다.
따라서 여러 State 정보가 있을 수 있게 되고, 이 상태를 변경하는 곳도 다양해질 수 있는데, 여러 곳에서 상태를 변경하고 관리하다보면 코드의 가독성을 심하게 해칠 수 있다.

예를 들어 다음과 같은 코드가 있다고 할 때, 상태가 두 곳으로 나뉘어져 있고, 상태를 변경하는 곳도 코드 중간에 삽입되어 있다.

@Composable
@Composable
fun MainScreen() {
    val isVisibleState = remember { mutableStateOf(false) }

    Box(modifier = Modifier.fillMaxSize()) {
        if (isVisibleState.value) {
            SectionView(modifier = Modifier.align(Alignment.Center).padding(horizontal = 30.dp))
        }

        Button(
            onClick = { isVisibleState.value = !isVisibleState.value },
            modifier = Modifier.align(Alignment.BottomCenter).fillMaxWidth().padding(50.dp)
        ) {
            Text(text = "Visible Button")
        }
    }
}

@Composable
fun SectionView(modifier: Modifier) {
    val countState = remember { mutableStateOf(0) }

    Column(
        modifier = modifier,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text(text = countState.value.toString())

        Button(
            onClick = { countState.value = countState.value + 1 },
            modifier = Modifier.fillMaxWidth().padding(30.dp)
        ) {
            Text(text = "Count Button")
        }
    }
}

이를 해결하기 위해 Compose 에서는 State Hoisting 을 권장한다.


✔ State Hoisting (상태 호이스팅)

상태 호이스팅을 적용하면 아래와 같이 코드가 변경된다.
상태와 이벤트를 최상위 컴포저블까지 끌어올려서 생명주기와 이벤트를 관리하기 쉽도록 구성한다.
(참고 : 실제로 위 코드와 아래 코드는 countState의 생명주기가 다르다. )

@Composable
fun MainScreen() {
    val isVisibleState = remember { mutableStateOf(false) }
    val countState = remember { mutableStateOf(0) }

    Box(modifier = Modifier.fillMaxSize()) {
        if (isVisibleState.value) {
            SectionView(
                modifier = Modifier.align(Alignment.Center).padding(horizontal = 30.dp),
                count = countState.value,
                onClickCountButton = { countState.value = countState.value + 1 }
            )
        }

        Button(
            onClick = { isVisibleState.value = !isVisibleState.value },
            modifier = Modifier.align(Alignment.BottomCenter).fillMaxWidth().padding(50.dp)
        ) {
            Text(text = "Visible Button")
        }
    }
}

@Composable
fun SectionView(
    modifier: Modifier,
    count: Int,
    onClickCountButton: () -> Unit
) {
    Column(
        modifier = modifier,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text(text = count.toString())

        Button(
            onClick = onClickCountButton,
            modifier = Modifier.fillMaxWidth().padding(30.dp)
        ) {
            Text(text = "Count Button")
        }
    }
}

✔ ViewModel + MVI

MVI 패턴이란?

Model-View-Intent 로 이루어져 있으며, 단방향 이벤트와 상태 관리를 중심으로 구성하는 아키텍처이다.
단방향 이벤트와 상태 관리(상태의 불변성)는 Compose와 잘 맞아 최근 MVI 패턴을 많이 사용하는 편이다.

Android에서의 MVI

Android에서의 MVI는 기존 MVVM과 조금 다르게 이해해야 한다.

MVVM에서는 ViewModel에서 비지니스 로직을 수행하고, Model을 data class로써 정의하므로 MVVM ViewModel을 AAC ViewModel로 구현한다.

하지만, MVI에서는 Model에서 비지니스 로직을 수행하면서 상태 관리를 같이 진행하기 때문에 MVI에서의 Model을 AAC ViewModel로 구현한다.

따라서 Android에서는 보통 아래와 같은 구조로 MVI를 구성한다.

Model : ViewModel (AAC)
View : UI (Activity or Compose)
Intent : sealed class (or enum class)

MVI 적용

지금까지 진행했던 예제 코드에서 MVI 패턴을 적용하였다.
Intent와 State를 정의하고, View에서 이벤트가 발생하면, Intent를 Model에게 전달하여 행위에 따라 상태를 변화시킨다. 또한, MVI 에서 상태는 불변성을 유지해야하므로 상태를 재생성하여 재정의한다.

// build.gradle
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.1")

// view
@Composable
fun MainScreen(viewModel: MainViewModel = viewModel()) {
    val state = viewModel.state.collectAsState().value

    Box(modifier = Modifier.fillMaxSize()) {
        if (state.isVisible) {
            SectionView(
                modifier = Modifier
                    .align(Alignment.Center)
                    .padding(horizontal = 30.dp),
                count = state.count,
                onClickCountButton = { viewModel.processIntent(MainIntent.OnClickCount) }
            )
        }

        Button(
            onClick = { viewModel.processIntent(MainIntent.OnClickIsVisibleButton) },
            modifier = Modifier
                .align(Alignment.BottomCenter)
                .fillMaxWidth()
                .padding(50.dp)
        ) {
            Text(text = "Visible Button")
        }
    }
}

// model
class MainViewModel : ViewModel() {

    private val _state = MutableStateFlow(MainState())
    val state: StateFlow<MainState> = _state.asStateFlow()

    fun processIntent(intent: MainIntent) {
        when (intent) {
            MainIntent.OnClickCount -> incrementCount()
            MainIntent.OnClickIsVisibleButton -> toggleVisibility()
        }
    }

    private fun incrementCount() {
        _state.value = _state.value.copy(count = _state.value.count + 1)
    }

    private fun toggleVisibility() {
        _state.value = _state.value.copy(isVisible = !_state.value.isVisible)
    }
}

// intent
sealed class MainIntent {
    data object OnClickCount: MainIntent()
    data object OnClickIsVisibleButton: MainIntent()
}

// state
data class MainState(
    val isVisible: Boolean = false,
    val count: Int = 0
)

✔ Orbit

MVI에서 Side Effect

MVI 패턴에서는 단방향으로 이벤트가 발생하고, 사이드 이펙트에 대해서는 따로 정의해야 한다.
사이드 이펙트는 부수 효과로서 어떤 이벤트에 대한 부수적인 이벤트를 뜻한다.

예를 들어 위 예제 코드에서 상태 변화에 따라 Toast 메시지를 출력한다고 하면, 아래와 같이 코드가 증가하며 LaunchEffect가 많아질 경우, 가독성을 해치게 된다.

val context = LocalContext.current
val isVisibleState = viewModel.isVisibleState.value
val countState = viewModel.countState.value
    
LaunchedEffect(state.count != 0) {
    Toast.makeText(context, "${state.count}번 클릭!!", Toast.LENGTH_SHORT).show()
}

Orbit 장점

  • Android에서 MVI 패턴을 구성하기 쉽도록 구성해준다.
  • 사이드 이펙트를 한 곳에서 관리할 수 있다. (UI)
  • 상태 변경과 사이드 이펙트 관리에 가독성이 향상된다.
  • 상태 변경 시 default로 coroutine block을 사용하여 비동기 처리에 용이하다.

Orbit 주요 용어

  • container : State와 Side Effect를 관리하는 하나의 공간
  • intent : 상태를 변경하는 데 사용되는 코루틴 블록 (디폴트로 IO Thread 사용)
  • reduce : 상태를 변경하는 함수
  • postSideEffect : 사이드 이펙트를 발생시키는 함수

Orbit 적용

// build.gradle
implementation("org.orbit-mvi:orbit-core:7.1.0")
implementation("org.orbit-mvi:orbit-viewmodel:7.1.0")
implementation("org.orbit-mvi:orbit-compose:7.1.0")

// model
class MainViewModel : ViewModel(), ContainerHost<MainState, MainSideEffect> {

    override val container: Container<MainState, MainSideEffect> = container(initialState = MainState())

    fun processIntent(intent: MainIntent) {
        when (intent) {
            MainIntent.OnClickCount -> incrementCount()
            MainIntent.OnClickIsVisibleButton -> toggleVisibility()
        }
    }

    private fun incrementCount() = intent {
        reduce {
            state.copy(count = state.count + 1)
        }
        postSideEffect(MainSideEffect.Toast("${state.count}번 클릭!!"))
    }

    private fun toggleVisibility() = intent {
        reduce {
            state.copy(isVisible = !state.isVisible)
        }
    }
}

sealed interface MainSideEffect {
    data class Toast(val toastMessage: String) : MainSideEffect
}

@Immutable
data class MainState(
    val isVisible: Boolean = false,
    val count: Int = 0
)

// view - side effect
val context = LocalContext.current
val state = viewModel.collectAsState().value

viewModel.collectSideEffect { sideEffect ->
    when(sideEffect) {
        is MainSideEffect.Toast -> { Toast.makeText(context, sideEffect.toastMessage, Toast.LENGTH_SHORT).show() }
    }
}
  1. viewModel.collectAsState().value - State를 바로 불러올 수 있다.
  2. viewModel.collectSideEffect - SideEfect를 한 곳에서 정의하며 관리할 수 있다.

🤔 정리

지금까지 Compose State, State Hoisting, MVI, Orbit 까지 알아보았다.
사용하는 사람에 따라서 Orbit 라이브러리 사용에 대해 굳이? 라고 느끼는 사람도 있을 것 같다.
하지만, 나는 SideEffect 관리 하나만으로도 매력을 느껴 계속해서 orbit 라이브러리를 사용할 것 같다.

profile
안드로이드 개발자 지망생

0개의 댓글