시배's Android

Design Patterns | Mavericks로 MVI 패턴 찍먹해보기 (2) 본문

Android/Design Patterns

Design Patterns | Mavericks로 MVI 패턴 찍먹해보기 (2)

si8ae 2023. 7. 23. 13:17

Mavericks

매버릭스(Mavericks)는 Airbnb, Tonal 및 기타 대형 앱에서 사용되는 안드로이드 MVI 프레임워크로, 쉽게 배울 수 있으면서도 복잡한 플로우를 지원할만큼 강력합니다.

매버릭스를 만들 때 목표는 제품을 더 쉽고 빠르게, 더 재미있게 개발하는 것이었습니다. 우리는 매버릭스가 성공하기 위해 안드로이드 개발에 처음으로 참여하는 사람들에게도 쉽게 배울 수 있어야 하지만 Airbnb에서 가장 복잡한 화면도 지원할 수 있어야 한다고 믿습니다.

매버릭스는 Airbnb의 수백 개의 화면에서 사용되며, 새로운 화면의 100%에서 사용되고 있습니다. 또한 10억 개 이상의 다운로드를 가진 앱부터 작은 샘플 앱까지 무수히 많은 다른 앱에서도 채택되고 있습니다.

매버릭스는 Android Jetpack과 Kotlin Coroutines 위에 구축되어서 구글의 표준 라이브러리를 보완하는 형태로 생각할 수 있습니다. 따라서 기존의 기술 스택을 버리는 대신 보완적으로 도입됩니다.

다음은 간단한 화면의 예시입니다. 더 복잡한 화면은 더 많은 상태 속성이나 ViewModel 함수를 가질 수 있지만, 디버깅이나 코드 읽기도 크게 복잡해지지 않습니다.

/** State classes contain all of the data you need to render a screen. */
data class CounterState(val count: Int = 0) : MavericksState

/** ViewModels are where all of your business logic lives. It has a simple lifecycle and is easy to test. */
class CounterViewModel(initialState: CounterState) : MavericksViewModel<CounterState>(initialState) {
    fun incrementCount() = setState { copy(count = count + 1) }
}

/**
 * Fragments in Mavericks are simple and rarely do more than bind your state to views.
 * Mavericks works well with Fragments but you can use it with whatever view architecture you use.
 */
class CounterFragment : Fragment(R.layout.counter_fragment), MavericksView {
    private val viewModel: CounterViewModel by fragmentViewModel()

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        counterText.setOnClickListener {
            viewModel.incrementCount()
        }
    }

    override fun invalidate() = withState(viewModel) { state ->
        counterText.text = "Count: ${state.count}"
    }
}

MavericksState

매버릭스(Mavericks) 화면을 생성하는 첫 번째 단계는 상태(State)의 함수로서 모델링하는 것입니다. 매버릭스 상태 인터페이스(MavericksState) 자체로는 아무 동작을 하지 않지만, 해당 클래스가 상태로 사용될 의도를 나타냅니다.

화면을 상태의 함수로서 모델링하는 것은 다음과 같은 이점이 있습니다:

 

  • 스레드 안전(Thread safe)합니다.
  • 여러분과 다른 엔지니어들이 쉽게 이해할 수 있습니다.
  • 해당 화면이 도달하기까지의 이벤트 발생 순서에 독립적으로 동일하게 렌더링됩니다.
  • 어떤 유형의 화면도 렌더링하는 데 강력합니다.
  • 쉽게 테스트할 수 있습니다.

 

매버릭스는 또한 상태 클래스가 다음과 같은 조건을 충족하도록 강제합니다:

 

  • Kotlin 데이터 클래스여야 합니다.
  • 오직 불변 속성만을 사용해야 합니다.
  • 모든 속성에는 기본 값이 있어야 하며, 이는 화면이 즉시 렌더링될 수 있도록 합니다.

 

매버릭스는 이러한 규칙들을 디버그 체크를 통해 강제합니다.

이 개념은 화면에 대한 추론과 테스트를 매우 쉽게 만들어 줍니다. 상태 클래스가 주어진다면 해당 화면이 올바르게 보이리라는 높은 확신을 가질 수 있습니다. 예시를 드리겠습니다.

