[Android] MVI 라이브러리 Orbit 알아보기
안드로이드에서 MVI 패턴을 구현하기 위해 사용하는 라이브러리로 Orbit이란 게 있다.
아래는 Orbit 깃허브와 공식문서다.
https://github.com/orbit-mvi/orbit-mvi
Orbit은 Redux/MVI와 비슷한 라이브러리지만 번거로움이 없다. 매우 간단해서 MVVM+라고 생각하면 된다
- 간단함, type-safe, 코루틴 스타일, 확장 가능한 API
- 멀티플랫폼, 안드로이드 / iOS 대상 (iOS 지원은 알파 버전이고 작업 중)
- 코루틴에 대한 완벽한 지원
- 생명주기에 안전한 무한 Flow 수집
- SavedState와 함께 뷰모델 지원
- 선택 사항인 간단한 단위 테스트 라이브러리
- 코루틴 wrapper를 통해 RxJava, LiveData 등과 호환 가능
https://orbit-mvi.org/Core/architecture
이 다이어그램은 Orbit 시스템(또는 MVI/Redux/Cycle 같은 유사한 시스템)이 작동하는 방식을 표현한 것이다
1. UI는 비즈니스 컴포넌트에 비동기적으로 작업을 전송한다
2. 비즈니스 컴포넌트는 비즈니스 로직으로 들어오는 작업을 변환한다
3. 그 후 비즈니스컴포넌트는 이런 이벤트를 체인 아래로 더 내려보낸다
4. 모든 이벤트는 시스템의 현재 상태로 축소돼 새 상태를 생성한다
5. 그 후 상태는 내부 정보를 기반으로 렌더링되는 UI로 다시 방출된다
기억해야 하는 가장 중요한 건 UI는 스스로 비즈니스 결정을 내릴 수 없다. 입력 상태를 기반으로 렌더링하는 방법만 알고 있어야 한다
< Orbit 컴포넌트 >
위 로직을 실제 컴포넌트에 매핑할 수 있다
1. UI는 ContainerHost 인터페이스를 구현하는 클래스에서 함수를 호출한다. 일반적으로 안드로이드에서 액티비티, 프래그먼트, 간단한 뷰일 수 있다. 그러나 Orbit 시스템은 백그라운드 서비스처럼 UI 없이 실행할 수도 있다
2. 함수는 intent 블록을 통해 컨테이너 인스턴스를 호출해서 작업을 백그라운드 코루틴으로 오프로드하고 사이드 이펙트 및 리덕션을 위한 DSL을 제공한다
3. 변환(Transformation)은 intent 블록 안에서 커스텀 비즈니스 로직을 통해 수행된다
4. reduce 연산자는 들어오는 이벤트에 따라 시스템의 현재 상태를 줄여 새 상태를 생성한다
5. 새 상태는 옵저버로 전송된다
참고 : 모든 Orbit 연산자는 선택 사항이다
< 사이드 이펙트 >
현실 세계에서 이런 시스템은 사이드 이펙트 없이 존재할 수 없다. 사이드 이펙트는 일반적으로 네비게이션, 로깅, 애널리틱스, 토스트 같이 Orbit 컨테이너 상태를 변경하지 않는 1회성 이벤트다. 따라서 사이드 이펙트를 처리할 수 있는 3번째 Orbit 오퍼레이터가 있다
UI는 모든 사이드 이펙트를 인지할 필요가 없다. 애널리틱스 이벤트를 왜 UI가 신경써야 하는가? 따라서 이벤트를 UI에 다시 post하지 않는 사이드 이펙트가 있을 수 있다
그럼 Orbit으로 MVI 패턴을 어떻게 구현할 수 있을까?
아래는 Orbit 깃허브에 포함된 계산기 코드에서 가져온 부분이다.
import android.os.Parcelable
import androidx.lifecycle.LiveData
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.asLiveData
import kotlinx.parcelize.Parcelize
import org.orbitmvi.orbit.Container
import org.orbitmvi.orbit.ContainerHost
import org.orbitmvi.orbit.syntax.simple.intent
import org.orbitmvi.orbit.syntax.simple.reduce
import org.orbitmvi.orbit.viewmodel.container
import java.math.BigDecimal
import java.math.RoundingMode
class CalculatorViewModel(
savedStateHandle: SavedStateHandle,
): ViewModel() {
private val host = object : ContainerHost<CalculatorStateImpl, Nothing> {
override val container: Container<CalculatorStateImpl, Nothing> =
container<CalculatorStateImpl, Nothing>(CalculatorStateImpl(), savedStateHandle)
}
@Suppress("UNCHECKED_CAST")
val state: LiveData<CalculatorState> =
host.container.stateFlow.asLiveData() as LiveData<CalculatorState>
fun clear() = host.intent {
reduce {
CalculatorStateImpl()
}
}
fun digit(digit: Int) {
host.intent {
reduce {
state.copy(xRegister = state.xRegister.appendDigit(digit))
}
}
}
fun period() = host.intent {
reduce {
state.copy(xRegister = state.xRegister.appendPeriod())
}
}
fun add() = host.intent {
reduce {
val yRegister = if (state.xRegister.isEmpty()) state.yRegister else state.xRegister
state.copy(
lastOperator = CalculatorStateImpl.Operator.Add,
xRegister = Register(),
yRegister = yRegister
)
}
}
fun subtract() = host.intent {
reduce {
val yRegister = if (state.xRegister.isEmpty()) state.yRegister else state.xRegister
state.copy(
lastOperator = CalculatorStateImpl.Operator.Subtract,
xRegister = Register(),
yRegister = yRegister
)
}
}
fun multiply() = host.intent {
reduce {
val yRegister = if (state.xRegister.isEmpty()) state.yRegister else state.xRegister
state.copy(
lastOperator = CalculatorStateImpl.Operator.Multiply,
xRegister = Register(),
yRegister = yRegister
)
}
}
fun divide() = host.intent {
reduce {
val yRegister = if (state.xRegister.isEmpty()) state.yRegister else state.xRegister
state.copy(
lastOperator = CalculatorStateImpl.Operator.Divide,
xRegister = Register(),
yRegister = yRegister
)
}
}
fun plusMinus() = host.intent {
reduce {
state.copy(xRegister = state.xRegister.plusMinus())
}
}
...
}
함수들의 형태가 지금까지 보던 것과는 조금 다르다. 뷰모델의 전역 변수로 ContainerHost 인터페이스를 구현한 다음, 이를 통해 intent 블록을 열어 함수를 작성했다.
이 함수들 중 digit()을 통해 Orbit이 어떻게 작동하는지 확인한다.
fun digit(digit: Int) {
host.intent {
reduce {
state.copy(xRegister = state.xRegister.appendDigit(digit))
}
}
}
먼저 ContainerHost를 구현한 host부터 확인해 본다. 안드로이드 스튜디오에서 ContainerHost를 확인하면 아래 내용이 표시된다.
/**
* 이 인터페이스를 Orbit container host가 되고자 하는 모든 것에 적용하라
* 일반적으론 안드로이드 뷰모델이지만 간단한 프레젠터 등에도 적용 가능하다
*
* 컨테이너에서 orbit intent를 편하게 실행할 수 있도록 확장 함수 intent, orbit이 제공된다
*/
public interface ContainerHost<STATE : Any, SIDE_EFFECT : Any> {
/**
* orbit [Container] 객체.
*
* 팩토리 함수를 통해 Container 객체를 쉽게 얻을 수 있다
*
* ```
* override val container = scope.container<MyState, MySideEffect>(initialState)
* ```
*/
public val container: Container<STATE, SIDE_EFFECT>
}
Container를 누르면 아래 내용이 표시된다.
/**
* Orbit MVI 시스템의 핵심. 입출력이 있는 MVI 컨테이너를 나타낸다
* orbit 함수를 통해 컨테이너를 조작할 수 있다
*
* @param STATE The container's state type.
* @param SIDE_EFFECT 이 컨테이너가 게시한 사이드 이펙트 타입. 이 컨테이너가 사이드 이펙트를 post하지 않는 경우
* Nothing일 수 있다
*/
public interface Container<STATE : Any, SIDE_EFFECT : Any> {
...
}
Container는 상태 업데이트와 사이드 이펙트 처리할 때 사용할 수 있는 함수들을 가진 인터페이스다.
stateFlow 변수를 통해 상태 업데이트를 나타내고, 이 변수를 구독하면 최신 상태를 방출하고 고유한 값만 제공한다.
sideEffectFlow 변수는 Container에서 발생한 1회성 사이드 이펙트를 담고 있다. 공식문서에서 설명한 대로 네비게이션, 토스트 등의 1회성 사이드 이펙트를 다루기 위해 사용될 수 있다.
ContainerHost는 팩토리 함수를 통해 Container의 인스턴스를 얻기 위해 사용한다. 팩토리 함수로 Container의 인스턴스를 얻고, 그 인스턴스를 통해 ContainerHost의 확장 함수인 intent를 호출한다.
intent 확장 함수의 구현은 아래와 같다.
/**
* Build and execute an intent on [Container].
*
* @param registerIdling whether to register an idling resource when executing this intent. Defaults to true.
* @param transformer lambda representing the transformer
*/
@OrbitDsl
internal fun <STATE : Any, SIDE_EFFECT : Any> Container<STATE, SIDE_EFFECT>.intent(
registerIdling: Boolean = true,
transformer: suspend ContainerContext<STATE, SIDE_EFFECT>.() -> Unit
): Job =
kotlinx.coroutines.runBlocking {
orbit {
withIdling(registerIdling) {
transformer()
}
}
}
/**
* Build and execute an intent on [Container].
*
* @param registerIdling whether to register an idling resource when executing this intent. Defaults to true.
* @param transformer lambda representing the transformer
*/
@OrbitDsl
public fun <STATE : Any, SIDE_EFFECT : Any> ContainerHost<STATE, SIDE_EFFECT>.intent(
registerIdling: Boolean = true,
transformer: suspend SimpleSyntax<STATE, SIDE_EFFECT>.() -> Unit
): Job = container.intent(registerIdling) { SimpleSyntax(this).transformer() }
intent 확장 함수의 역할은 Container에서 intent를 빌드, 실행하는 것이다. 첫 번째 구현에서 orbit 확장 함수를 호출하는데 orbit은 Container 인터페이스에 구현돼 있다.
/**
* orbit intent를 실행한다. intent는 선택한 구문을 사용해서 ContainerHost에서 빌드된다
*
* @param orbitIntent 람다는 intent를 나타내는 일시 중단 함수를 리턴한다
*/
public suspend fun orbit(orbitIntent: suspend ContainerContext<STATE, SIDE_EFFECT>.() -> Unit): Job
정리하면 아래와 같다.
- Container는 MVI 컨테이너를 나타내며 입출력을 관리하고, 이를 통해 상태와 사이드 이펙트를 처리한다. 그러기 위한 여러 함수들이 정의돼 있다
- Container의 stateFlow는 StateFlow<State> 타입으로 상태 업데이트를 나타내고, 구독 시 최신 상태를 방출하고 고유한 값만 제공한다. sideEffectFlow는 Flow<SIDE_EFFECT> 타입으로 Container에서 발생한 1회성 사이드 이펙트를 나타낸다
- ContainerHost는 안드로이드의 뷰모델에 적용될 수 있는 인터페이스로, Container<STATE, SIDE_EFFECT> 타입 프로퍼티인 container를 통해 상태와 사이드 이펙트를 관리하는 컨테이너다
- ContainerHost 인터페이스의 인스턴스는 팩토리 함수를 통해 얻을 수 있다
이어서 reduce, postSideEffect 확장 함수의 구현과 주석을 확인한다.
/**
* Reducers는 현재 상태와 들어오는 이벤트를 줄여 새 상태를 만든다
*
* @param reducer - 람다가 현재 상태와 들어오는 이벤트를 줄여 새 상태를 생성한다
*/
@OrbitDsl
public suspend fun <S : Any, SE : Any> SimpleSyntax<S, SE>.reduce(reducer: SimpleContext<S>.() -> S) {
containerContext.reduce { reducerState ->
SimpleContext(reducerState).reducer()
}
}
reduce는 Orbit에서 상태 변경에 사용하는 함수 중 하나로 현재 상태와 들어오는 이벤트를 기반으로 새 상태를 만든다.
단순히 어떤 상태를 다른 상태로 바꿔버리는 게 아니라, 상태를 기반으로 새 상태를 생성하는 것에 주의한다.
참고로 자바스크립트 진영에서 사용하는 상태 컨테이너로 Redux라는 게 있는데 이 Redux는 State, Action, Reducer의 3가지로 이뤄져 있다. 이 중 Reducer는 아래 역할을 한다.
https://proandroiddev.com/mvi-a-new-member-of-the-mv-band-6f7f0d23bc8a
이전 상태와 동작을 가져와 새 상태를 생성하는 순수 함수
여기서 다시 digit()의 구현을 확인한다.
fun digit(digit: Int) {
host.intent {
reduce {
state.copy(xRegister = state.xRegister.appendDigit(digit))
}
}
}
reduce 함수 안에서 state.copy()를 통해 CalculatorStateImpl data class의 프로퍼티 중 xRegister의 값만 바꾸고 있다.
코틀린의 data class를 사용하면 기본 제공되는 함수인 toString, equals, hashCode, copy 중 copy를 사용해서 얕은 복사(Shallow Copy)를 통해 원하는 값만 바꾸는 것이다.
postSideEffect는 계산기 예제에서 사용되지 않았지만 사이드 이펙트를 처리하는 함수기 때문에 같이 확인한다.
/**
* 사이드 이펙트를 통해 트래킹, 네비게이션 등을 처리할 수 있다
*
* 이는 [Container.sideEffectFlow]를 통해 [SimpleSyntax.postSideEffect]를 호출해서 전달된다
*
* @param sideEffect - side effect flow를 통해 post할 사이드 이펙트
*/
@OrbitDsl
public suspend fun <S : Any, SE : Any> SimpleSyntax<S, SE>.postSideEffect(sideEffect: SE) {
containerContext.postSideEffect(sideEffect)
}
postSideEffect 함수는 상태 변경과 관련 없지만 처리해야 하는 사이드 이펙트(토스트, 네비게이션 등)를 처리하기 위해 사용한다.
이번엔 Compose에서 Orbit을 사용하는 예시를 확인해 본다. 먼저 라이브러리부터 적용한다.
[versions]
orbit = "6.1.0"
[libraries]
orbit-core = { group = "org.orbit-mvi", name = "orbit-core", version.ref = "orbit"}
orbit-viewmodel = { group = "org.orbit-mvi", name = "orbit-viewmodel", version.ref = "orbit"}
orbit-compose = { group = "org.orbit-mvi", name = "orbit-compose", version.ref = "orbit"}
그리고 app gradle에서 사용한다.
implementation(libs.orbit.core)
implementation(libs.orbit.compose)
implementation(libs.orbit.viewmodel)
이제 뷰모델을 구현한다.
class CounterViewModel: ViewModel(), ContainerHost<CountState, CountSideEffect> {
override val container: Container<CountState, CountSideEffect> = container(
initialState = CountState(),
buildSettings = {
this.exceptionHandler = CoroutineExceptionHandler { _, throwable ->
intent {
postSideEffect(CountSideEffect.Toast(throwable.message ?: "빈 메시지입니다"))
}
}
}
)
fun add(value: Int) = intent {
reduce {
state.copy(count = state.count + value)
}
postSideEffect(CountSideEffect.Toast("state.count = ${state.count}"))
}
fun moveToOtherScreen() = intent {
postSideEffect(CountSideEffect.NavigateToOtherScreen)
}
}
data class CountState(
val count: Int = 0,
)
sealed interface CountSideEffect {
class Toast(val message: String): CountSideEffect
data object NavigateToOtherScreen: CountSideEffect
}
Orbit 예제와는 다르게 뷰모델을 Container 인터페이스의 구현 클래스로 만들었다. 그러면서 container 변수를 반드시 재정의해서 구현하게 변경됐으니 재정의해준다.
이러면 host.intent와 같은 형태 대신 곧바로 intent 함수를 호출할 수 있어 조금 편리해진다.
사이드 이펙트는 Nothing 대신 정의한 사이드 이펙트를 넣어준다. State로 사용할 data class도 적절하게 선언한 다음 ContainerHost의 제네릭 안에 넣는다.
그리고 initialState, buildSettings를 각각 구현한다. initialState는 별 것 없지만 buildSettings에서 CoroutineExceptionHandler를 설정해 코루틴 내부에서 예외(throwable) 발생 시 공통으로 토스트를 띄우게 한다. 로그나 다른 처리를 할 거라면 다른 로직으로 바꾸면 된다.
다음은 컴포저블 함수의 구현이다.
class MainActivity : ComponentActivity() {
private val counterViewModel: CounterViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
setContent {
ComposePracticeTheme {
Surface {
Column(
modifier = Modifier.padding(48.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Button(
onClick = {
counterViewModel.add(1)
}
) {
Text(text = "add() call")
}
Button(
onClick = {
counterViewModel.moveToOtherScreen()
}
) {
Text(text = "moveToOtherScreen() call")
}
}
val state = counterViewModel.collectAsState().value
Log.e(this::class.java.simpleName, "state : ${state.count}")
val context = LocalContext.current
counterViewModel.collectSideEffect { sideEffect ->
when (sideEffect) {
is CountSideEffect.Toast -> {
Toast.makeText(context, sideEffect.message, Toast.LENGTH_SHORT)
.show()
}
is CountSideEffect.NavigateToOtherScreen -> {
Log.e(this::class.java.simpleName, "다른 화면으로 이동")
}
}
}
}
}
}
}
}
이렇게 설정한 후 실행하면 아래 화면이 나타날 것이다.
위의 버튼을 누르면 누를 때마다 로그캣에 로그가 표시되고 동시에 토스트도 표시된다.
그 밑의 버튼을 누르면 로그캣에 "다른 화면으로 이동"이라는 로그가 표시된다.
이런 식으로 Orbit 라이브러리를 통해 MVI 패턴을 구현할 수 있다.