xml로 레이아웃을 작성할 때는 MVVM 패턴과 함께 Databindnig을 사용하면 코드가 간결해지면서 가독성이 높아지는 효과가 있어 자주 사용하였다.
하지만, 최근 Compose를 주로 사용해보며 상태 관리의 중요성이 느껴져 MVI 패턴을 적용하였고, MVI 적용을 도와주는 Orbit 라이브러리가 있어 적용해보았다.
이번 글에서는 Compose -> State Hoisting -> MVI -> Orbit 을 적용해보며 장점과 결과를 정리하고자 한다.
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 을 권장한다.
상태 호이스팅을 적용하면 아래와 같이 코드가 변경된다.
상태와 이벤트를 최상위 컴포저블까지 끌어올려서 생명주기와 이벤트를 관리하기 쉽도록 구성한다.
(참고 : 실제로 위 코드와 아래 코드는 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")
}
}
}
Model-View-Intent 로 이루어져 있으며, 단방향 이벤트와 상태 관리를 중심으로 구성하는 아키텍처이다.
단방향 이벤트와 상태 관리(상태의 불변성)는 Compose와 잘 맞아 최근 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 패턴을 적용하였다.
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
)
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()
}
// 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() }
}
}
지금까지 Compose State, State Hoisting, MVI, Orbit 까지 알아보았다.
사용하는 사람에 따라서 Orbit 라이브러리 사용에 대해 굳이? 라고 느끼는 사람도 있을 것 같다.
하지만, 나는 SideEffect 관리 하나만으로도 매력을 느껴 계속해서 orbit 라이브러리를 사용할 것 같다.