data class UserState(
    val score: Int = 0,
    val previousHighScore: Int = 150,
    val livesLeft: Int = 99,
) : MavericksState

 

Derived Properties

상태(State) 일반적인 Kotlin 데이터 클래스이기 때문에 다음과 같이 특정 상태 조건을 나타내는 파생 속성(derived properties) 생성할 있습니다.

data class UserState(
    val score: Int = 0,
    val previousHighScore: Int = 150,
    val livesLeft: Int = 99,
) : MavericksState {
    // Properties inside the body of your state class are "derived".
    val pointsUntilHighScore = (previousHighScore - score).coerceAtLeast(0)
    val isHighScore = score >= previousHighScore
}

pointsUntilHighScore나 isHighScore와 같이 파생된 속성으로 로직을 배치하는 것은 다음과 같은 이점이 있습니다:

  • onEach를 사용하여 이러한 파생 속성의 변경에 대해 구독할 수 있습니다.
  • 파생 속성은 항상 기본 상태와 일치하므로 동기화 문제가 발생하지 않습니다.
  • 상태를 추론하기가 매우 쉽습니다.
  • 이러한 파생 속성에 대한 단위 테스트를 작성하기가 매우 쉽습니다.

MavericksViewModel

ViewModel은 다음과 같은 책임을 가지고 있습니다:
1. 상태(State)의 업데이트:

  • ViewModel은 상태를 업데이트하는 역할을 담당합니다.
  • setState { copy(yourProp = newValue) }와 같은 문법을 사용하여 ViewModel 내부에서 상태를 업데이트할 수 있습니다.
  • 이 문법에서 람다(lambda)의 시그니처는 S.() -> S로, 람다의 수신자(Receiver, 즉 this)는 람다가 호출될 때의 현재 상태를 가리킵니다. 람다는 새로운 상태를 반환합니다.
  • 상태 클래스가 Kotlin 데이터 클래스이기 때문에 copy 함수를 사용하여 새로운 상태 객체를 생성할 수 있습니다.
  • 람다는 동기적으로 실행되지 않으며, 백그라운드 스레드에서 대기열에 추가되어 실행됩니다. 스레딩에 대한 자세한 내용은 문서를 참조하시기 바랍니다.

2. 상태 변경을 구독하기:

  • ViewModel에서 상태 변경을 구독할 수 있습니다.
  • 이를 통해 분석(analytics) 등의 목적으로 사용할 수 있습니다.
  • 일반적으로 이러한 구독은 init { ... } 블록에서 수행됩니다.

Mavericks의 ViewModel은 개념적으로는 Jetpack ViewModel과 거의 동일하며, MavericksState 클래스에 대한 제네릭(Generic) 타입을 추가함으로써 구분됩니다.

비동기/데이터베이스/네트워크 작업 처리에 대해서는 Mavericks에서는 이를 쉽게 다룰 수 있도록 Async<T>와 execute(...)와 같은 도구들을 제공합니다. 

// Invoked every time state changes
onEach { state ->
}
// Invoked whenever propA changes only.
onEach(YourState::propA) { a ->
}
// Invoked whenever propA, propB, or propC changes only.
onEach(YourState::propA, YourState::propB, YourState::propC) { a, b, c ->
}

 

stateFlow

MavericksViewModel은 stateFlow 속성을 노출하는데, 이는 일반적인 Kotlin Flow입니다. stateFlow는 현재 상태와 이후 업데이트를 발행하며, 원하는 대로 사용할 수 있습니다. 위에서 설명한 onEach와 같은 도우미 함수들은 자동적으로 라이프사이클에 맞춰서 취소되는 래퍼(wrapper)입니다.

 

한 번만 상태에 접근하기

만약 상태의 값을 한 번만 가져오고자 한다면, withState { state -> ... }를 사용할 수 있습니다.

ViewModel 내부에서 호출될 , 이는 동기적으로 실행되지 않습니다. 대신 백그라운드 큐에 넣어져서 모든 대기 중인 setState 리듀서들이 withState 호출 이전에 호출되도록 보장됩니다.

MavericksView

