[Android] MVI 라이브러리인 Orbit 에 대해 알아보자

오규성·약 17시간 전
0

Android 에는 수많은 디자인 패턴들이 존재한다.
그 중 가장 유명한 것은 MVVM, MVP, MVC 였으나, 최근 Jetpack Compose 의 단방향 데이터 흐름과 상태 관리에 맞춰 MVI 라는 디자인 패턴이 인기를 끌고 있다.

Orbit 은 이러한 MVI 패턴 + 편의성을 위해 출시된 라이브러리로 많이들 쓰고 있는데, 이것이 나오게 된 이유를 알아보자.


# MVI 패턴이란?

Model + View + Intent (사용자 액션) 으로 이루어진 디자인 패턴이다.
기존 여러 상태를 관리하던 것을 하나의 불변 Model 로 생성하고, 사용자 행동(Intent) 가 발생하면 그 Intent 에 맞춰 새로운 상태 객체를 생성, View 는 이 상태를 통해 다시 UI 를 렌더링한다.

즉, 사용자 버튼 클릭 -> Intent 발생 -> 비즈니스 로직 처리 -> 새로운 Model 객체 생성 -> 이를 통한 렌더링 과 같은 단방향 흐름이 진행된다.

기존 MVVM 의 경우 데이터 바인딩을 사용하지 않는 경우, 단방향 데이터 흐름과 유사했지만 여러 상태를 관리하고 UI 가 ViewModel 의 함수를 직접 호출하였다.

개인적으로는 이러한 방식 탓에 Jetpack Compose 의 단방향 데이터 흐름이라는 설계 원칙과 완벽히 일치하고, 하나의 상태만을 가지며 이벤트에 따라 변하기에 상태 변화에 대한 예측이 쉬운 MVI 패턴을 선호하게 된 것이라고 생각한다.

# MVI 패턴의 단점

물론 다른 디자인 패턴과 동일하게 MVI 패턴에도 단점은 존재한다.
그것은 바로 수많은 보일러 플레이트 코드가 발생한다는 것인데, 예시 코드를 작성해보자.

sealed interface LoginUiEvent {
    data class EmailChanged(val email: String) : LoginUiEvent
    data class PasswordChanged(val password: String) : LoginUiEvent
    data object LoginButtonClick : LoginUiEvent
    data object ErrorDialogDismissed : LoginUiEvent
}

sealed interface LoginSideEffect {
    data object NavigateToMain : LoginSideEffect
    data class ShowToast(val message: String) : LoginSideEffect
}

data class LoginUiState(
    val email: String = "",
    val password: String = "",
    val isLoading: Boolean = false,
    val isLoginButtonEnabled: Boolean = false,
    val errorMessage: String? = null
)

하나의 화면에 대한 작업을 처리하기 위해서 Intent (Event), 그리고 Ui 에서의 부수효과인 Effect, 화면 상태인 UiState class 를 생성했다.

이 뿐만이 아니다.

abstract class MVIViewModel<SideEffect, Event, UIState>(defaultUiState: UIState) : ViewModel() {
    private val _sideEffectChannel = Channel<SideEffect>(Channel.BUFFERED)
    private val _eventChannel = Channel<Event>(Channel.BUFFERED)
    open val uiModel: StateFlow<UIState> = _eventChannel
        .consumeAsFlow()
        .runningFold(defaultUiState, ::reduce)
        .stateIn(viewModelScope, SharingStarted.Eagerly, defaultUiState)
    open val sideEffect = _sideEffectChannel.receiveAsFlow()

    open fun onEvent(event: Event) =
        viewModelScope.launch { _eventChannel.send(event) }

    open fun onEffect(effect: SideEffect) =
        viewModelScope.launch { _sideEffectChannel.send(effect) }

    protected abstract suspend fun reduce(
        currentModel: UIState,
        event: Event,
    ): UIState
}

@Composable
fun MainScreen(){
    LaunchedEffect(Unit) {
        viewModel.sideEffect.collectLatest { 
            when(it){
                TabSideEffect.None -> {}
                else -> {}
            }
        }
    }
}

