이전 포스팅 Android 프로젝트에 MVI 도입하기 를 먼저 읽는 것을 권장합니다.

Orbit 개요

Orbit은 안드로이드 뿐만 멀티플랫폼을 지원하는 Redux/MVI 같은 라이브러리 이며, 쉽고 가벼운 것이 특징이다. 자세한 내용은 아래의 링크에서 참조하자

orbit을 프로젝트에 추가하기 위해 build.gradle에 다음 의존성을 추가 할 수 있다.(최신버전 확인)

implementation("org.orbit-mvi:orbit-core:<latest-version>")
// or, if on Android:
implementation("org.orbit-mvi:orbit-viewmodel:<latest-version>")
// If using Jetpack Compose include
implementation("org.orbit-mvi:orbit-compose:<latest-version>")

// Tests
testImplementation("org.orbit-mvi:orbit-test:<latest-version>")

Orbit은 다음과 같은 특징을 가진다.

  • 쉽고, 안전한 타입, 코루틴 스타일 및 확장 API 지원
  • iOS 및 안드로이드를 타게팅한 멀티플랫폼 지원
  • 코틀린 코루틴 완벽 지원
  • 생명주기에 안전한 flow 수집
  • SavedState를 포함한 ViewModel 지원
  • 간단한 단위 테스트 지원
  • 내장된 Espresso 유휴 자원 지원
  • RxJava 및 LiveData와 호환 지원

Orbit 을 써야 하는 이유

MVI 패턴을 구현하기 위해서 별도의 라이브러리 또는 프레임워크가 필수는 아니지만, 내가 Orbit을 선택한 이유는 다음과 같다.

  • MVI의 개념을 그대로 따름
  • 타 라이브러리에 비해 배우기 쉬움
  • 보일러플레이트 제거
  • 사용하기 쉬운 코루틴

그 외에도 Matthew Dolan의 아티클 을 읽어보면 다른 라이브러리들과 종합적으로 비교한 내용이 있는데, 상당부분 공감하기 때문에 orbit을 선택했다.

기본적인 사용법

Orbit은 상태(State)와 부수효과(Side Effects)를 관리하는 Container라는 개념을 정의하고 사용하고 있다. 일반적으로 ViewModel이 Container Host가 되어 Container를 관리하게 되는데, 이로 인해 상태 및 부수효과를 다루기가 쉬워지고, 보일러플레이트를 감소 시킬 수 있다.

우선 공식문서에 나온 예제를 기준으로 설명하자면 상태와 부수효과를 먼저 다음과 같이 정의한다

data class CalculatorState(
    val total: Int = 0
)

sealed class CalculatorSideEffect {
    data class Toast(val text: String) : CalculatorSideEffect()
}

ViewModel에서 컨테이너 활용을 위해 다음과 같이 코드를 작성한다.

class CalculatorViewModel: ContainerHost<CalculatorState, CalculatorSideEffect>, ViewModel() {

    // Include `orbit-viewmodel` for the factory function
    override val container = container<CalculatorState, CalculatorSideEffect>(CalculatorState())

    fun add(number: Int) = intent {
        postSideEffect(CalculatorSideEffect.Toast("Adding $number to ${state.total}!"))

        reduce {
            state.copy(total = state.total + number)
        }
    }
}

위 코드의 내용은 다음과 같다.

  • 일반적으로 안드로이드에서는 ViewModel을 ContainerHost(인터페이스)로 구현한다.
  • 위 인터페이스를 구현하게 되면 container를 생성해야하는데, container<State, SideEffect>(…) 팩토리 함수를 활용할 수 있다.
  • intent, reduce, postSideEffect 와 같은 dsl을 활용하여 상태 및 부수효과를 변경한다.

orbit에서 사용하는 DSL의 의미는 다음과 같다

  • intent : 컨테이너 내에 있는 상태 및 부수효과를 변경하기 위한 빌드 함수
  • reduce : 현재 상태와 들어온 이벤트를 토대로 새로운 상태를 만들어 낸다.
  • postSideEffect : 상태 변경과 관련 없는 이벤트들을 처리하기 위한 부수효과를 발생 시킨다.

이제 ViewModel(ContainerHost)을 Activity 또는 Fragment 에 연결해보자

class CalculatorActivity: AppCompatActivity() {

    private val viewModel by viewModel<CalculatorViewModel>()

