MVI 첫 포스팅에서는 라이브러리 없이 MVI 패턴을 구현해보았다.
https://snaildeveloper.tistory.com/140
MVI 패턴
스톱워치는 시간과 시작/종료 버튼, 리셋 버튼이 있다.사용자가 시작 버튼을 누르면 시간이 올라가며, 종료 버튼을 누르면 시간이 멈추게 되고, 리셋 버튼을 누른다면 시간이 0으로 초기화 된다.
snaildeveloper.tistory.com
Orbit 라이브러리를 사용하면 보다 쉽게 MVI 패턴을 구현할 수 있다.
https://github.com/orbit-mvi/orbit-mvi
GitHub - orbit-mvi/orbit-mvi: A simple MVI framework for Kotlin Multiplatform and Android
A simple MVI framework for Kotlin Multiplatform and Android - orbit-mvi/orbit-mvi
github.com
implementation("org.orbit-mvi:orbit-core:<latest-version>")
implementation("org.orbit-mvi:orbit-viewmodel:<latest-version>")
implementation("org.orbit-mvi:orbit-compose:<latest-version>")
testImplementation("org.orbit-mvi:orbit-test:<latest-version>")
Orbit은 2가지만 구현하면 된다.
- Contract
- ViewModel
Contract
MVI에서 필수적인 State와 Effect, Intent를 정의
sealed interface TimerIntent {
data object OnButtonClick : TimerIntent
data object OnResetClick : TimerIntent
}
sealed interface TimerEffect {
data object Run : TimerEffect
data object Stop : TimerEffect
}
data class TimerState(
val time: Int = 0,
val timerState: Boolean = false
)
ViewModel
class OrbitViewModel : ContainerHost<TimerState, TimerEffect>, ViewModel() {
override val container = container<TimerState, TimerEffect>(
TimerState()
)
fun onIntent(intent: TimerIntent) = intent {
when (intent) {
is TimerIntent.OnButtonClick -> {
reduce {
state.copy(timerState = !state.timerState)
}
postSideEffect(
if (state.timerState) TimerEffect.Run else TimerEffect.Stop
)
}
is TimerIntent.OnResetClick -> {
postSideEffect(TimerEffect.Stop)
reduce {
state.copy(timerState = false, time = 0)
}
}
}
}
}
- ContainerHost Interface에 State와 Effect를 지정
- container를 초기화
- intent로 감싸 intent 내부에서 reduce와 postSideEffect로 상태와 Effect를 전달
View
@Composable
fun TimerScreen(
modifier: Modifier = Modifier,
viewModel: OrbitViewModel = viewModel()
) {
val state: TimerState by viewModel.collectAsState()
val state2: State<TimerState> = viewModel.collectAsState()
viewModel.collectSideEffect { sideEffect ->
if (sideEffect is TimerEffect.Run) {
viewModel.runTimer()
}
}
Column(
modifier = modifier
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text("${state.time}")
TimerButtons(
viewModel::onIntent,
{ state.timerState }
)
}
}
Orbit 라이브러리를 사용하면 위와 같이 간단하게 MVI를 구현할 수 있다.
분석
분석 요약 미리보기
- ContainerHost를 통해 container를 override 하고, Orbit의 intent 함수를 쉽게 사용할 수 있도록 확장함수 intent()를 제공한다.
- Container에서 State와 Effect를 ContainerContext를 통해 관리한다.
- ViewModel에서의 Container는 viewModelScope의 확장 함수로 생성되며, 해당 scope를 이용하여 intent 내부 작업을 비동기로 처리한다.
- Orbit 함수로 Container를 조작할 수 있고, 해당 함수는 intent 함수 매개 변수의 Syntax 객체 내의 reduce()와 postSideEffect()에서 ContainerContext를 통해 조작되고 있다.
- State는 StateFlow로 update()를 통해 관리되고 있으며, SideEffect는 Channel을 통해 전달되며 Container에서 Flow로 변환되어 UI에 제공되고 있다.
(혼자 분석하고 내린 결론이기 때문에 정확하지 않을 수 있습니다.)
1. ContainerHost
public interface ContainerHost<STATE : Any, SIDE_EFFECT : Any> {
public val container: Container<STATE, SIDE_EFFECT>
@OrbitDsl
public fun intent(
registerIdling: Boolean = true,
transformer: suspend Syntax<STATE, SIDE_EFFECT>.() -> Unit
): Job = container.intent(registerIdling) { Syntax(this).transformer() }
@OrbitDsl
@OrbitExperimental
public suspend fun subIntent(
transformer: suspend Syntax<STATE, SIDE_EFFECT>.() -> Unit,
): Unit = container.inlineOrbit {
Syntax(this).transformer()
}
}
2. Container
Container는 orbit MVI의 기본 틀이다.
모든 작업은 Container 기반으로 수행되며, 여기서 알아야 할 중요한 부분은 ContainerContext이다.
@OrbitInternal
public data class ContainerContext<S : Any, SE : Any>(
public val settings: RealSettings,
public val postSideEffect: suspend (SE) -> Unit,
public val reduce: suspend ((S) -> S) -> Unit,
public val subscribedCounter: SubscribedCounter,
public val stateFlow: StateFlow<S>,
) {
public val state: S
get() = stateFlow.value
}
Container 초기화 과정을 살펴보면
public fun <STATE : Parcelable, SIDE_EFFECT : Any> ViewModel.container(
initialState: STATE,
savedStateHandle: SavedStateHandle,
buildSettings: SettingsBuilder.() -> Unit = {},
onCreate: (suspend Syntax<STATE, SIDE_EFFECT>.() -> Unit)? = null
): Container<STATE, SIDE_EFFECT> {
val savedState: STATE? = savedStateHandle[SAVED_STATE_KEY]
val state = savedState ?: initialState
val realContainer: Container<STATE, SIDE_EFFECT> =
viewModelScope.container(state, buildSettings, onCreate)
return SavedStateContainerDecoratorParcelable(
realContainer,
savedStateHandle,
SAVED_STATE_KEY
)
}
ViewMode.container() 확장 함수로 생성할 수 있고, 내부에서 CoroutineScope.container()로 realContainer를 생성하고 있다.
public fun <STATE : Any, SIDE_EFFECT : Any> CoroutineScope.container(
initialState: STATE,
buildSettings: SettingsBuilder.() -> Unit = {},
onCreate: (suspend Syntax<STATE, SIDE_EFFECT>.() -> Unit)? = null
): Container<STATE, SIDE_EFFECT> {
val realContainer = RealContainer<STATE, SIDE_EFFECT>(
initialState = initialState,
settings = SettingsBuilder().apply { buildSettings() }.settings,
parentScope = this
)
return if (onCreate == null) {
TestContainerDecorator(
initialState,
realContainer
)
} else {
TestContainerDecorator(
initialState,
LazyCreateContainerDecorator(
realContainer
) { Syntax(this).onCreate() }
)
}
}
CoroutineScope.container() 에서는 RealContainer 객체를 생성하여 반환하고 있다.
public class RealContainer<STATE : Any, SIDE_EFFECT : Any>(
initialState: STATE,
parentScope: CoroutineScope,
public override val settings: RealSettings,
subscribedCounterOverride: SubscribedCounter? = null
) : Container<STATE, SIDE_EFFECT> {
override val scope: CoroutineScope = parentScope + settings.eventLoopDispatcher
private val intentJob = Job(scope.coroutineContext[Job])
private val dispatchChannel = Channel<Pair<CompletableJob, suspend ContainerContext<STATE, SIDE_EFFECT>.() -> Unit>>(Channel.UNLIMITED)
private val internalStateFlow = MutableStateFlow(initialState)
private val sideEffectChannel = Channel<SIDE_EFFECT>(settings.sideEffectBufferSize)
....
override val stateFlow: StateFlow<STATE> = internalStateFlow.asStateFlow()
override val sideEffectFlow: Flow<SIDE_EFFECT> = sideEffectChannel.receiveAsFlow()
...
internal val pluginContext: ContainerContext<STATE, SIDE_EFFECT> = ContainerContext(
settings = settings,
postSideEffect = { sideEffectChannel.send(it) },
reduce = { reducer -> internalStateFlow.update(reducer) },
subscribedCounter = subscribedCounter,
stateFlow = stateFlow,
)
...
}
RealContainer 클래스는 Container Interface를 상속받아 구현한 구현체임을 알 수 있고, 이 RealContainer 클래스 내에서 ContainerContext를 생성하고 있다.
orbit 라이브러리에서도 StateFlow로 update()를 사용하여 상태를 관리하고 있고, sideEffect는 Channel로 관리하고 있다.
3. intent()
ContainerHoset ViewModel에서 사용하는 intent는 Syntax 객체를 기반 확장함수를 사용하고 있다.
@OrbitDsl
public fun intent(
registerIdling: Boolean = true,
transformer: suspend Syntax<STATE, SIDE_EFFECT>.() -> Unit
): Job = container.intent(registerIdling) { Syntax(this).transformer() }
또한 intent 함수를 따라가보면 orbit() 메서드를 불러오고 있는데
@OrbitDsl
internal fun <STATE : Any, SIDE_EFFECT : Any> Container<STATE, SIDE_EFFECT>.intent(
registerIdling: Boolean = true,
transformer: suspend ContainerContext<STATE, SIDE_EFFECT>.() -> Unit
): Job = orbit {
withIdling(registerIdling) {
transformer()
}
}
orbit 메서드는 다음과 같이 정의되어 있다.
override fun orbit(orbitIntent: suspend ContainerContext<STATE, SIDE_EFFECT>.() -> Unit): Job {
initialiseIfNeeded()
val job = Job(intentJob)
dispatchChannel.trySend(job to orbitIntent)
return job
}
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
private fun initialiseIfNeeded() {
if (initialised.compareAndSet(expectedValue = false, newValue = true)) {
scope.produce<Unit>(Dispatchers.Unconfined) {
awaitClose {
settings.idlingRegistry.close()
}
}
scope.launch(CoroutineName(COROUTINE_NAME_EVENT_LOOP)) {
for ((job, intent) in dispatchChannel) {
val exceptionHandlerContext =
CoroutineName("$COROUTINE_NAME_INTENT${intentCounter.fetchAndIncrement()}") +
job +
settings.intentLaunchingDispatcher
launch(exceptionHandlerContext) {
runCatching { pluginContext.intent() }.onFailure { e ->
settings.exceptionHandler?.handleException(coroutineContext, e) ?: throw e
}
}.invokeOnCompletion { job.complete() }
}
}
}
}
CoroutineScope.container()를 통해 생성된 Container 내에는 확장 함수의 수신 객체인 CoroutineScope가 scope 변수에 저장되어 있다.
이 scope를 통해 initialiseIfNeeded() 내에서 pluginContext(ContainerContext)의 intent를 비동기 코루틴으로 처리하고 있는 것을 확인 할 수 있다.
4. Syntax
Syntax 객체를 살펴보면 reduce()와 postSideEffect() 메서드가 있는데, 우리가 사용하는 reduce와 postSideEffect를 왜 intent 내부에서만 사용할 수 있는지 알 수 있다.
@OrbitDsl
public class Syntax<S : Any, SE : Any>(public val containerContext: ContainerContext<S, SE>) {
public val state: S get() = containerContext.state
@OrbitDsl
public suspend fun reduce(reducer: IntentContext<S>.() -> S) {
containerContext.reduce { reducerState ->
IntentContext(reducerState).reducer()
}
}
@OrbitDsl
public suspend fun postSideEffect(sideEffect: SE) {
containerContext.postSideEffect(sideEffect)
}
...
}
'안드로이드 > 디자인패턴' 카테고리의 다른 글
Compose 프로젝트 일부 MVI로 마이그레이션 (0) | 2025.07.03 |
---|---|
MVI 패턴 (0) | 2025.06.30 |
Data Layer (0) | 2025.03.09 |
SOILD 원칙 (0) | 2025.03.08 |
SingleTon (0) | 2025.03.08 |