위와 같이 레이스 컨디션을 방지하기 위해 Channel 과 추가 함수를 구현해야 안정적으로 사용이 가능하기에 보일러플레이트코드가 엄청나게 생성된다.

우리가 MVI 패턴을 적용할 때 생성되는 보일러플레이트코드를 방지하기 위해 나온 해결책으로 나온 것이 orbit 이다 !

Orbit 이란?

안드로이드 뿐만 아니라 CMP 도 지원하는 멀티플랫폼 MVI 라이브러리. 러닝 커브가 매우 낮다 !
자세한 참고는 다음 확인 https://github.com/orbit-mvi/orbit-mvi

기존 MVI 패턴이 가졌던 가장 큰 단점인 보일러플레이트코드를 줄일 수 있고, 코틀린 코루틴을 지원하며 CMP 에서도 사용 가능하다.

다른 라이브러리와의 비교점에서 다음과 같은 이점을 가지고 있다고 한다.

Orbit 을 사용해보자

우선 필요한 의존성을 추가하자.

// Core of Orbit, providing state management and unidirectional data flow (multiplatform)
implementation("org.orbit-mvi:orbit-core:<latest-version>")

// Integrates Orbit with Android and Common ViewModel for lifecycle-aware state handling (Android, iOS, desktop)
implementation("org.orbit-mvi:orbit-viewmodel:<latest-version>")

// Enables Orbit support for Jetpack Compose and Compose Multiplatform (Android, iOS, desktop)
implementation("org.orbit-mvi:orbit-compose:<latest-version>")

// Simplifies testing with utilities for verifying state and event flows (multiplatform)
testImplementation("org.orbit-mvi:orbit-test:<latest-version>")

이후 뷰모델에서 ContainerHost<T, T> 를 구현해주자.
이를 구현하면 container property 를 정의해줘야 하는데, container factory 함수를 사용하자.

@HiltViewModel
class DevGyuTestViewModel @Inject constructor(
): ContainerHost<CalculatorState, CalculatorSideEffect>, BaseViewModel(){
    override val container = container<CalculatorState, CalculatorSideEffect>(CalculatorState(2))

    fun add(num: Int) = intent {
        postSideEffect(CalculatorSideEffect.Toast("메세지 적용 : ${num}"))
        reduce { state.copy(total = state.total + num) }
    }
}
  • intent : 컨테이너 내의 상태 및 부수효과를 변경하기 위한 함수
  • reduce : 현재 상태와 이벤트를 종합하여 새로운 상태 생성
  • postSideEffect : 상태 변경과 관련없는 이벤트 처리 위한 부수효과 생성

UI 에서의 실행 코드

@Composable
fun MainScreen(
    viewModel: MainViewModel = hiltViewModel()
){
    viewModel.collectSideEffect {  // 각 SideEffect 별 확인 가능
        when(it){
            is CalculatorSideEffect.Toast -> Timber.d("토스트 발생")
        }
    }

    viewModel.observe(
        lifecycleOwner = LocalLifecycleOwner.current,
        state = {}, // 각 State 별 확인 가능
        sideEffect = { effect -> // 각 SideEffect 별 확인 가능
            when(effect){
                is CalculatorSideEffect.Toast -> Timber.d("토스트 발생")
            }
        }
    )
}

내부적으로 두 코드 다 STARTED 이후부터 내용을 수집하도록 설정되어있으니, 이를 참고하여 사용하자.


기존에는 MVI 패턴 구현하는것이 그리 어렵지 않은데 굳이 라이브러리를 써야하나? 싶었다.
보일러플레이트코드가 점점 생성되고, 이를 매번 관리해야하는 것에 대해서는 크게 신경쓰지 않았던 것 같다.

orbit 을 알아가며 뭔가 자주 쓸 것 같기는 한데, Circuit 을 써보지 않아서 뭘 써야할지는 아직 잘 모르겠다.

두 개 모두 장단점이 있으므로 나중에 Circuit 에 대해서도 알아보고 뭐가 좋을지 판단해봐야겠다.


참고

profile
안드로이드 개발자 Gyu 의 개발 블로그 !

0개의 댓글