스톱워치는 시간과 시작/종료 버튼, 리셋 버튼이 있다.
사용자가 시작 버튼을 누르면 시간이 올라가며, 종료 버튼을 누르면 시간이 멈추게 되고, 리셋 버튼을 누른다면 시간이 0으로 초기화 된다.
1. 사용자가 버튼을 클릭함으로써 이벤트가 발생
2. 이벤트마다 시간이 흘러가거나, 멈추거나, 초기화가 되는 상태가 생성
3. 이 상태를 View에 알려줘 사용자는 스톱워치의 기능을 사용할 수 있게 된다.
MVI의 기본 구조는 발생하는 이벤트를 가지고 상태를 관리하고 상태에 따라 UI를 단방향으로 처리하는 것이다.
- Model : Intent로 부터 전달 받은 이벤트를 처리하여 만든 상태를 View에게 전달
- View : Model에서 전달 받은 상태를 UI에 적용
- Intent : View에서 발생한 사용자의 액션, 동작을 Model에 전달하는 역할 (이벤트)
(MVI에서는 불변 데이터를 이용하여 상태를 처리하고 전달한다.)
MVI는 UI와 사용자의 이벤트를 전달하는 Intent, 데이터와 비즈니스 로직을 처리하는 Model을 분리하여 코드의 가독성을 높이고 유지보수성을 향상시킬 수 있다.
하지만 분리함에 따라 추가적으로 만들어야 할 클래스가 증가하고, 코드양이 늘어나기 때문에 기능이 간단한 프로젝트에 적용할 경우 오히려 복잡성만 증가할 수 있다.
SideEffect
스톱워치는 사용자가 시작 버튼을 누르면(Intent) 시간이 증가한다.(상태 변화)
시간이 증가하기 위해서 1초마다 시간을 증가시켜주는 작업이 필요하다.
이처럼 기능을 구현하다 보면 이벤트와 상태 외의 추가적인 작업이 필요한 경우가 생기는데 이를 SideEffect라 한다.
스톱워치 구현 with Compose
1. MVI 기본 세팅
MVI 기본 구성 요소인 상태와 Intent, Effect를 정의
interface UiState
interface UiIntent
interface UiEffect
이를 처리할 BaseViewModel 생성
abstract class BaseViewModel<State : UiState, Intent : UiIntent, Effect : UiEffect>(
initialState: State
) : ViewModel() {
private val _uiState = MutableStateFlow(initialState)
val uiState = _uiState.asStateFlow()
private val _effect: Channel<Effect> = Channel()
val effect = _effect.receiveAsFlow()
protected val currentState: State
get() = _uiState.value
// 넘어온 Intent를 처리하기 위한 함수
abstract fun onIntent(intent: Intent)
// 상태로 만든 Intent를 업데이트
protected fun intent(reduce: State.() -> State) {
_uiState.update { currentState.reduce() }
}
// SideEffect를 처리
protected fun sideEffect(effect: Effect) {
viewModelScope.launch { _effect.send(effect) }
}
}
2. State와 Intent 그리고 Effect 정의 (Contract)
State에는 스톱워치가 동작하고 있는지, 멈춰있는지 알려주는 상태와 시간이 필요하다.
data class TimerState(
val time: Int = 0,
val state: Boolean = false
) : UiState
Intent에는 시작/종료 버튼 클릭 Intent와 리셋 버튼 클릭 Intent가 필요하다.
sealed interface TimerIntent : UiIntent {
data object OnButtonClick : TimerIntent
data object OnResetClick : TimerIntent
}
Effect에는 스톱워치의 동작 유무에 대한 정의가 필요하다.
sealed interface TimerEffect : UiEffect {
data object Run: TimerEffect
data object Stop: TimerEffect
}
3. ViewModel 구현
class TimerViewModel : BaseViewModel<TimerState, TimerIntent, TimerEffect>(
TimerState()
) {
override fun onIntent(intent: TimerIntent) {
when (intent) {
is TimerIntent.OnButtonClick -> {
intent {
copy(state = !state)
}
sideEffect(
if (currentState.state) TimerEffect.Run else TimerEffect.Stop
)
}
is TimerIntent.OnResetClick -> {
intent {
copy(state = false, time = 0)
}
sideEffect(TimerEffect.Stop)
}
}
}
fun timer() {
intent {
copy(time = time + 1)
}
}
}
4. View
@Composable
fun TimerScreen(
modifier: Modifier = Modifier,
viewModel: TimerViewModel = viewModel()
) {
val timer by viewModel.uiState.collectAsStateWithLifecycle() // Timer 상태
val effect by viewModel.effect.collectAsStateWithLifecycle(TimerEffect.Stop) // 동작 유무
if (effect is TimerEffect.Run) {
Timer(viewModel::timer)
}
Column(
modifier = modifier
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text("${timer.time}")
TimerButtons(
viewModel::onIntent,
{ timer.state }
)
}
}
@Composable
private fun TimerButtons(
onClick: (TimerIntent) -> Unit,
state: () -> Boolean
) {
Row {
Button(
onClick = { onClick(TimerIntent.OnButtonClick) }
) {
Text(if (state()) "stop" else "start")
}
Button(
onClick = { onClick(TimerIntent.OnResetClick) }
) {
Text("reset")
}
}
}
@Composable
private fun Timer(
intent: () -> Unit
) {
LaunchedEffect(Unit) {
while (true) {
delay(1000)
intent()
}
}
}
사실 TimerState에도 스톱워치의 동작 유무인 state 값을 저장하기 때문에 Effect의 역할이 불필요하긴 하지만 활용 방법을 보여주기 위해 일부로 구현하였다.
코드 설명
1. 사용자 버튼 클릭 Intent
@Composable
private fun TimerButtons(
onClick: (TimerIntent) -> Unit,
state: () -> Boolean
) {
Row {
Button(
onClick = { onClick(TimerIntent.OnButtonClick) }
) {
Text(if (state()) "stop" else "start")
}
Button(
onClick = { onClick(TimerIntent.OnResetClick) }
) {
Text("reset")
}
}
}
사용자가 버튼을 클릭하면 Intent를 ViewModel의 onIntent로 전달한다.
override fun onIntent(intent: TimerIntent) {
when (intent) {
is TimerIntent.OnButtonClick -> {
intent {
copy(state = !state) // 상태 변경
}
sideEffect(
if (currentState.state) TimerEffect.Run else TimerEffect.Stop
)
}
is TimerIntent.OnResetClick -> {
intent {
copy(state = false, time = 0) // 상태 변경
}
sideEffect(TimerEffect.Stop)
}
}
}
onIntent()에서는 Intent에 따라 상태를 만들고 전달하여 업데이트 한다.
또한 동작 유무에 따라 SideEffect를 생성
intent()는 상태를 받아 업데이는 하는 함수
2. SideEffect 처리
fun TimerScreen(
modifier: Modifier = Modifier,
viewModel: TimerViewModel = viewModel()
) {
...
val effect by viewModel.effect.collectAsStateWithLifecycle(TimerEffect.Stop)
if (effect is TimerEffect.Run) {
Timer(viewModel::timer)
}
...
}
@Composable
private fun Timer(
intent: () -> Unit
) {
LaunchedEffect(Unit) {
while (true) {
delay(1000)
intent()
}
}
}
Effect가 Run인 경우 Timer를 실행하여 1초마다 ViewModel의 timer()를 실행시킨다.
fun timer() {
intent {
copy(time = time + 1)
}
}
3. 타이머 시간 상태 UI 적용
위의 작업들로 Timer의 상태가 변경되면 UI에서는 상태를 전달 받아 적용시킨다.
@Composable
fun TimerScreen(
modifier: Modifier = Modifier,
viewModel: TimerViewModel = viewModel()
) {
val timer by viewModel.uiState.collectAsStateWithLifecycle()
...
Column(
...
) {
Text("${timer.time}")
...
}
}
확실히 스톱워치와 같이 간단한 기능을 구현하기에는 MVI 패턴은 과한 부분이 있어 보인다.
하지만 기능이 많다면 이처럼 분리하여 구현함으로써 가독성과 유지보수성을 증가시키고 테스트도 보다 쉽게 작성할 수 있다.
'안드로이드 > 디자인패턴' 카테고리의 다른 글
Compose 프로젝트 일부 MVI로 마이그레이션 (0) | 2025.07.03 |
---|---|
MVI 라이브러리 - Orbit with Compose (0) | 2025.07.02 |
Data Layer (0) | 2025.03.09 |
SOILD 원칙 (0) | 2025.03.08 |
SingleTon (0) | 2025.03.08 |