    override fun onCreate(savedState: Bundle?) {
        ...
        addButton.setOnClickListener { viewModel.add(1234) }

        // orbit-viewmodel 모듈을 사용하여 Lifecycle.State.STARTED일 때,
        // 상태와 부수효과를 observe 한다.
        viewModel.observe(state = ::render, sideEffect = ::handleSideEffect)

        // 또는 스트림을 직접적으로 observe 할 수도 있다.
        lifecycleScope.launch {
            lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
                launch {
                    viewModel.container.stateFlow.collect { render(it) }
                }
                launch {
                    viewModel.container.sideEffectFlow.collect { handleSideEffect(it) }
                }
            }
        }
    }

    private fun render(state: CalculatorState) {
        ...
    }

    private fun handleSideEffect(sideEffect: CalculatorSideEffect) {
        when (sideEffect) {
            is CalculatorSideEffect.Toast -> toast(sideEffect.text)
        }
    }
}

viewModel.container 를 통해 stateFlow(상태)와 sideEffectFlow(부수효과)에 접근 할 수 있으며, 이를 직접 수집하거나 orbit-viewmodel 모듈 의존성추가를 통해 손쉽게 viewModel.observe(…)를 호출 할 수도 있다.

예제코드 orbit으로 마이그레이션

Android 프로젝트에 MVI 도입하기 에서 설명한 에제 코드에서 기존 ViewModel 코드를 orbit 스타일로 마이그레이션 해보자.

// AS-IS : orbit 사용 전
@HiltViewModel
class MainViewModel @Inject constructor(
    private val repository: MainRepository
) : ViewModel() {

    private val events = Channel<MainEvent>()

    val state: StateFlow<MainState> = events.receiveAsFlow()
        .runningFold(MainState(), ::reduceState)
        .stateIn(viewModelScope, SharingStarted.Eagerly, MainState())

    private val _sideEffects = Channel<String>()

    val sideEffects = _sideEffects.receiveAsFlow()

    private fun reduceState(current: MainState,event:MainEvent):MainState{
        return when(event){
            MainEvent.Loading -> {
                current.copy(loading = true)
            }
            is MainEvent.Loaded -> {
                current.copy(loading = false, users = event.users)
            }
        }
    }

    fun fetchUser() {
        viewModelScope.launch {
            events.send(MainEvent.Loading)
            val users = repository.getUsers()
            events.send(MainEvent.Loaded(users = users))
            _sideEffects.send("${users.size} user(s) loaded")
        }
    }

}


// TO-BE : orbit 사용 후
@HiltViewModel
class MainViewModel @Inject constructor(
    private val repository: MainRepository
) : ViewModel(), ContainerHost<MainState, String> {

    override val container: Container<MainState, String> = container(MainState())

    fun fetchUser() = intent{
        viewModelScope.launch {
            reduce { state.copy(loading = true) }
            val users = repository.getUsers()
            reduce { state.copy(users = users, loading = false) }
            postSideEffect("${users.size} user(s) loaded")
        }
    }

}

동일한 기능을 하는 코드 인데 코드 라인 수가 상당 수 줄어든 것을 확인할 수 있다.

(예제코드)

카테고리: ComposeetcKotlin

3개의 댓글

doyoul · 2024년 3월 6일 11:34 오후

안녕하세요! 이번에 프로젝트에 mvi를 적용하려고 여러 게시글을 찾아보다 해당 게시글을 읽게 되었습니다. 다름이 아니라 게시글을 읽고 궁금한 점이 생겨 답글을 달게 되었습니다!

doyoul · 2024년 3월 6일 11:35 오후

엇! 엔터를 잘못 눌러 답글을 두 개 달게 됐네요ㅜㅜ

doyoul · 2024년 3월 6일 11:38 오후

orbit에서 제공하는 container가 가지고 있는 state는 해당 화면에서 가지는 모든 state를 하나의 class로 관리하는 건가요?? 만약 좋아요 버튼이 있고, 삭제 버튼이 있다면 state에는 삭제 시 받아오는 response를 가지는 상태 하나, 좋아요 시 받아오는 response를 가지는 상태 하나, 총 두 상태를 모두 들고 있는 것인가요??

그리고 copy를 통해 기존 state에서 일부만을 변경해주는 작업을 하고 있는데, 만약 state가 위에서 말한 화면에 대한 모든 상태를 가지고 있는 거라면 copy 작업이 이루어질 때마다 너무 무거운 작업이 되는 건 아닌지 궁금합니다!!

실수로 답글을 세 번 나눠 달게 되었네요ㅜㅜ 죄송합니다!

답글 남기기

Avatar placeholder

이메일은 공개되지 않습니다. 필수 입력창은 * 로 표시되어 있습니다.