시배's Android

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

Android/Design Patterns

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

si8ae 2023. 8. 8. 15:21

Orbit + Compose + Pokedex API

Android 앱 개발에서 최신 기술인 Orbit와 Compose를 활용하여 Pokedex API를 사용하여 국룰 Pokedex 앱을 구현해보겠습니다.

Pokedex

 

GitHub - koreatlwls/Orbit-MVI-Compose-Pokedex: Orbit-MVI-Compose-Pokedex

Orbit-MVI-Compose-Pokedex. Contribute to koreatlwls/Orbit-MVI-Compose-Pokedex development by creating an account on GitHub.

github.com

 

 

 

 

 

 

 

 

 

 

  • Paging3를 활용하여 Infinite Scroll을 구현하였습니다.
  • Room과 Retrofit2를 활용하여 네트워크를 통해 데이터를 미리 로그하고 Room에 캐싱하였습니다.

ㄹㅇㄴ

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

  • Orbit을 활용하여 State와 Side-Effect를 정의하여 구현하였습니다.
  • 메인화면에서 포켓몬 이름을 검색하거나 클릭을 통해 포켓몬 정보를 로드하였습니다.

 

 

 

 

 

 

 

 

 

 

State & SideEffect

data class DetailState(
    val pokemonInfo: PokemonInfo? = null,
    val loading: Boolean = true,
)

sealed interface DetailSideEffect {
    data class SnackBar(val message: String) : DetailSideEffect
}

Orbit에서 사용할 State와 SideEffect를 정의하여 클래스를 구현합니다. 포켓몬의 상세정보의  State를 관리하고, Error 스낵바 메시지를 SideEffet를 통해 관리하였습니다. 

여기서 SideEffect는 특정 범위 밖에서 발생하는 앱 상태에 대한 변경을 의미합니다. 

Compose를 사용하고 있으므로 Composable 범위 밖에서 발생하는 앱 상태에 대한 변경을 의미하고 있습니다.

ViewModel 

@HiltViewModel
class DetailViewModel @Inject constructor(
    private val pokemonRepository: PokemonRepository
) : ViewModel(), ContainerHost<DetailState, DetailSideEffect> {

    override val container: Container<DetailState, DetailSideEffect> = container(DetailState())

    fun fetchPokemon(name: String) {
        intent {
            pokemonRepository.getPokemonInfo(name)
                .onSuccess { pokemonInfo ->
                    reduce {
                        state.copy(
                            pokemonInfo = pokemonInfo,
                            loading = false
                        )
                    }
                }
                .onFailure { throwable ->
                    postSideEffect(
                        DetailSideEffect.SnackBar(
                            message = throwable.message ?: "something went wrong"
                        )
                    )
                    reduce {
                        state.copy(
                            pokemonInfo = null,
                            loading = false
                        )
                    }
                }
        }
    }

}

Orbit을 사용할때는 ViewModel에서 ContianerHost를 상속하여 container 변수를 override하여 정의합니다.

MVI의 Intent를 감쌀 함수에서는 Intent 블럭으로 감싸고 reduce를 통해 상태값을 copy함수를 통해 변경합니다.

만약 SideEffect를 내보내야할 경우에는 postSideEffect 함수를 사용해야 합니다.

View

val state by detailViewModel.collectAsState()

detailViewModel.collectSideEffect {
        if (it is DetailSideEffect.SnackBar) {
            scope.launch {
                snackBarHostState.showSnackbar("Error가 발생하였습니다.")
                Log.e("DetailScreen", it.message)
            }
        }
    }

View에서는 state를 collectAsState()를 통해 읽어들이고, collectSideEffect를 통해 읽어들여 snackBar를 띄웠습니다.

@Composable
public fun <STATE : Any, SIDE_EFFECT : Any> ContainerHost<STATE, SIDE_EFFECT>.collectAsState(
    lifecycleState: Lifecycle.State = Lifecycle.State.STARTED
): State<STATE> {
    val stateFlow = container.stateFlow
    val lifecycleOwner = LocalLifecycleOwner.current

    val stateFlowLifecycleAware = remember(stateFlow, lifecycleOwner) {
        stateFlow.flowWithLifecycle(lifecycleOwner.lifecycle, lifecycleState)
    }

    // Need to access the initial value to convert to State - collectAsState() suppresses this lint warning too
    @SuppressLint("StateFlowValueCalledInComposition")
    val initialValue = stateFlow.value
    return stateFlowLifecycleAware.collectAsState(initialValue)
}

container의 stateFlow를 -> lifecycle을 알고있는 Flow로 변환 -> collectAsState를 통해 State로 변환합니다.

@SuppressLint("ComposableNaming")
@Composable
public fun <STATE : Any, SIDE_EFFECT : Any> ContainerHost<STATE, SIDE_EFFECT>.collectSideEffect(
    lifecycleState: Lifecycle.State = Lifecycle.State.STARTED,
    sideEffect: (suspend (sideEffect: SIDE_EFFECT) -> Unit)
) {
    val sideEffectFlow = container.sideEffectFlow
    val lifecycleOwner = LocalLifecycleOwner.current

    LaunchedEffect(sideEffectFlow, lifecycleOwner) {
        lifecycleOwner.lifecycle.repeatOnLifecycle(lifecycleState) {
            sideEffectFlow.collect { sideEffect(it) }
        }
    }
}

sideEffectFlow를 -> lifecycleState동안 구독하여 -> sideEffect 람다 실행

Mavericks vs Orbit

  • Mavericks에서는 ViewModel을 사용할 때 팩토리 함수를 이용하여 생성 + DI를 사용할때 ViewModel 모듈 필요
  • Orbit은 ViewModel을 사용할 때 상속과 override container만 이용
  • Mavericks에서는 네트워크나 DB를 통해 데이터를 들고 올 때 Async라는 sealed class 제공하여 Loading, Uninitialized, Success, Fail 상태 제공
  • Orbit은 미제공