이전 포스팅 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

1개의 댓글

이지훈 · 2024년 2월 11일 12:04 오후

좋은 글 감사합니다!

답글 남기기

Avatar placeholder

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