시배's Android

Design Patterns | Orbit로 MVI 찍먹 해보기 (1) 본문

Android/Design Patterns

Design Patterns | Orbit로 MVI 찍먹 해보기 (1)

si8ae 2023. 8. 1. 17:12

MVI and Orbit

이 다이어그램은 Orbit 시스템(또는 MVI/Redux/Cycle과 유사한 시스템)이 간단한 원리로 작동하는 방식을 간략하게 보여줍니다.

  • UI는 비동기적으로 비즈니스 컴포넌트에게 액션을 보냅니다.
  • 비즈니스 컴포넌트는 들어오는 액션을 비즈니스 로직으로 변환합니다.
  • 비즈니스 컴포넌트는 이후 이벤트를 더 아래 단계로 발생시킵니다.
  • 모든 이벤트는 시스템의 현재 상태와 함께 reduce되어 새로운 상태를 생성합니다.
  • 이 상태는 다시 UI에게 발행되고, UI는 상태에 따라 자신을 렌더링합니다.

중요한 점은 UI가 비즈니스 결정을 스스로 내릴 수 없다는 것입니다. UI는 입력 상태에 따라 자신을 렌더링하는 방법만 알아야 합니다.

Orbit components

위의 로직을 실제 구성 요소에 매핑할 수 있습니다.

  • UI는 ContainerHost 인터페이스를 구현한 클래스에 대해 함수를 호출합니다. 일반적으로 Android에서는 이것이 액티비티(Activity), 프래그먼트(Fragment) 또는 간단한 뷰(View)가 될 수 있습니다. 그러나 Orbit 시스템은 UI 없이도 실행될 수 있으며, 예를 들어 백그라운드 서비스로 실행할 수도 있습니다.
  • 함수들은 인텐트 블록을 통해 Container 인스턴스로 호출됩니다. 이때 백그라운드 코루틴을 사용하여 작업을 오프로드하고, Side-Effect와 reduction를 위한 DSL을 제공합니다.
  • 변환은 인텐트 블록 내에서 사용자 정의 비즈니스 로직을 통해 수행됩니다.
  • reduce 연산자는 시스템의 현재 상태를 들어오는 이벤트와 함께 감소하여 새로운 상태를 생성합니다.
  • 새로운 상태는 관찰자들에게 전달됩니다.

Side effects

실제로는 이러한 시스템이 Side-Effect 없이는 존재할 없습니다. Side-Effect 일회성 이벤트로, 주로 탐색(navigation), 로깅(logging), 분석(analytics), 토스트(toasts) 등과 같이 Orbit 컨테이너의 상태를 변경하지 않는 이벤트들을 일컫습니다. 이와 같은 Side-Effect 처리하기 위해 Orbit 프레임워크에는 번째 연산자가 존재합니다.

UI 모든 Side-Effect를 인지할 필요가 없습니다. (예를 들어, UI 분석 이벤트를 보내는지 신경 써야 하죠?). 따라서 UI에게 반환할 이벤트가 없는 Side-Effect도 있을 있습니다.

Orbit container

컨테이너는 Orbit MVI 시스템의 핵심입니다. 컨테이너는 상태를 유지하고, 부작용과 상태 업데이트를 감시하며, 원하는 비즈니스 로직의 Orbit 연산자를 실행하여 상태를 수정하는 기능을 제공합니다.

Subscribing to the container

컨테이너는 컨테이너 상태 및 Side-Effect 업데이트를 내보내는 플로우(Flow)를 노출합니다.

상태 업데이트는 reduce된 상태가 발행됩니다.

Side-Effect는 기본적으로 관찰자가 없을 경우에도 캐시됩니다. 이는 컨테이너 설정을 통해 변경할 있습니다.

data class ExampleState(val seen: List<String> = emptyList())

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

class ExampleContainerHost(scope: CoroutineScope): ContainerHost<ExampleState, ExampleSideEffect> {
    
    // create a container
    override val container = scope.container<ExampleState, ExampleSideEffect>(ExampleState())

    fun doSomethingUseful() = intent {
        ...
    }
}

private val scope = CoroutineScope(Dispatchers.Main)
private val viewModel = ExampleContainerHost(scope)

fun main() {

    // subscribe to updates
    // On Android, use ContainerHost.observe() from the orbit-viewmodel module
    scope.launch {
        viewModel.container.stateFlow.collect {
            // do something with the state
        }
    }
    scope.launch {
        viewModel.container.sideEffectFlow.collect {
            // do something with the side effect
        }
    }

    viewModel.doSomethingUseful()
    
    // Ensure the main function does not complete so we can do something useful with the container.
}