MavericksView는 실제로 상태 클래스를 화면에 렌더링하는 곳입니다. 대부분의 경우 이는 Fragment가 될 것이지만, 꼭 그럴 필요는 없습니다. MavericksView를 구현함으로써 다음과 같은 기능들을 이용할 수 있습니다.

 

1. 뷰 모델 접근과 업데이트:

  • 어떤 뷰 모델 델리게이트를 통해 MavericksViewModel에 접근할 수 있습니다. 이렇게 하면 자동으로 상태 변경을 구독하고 invalidate() 함수를 호출합니다.
  • invalidate() 함수를 오버라이드합니다. 이 함수는 위에서 언급한 델리게이트를 통해 접근한 모든 뷰 모델의 상태가 변경될 때마다 호출됩니다. invalidate() 함수는 각 상태 변경마다 UI를 다시 그리는 데 사용됩니다.

 

2. 뷰 모델 델리게이트:

  • activityViewModel(): 뷰 모델을 Activity에 지정합니다. Activity 내에서 이와 같은 유형의 뷰 모델을 요청하는 모든 Fragment는 동일한 인스턴스를 받게 됩니다. 화면 간 데이터 공유에 유용합니다.
  • fragmentViewModel(): 뷰 모델을 Fragment에 지정합니다. 자식 Fragment에서도 접근할 수 있지만, 부모나 형제 Fragment는 다른 인스턴스를 받게 됩니다.
  • parentFragmentViewModel(): 원하는 유형의 뷰 모델을 가진 상위 Fragment로 올라가며 찾습니다. 만약 발견되지 않으면, 가장 최상위 상위 Fragment에 대해 자동으로 새로운 인스턴스를 생성합니다.
  • existingViewModel(): activityViewModel()과 동일하지만, 다른 누군가에 의해 이미 뷰 모델이 생성되지 않았을 경우 예외를 throw합니다.
  • navGraphViewModel(navGraphId: Int): 해당 ID를 가진 Jetpack Navigation 그래프에 뷰 모델을 지정합니다. 이 기능은 mvrx-navigation 아티팩트가 필요합니다.

 

3. 수동으로 상태 구독:

  • 대부분의 경우 invalidate() 함수를 오버라이드하고 뷰를 업데이트하는 것으로 충분합니다. 하지만 애니메이션 시작과 같은 상태 구독이 필요한 경우, ViewModel에서 onEach 구독 중 하나를 호출할 수 있습니다. 뷰가 Fragment인 경우, 이러한 구독은 onCreate()에서 설정되어야 합니다.

 

4. 상태 값 한 번에 접근하기:

  • 상태의 값을 한 번만 가져오고자 한다면, withState { state -> ... }를 사용할 수 있습니다.
  • 뷰 모델 외부에서 호출될 때, 이는 동기적으로 실행됩니다.

 

5. 상태 변경 트리거하기:

  • 뷰 모델은 뷰에서 호출할 수 있는 이름이 지정된 함수들을 노출해야 합니다. 예를 들어, 카운터 뷰 모델은 incrementCount()와 같은 함수를 노출하여 뷰에서 접근할 수 있는 명확한 API를 제공할 수 있습니다.

 

Design Patterns | Mavericks로 MVI 패턴 찍먹해보기 (1)

 

Design Patterns | Mavericks로 MVI 패턴 찍먹해보기 (1)

안드로이드 앱 개발에서 MVI (Model-View-Intent) 아키텍처는 현재 많은 개발자들에게 인기가 높아지고 있는 패턴입니다. MVI는 앱의 상태 관리와 UI 처리를 효과적으로 분리하여 앱의 유지보수성을 향

si8ae.tistory.com

Design Patterns | Mavericks로 MVI 패턴 찍먹해보기 (3)

 

Design Patterns | Mavericks로 MVI 패턴 찍먹해보기 (3)

Mavericks + Compose + Naver API Android 앱 개발에서 최신 기술인 Mavericks와 Compose를 활용하여 Naver API를 사용하여 책을 검색하는 앱을 구현해보겠습니다. MavericksState data class SearchUiState( val keyword: String = "",

si8ae.tistory.com

https://airbnb.io/mavericks/#/README

 

Mavericks Docs

 

airbnb.io