본문 바로가기

안드로이드/디자인패턴

MVI 라이브러리 - Orbit with Compose

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