ContainerHost

Orbit 컨테이너와 작업하기 위해서는 반드시 ContainerHost를 사용해야 하는 것은 아닙니다. 그러나 Orbit의 구문은 이 클래스를 기반으로 정의되므로 ContainerHost를 상속받는 것이 일반적입니다. 또한 이렇게 하면 비즈니스 로직을 간단하고 구조화하여 관리할 수 있으므로 강력히 권장됩니다. ContainerHost는 일반적으로 MVI 플로우를 정의하는데 사용됩니다. 이는 Container에서 호출될 비즈니스 로직과 Orbit 연산자를 함수로 정의하여, 예를 들어 UI에서 호출할 수 있도록 합니다.

일반적인 구현에서는 Android ViewModel 상속받고 ContainerHost 구현하여 Orbit 기능이 포함된 Android ViewModel 생성합니다.

class ExampleViewModel(
    savedStateHandle: SavedStateHandle
) : ViewModel(), ContainerHost<ExampleState, ExampleSideEffect> {
    // create a container
    val container = container<ExampleState, ExampleSideEffect>(ExampleState(), savedStateHandle)

    …
}

Core operators

MVI Operation Orbit DSL Purpose
block intent { ... } Container 비즈니스 로직을 포함하며, 다른 연산자를 호출하는 것을 가능하게 합니다.
transformation operations within intent 데이터 변환을 위해 비즈니스 작업을 실행합니다.
posted side effect postSideEffect( ... ) 일회성 이벤트를 Side-Effect 채널로 보냅니다.
reduction reduct { ... } 원자적으로 Container 상태를 업데이트합니다.
- repeatOnSubscription { ... } 활성 구독자가 있을 때만 무한한 Flow 수집하는 도움을 줍니다.

Transformation

class Example : ContainerHost<ExampleState, ExampleSideEffect> {
    ...

    fun simpleExample() = intent {
        anotherApiCall(apiCall()) // just call suspending functions
    }
}

변환(Transformations)은 상위 데이터를 다른 유형으로 변경하는 작업을 의미합니다. 변환은 간단한 매핑(mapping) 작업일 수도 있고, 백엔드 API를 호출하거나 Flow를 구독하는 등 더 복잡한 작업을 수행할 수도 있습니다.

Orbit에서 변환은 단순히 블록 함수 내에서 호출되는 일시 중단 함수(suspend function) 호출로 구현됩니다. 단지 올바른 컨텍스트를 사용하여 호출하는지 확인하는 것은 사용자의 책임입니다. 인텐트 블록에서 블로킹 코드를 사용하면 일반적으로 Orbit "이벤트 루프" 블로킹되어, 해당 코드가 완료될 때까지 새로운 인텐트의 처리가 방해받을 있습니다.

Reduction

class Example : ContainerHost<ExampleState, ExampleSideEffect> {
    ...

    fun simpleExample(number: Int) = intent {
        val result = apiCall()
        reduce { state.copy(results = result) }
    }
}

Reducers 들어오는 이벤트와 현재 상태를 사용하여 새로운 상태를 생성합니다.

Side effect

class Example : ContainerHost<ExampleState, ExampleSideEffect> {
    ...

    fun simpleExample(number: Int) = intent {
        val result = apiCall()
        postSideEffect(ExampleSideEffect.Toast("result $result"))
        reduce { state.copy(results = result) }
    }
}

어떤 시스템을 다루다보면 결국 Side-effect이 발생합니다. Orbit에서는 이러한 부작용을 주요 요소로 취급하고 있습니다.

이 기능은 일회성 이벤트, 네비게이션, 로깅, 분석 등과 같은 부작용을 처리하는 데에 주로 사용됩니다.

Side-effect를 컨테이너의 Side-effect 플로우에 보내기 위해 해당 이벤트를 게시할 수 있습니다. 이를 사용하여 토스트 메시지, 네비게이션 등과 같은 뷰와 관련된 부작용을 처리할 수 있습니다.

만약 관찰자가 없는 경우 Side-effect는 캐시되어, 네비게이션과 같은 중요한 이벤트가 다시 구독될 처리될 있도록 보장됩니다.

Container.sideEffectFlow는 오직 하나의 관찰자(observer)에 의해서만 수집(collected)될 수 있도록 설계되었습니다. 이렇게 함으로써 부작용 캐싱이 예측 가능한 방식으로 동작하도록 보장합니다. 만약 특정 사용 사례에서 멀티캐스팅(multi-casting)이 필요하다면 side effect flow에서 broadcast를 사용할 수 있지만, 이 경우 결과적으로 생성된 BroadcastChannel에 대해서는 캐싱이 동작하지 않음을 알아두어야 합니다.

Repeat on subscription

class Example : ContainerHost<ExampleState, ExampleSideEffect> {
    ...

    fun simpleExample() = intent(idlingResource = false) {
        repeatOnSubscription {
            expensiveFlow().collect {
                //
            }
        }
    }
}

인텐트 블록에서 직접 플로우를 수집하는 경우 해당 플로우가 완료되거나 취소될 때까지 계속됩니다. 취소는 Orbit 코루틴 스코프가 자동으로 취소되는 경우에 발생합니다.

Orbit 코루틴 스코프의 라이프사이클, 특히 viewModelScope로 설정된 경우, UI의 라이프사이클보다 오래 지속될 수 있어서 구독이 백그라운드에서 계속될 수 있습니다.

위치 또는 블루투스와 같이 비용이 많이 드는 구독의 경우, 이러한 상황은 바람직하지 않을 수 있으며, UI가 상태나 부작용 스트림을 적극적으로 관찰할 때에만 해당 플로우를 수집하길 원할 수 있습니다.

repeatOnSubscription 상태나 부작용 스트림이 관찰될 내부 블록을 시작( 재시작)하고, 해당 스트림이 이상 관찰되지 않을 중지하는 기능을 제공합니다.

Operator context

단순 구문의 연산자 람다(lambda)에는 현재 컨테이너의 상태를 노출하는 리시버(receiver) 있습니다.

perform("Toast the current state")
class Example : ContainerHost<ExampleState, ExampleSideEffect> {
    ...

    fun anotherExample(number: Int) = intent {
        val result = apiCall()
        postSideEffect(ExampleSideEffect.Toast("state $state"))
        reduce { state.copy(results = event.results) }
    }
}
reduce는 특별한 연산자로서, 람다가 호출될 때 상태가 캡처됩니다. 이는 reduce 블록 내에서 상태가 변경되지 않음이 보장됨을 의미합니다.

Container factories

perform("Toast the current state")
class Example : ContainerHost<ExampleState, ExampleSideEffect> {
    override val container = container<ExampleState, ExampleSideEffect>(ExampleState()) {
        // This block is an intent invoked when the container is first created
        reduce { ... }
    }
}

일반적으로 컨테이너는 직접 생성되는 것이 아니라 편리한 팩토리 함수를 통해 생성됩니다. 이를 통해 추가적인 설정이나 컨테이너가 처음 생성될 때 호출할 인텐트 람다를 전달할 수 있습니다. (이는 저장된 상태에서 다시 생성되거나 UI보다 오래 지속되는 컨테이너에 중요합니다.)

이는 컨테이너가 생성된 직후에 즉시 관찰을 시작해야 하는 플로우들을 수집하는 일반적인 사용 사례입니다.

perform("Toast the current state")
class Example(
    private val flow1: Flow<Int>,
    private val flow2: Flow<Int>,
): ContainerHost<ExampleState, ExampleSideEffect> {
    override val container = container<ExampleState, ExampleSideEffect>(ExampleState()) {
        coroutineScope {
            repeatOnSubscription {
            launch {
                flow1.collect {
                    reduce { ... }
                }
            launch {
                flow2.collect {
                    reduce { ... }
                }
            }
        }
    }
}

Threading

Orbit 대부분의 일반적인 사용 사례를 고려하여 합리적인 기본 스레딩 모델을 제공하는 것을 목표로 설계되었습니다. 그럼에도 불구하고, 사용자는 제한되지 않으며 필요한 경우 스레드를 변경할 있습니다 (: 데이터베이스 접근을 위해). 일반적으로 이는 코루틴 컨텍스트를 변경함으로써 수행됩니다.

Error handling

모든 오류를 인텐트 내에서 처리하는 것이 좋은 관행입니다. 기본적으로 Orbit 예외를 처리하거나 처리하지 않습니다. 이는 사용자가 오류에 대해 어떻게 응답할지에 대해 가정할 없기 때문입니다. 그러나 Container Settings 속성인 orbitExceptionHandler 통해 기본 예외 처리기를 설치할 있습니다. 이렇게 설정하면 예외가 해당 핸들러에서 잡히므로 부모 스코프에 영향을 미치지 않고 Orbit 컨테이너가 정상적으로 계속 작동합니다.