[Android] MVI Pattern without Orbit

조범준·2024년 6월 17일
0

Android

목록 보기
3/5

Compose를 사용하기 시작하면서 상태 관리를 편하게 하기 위해서 MVI 패턴을 적용해왔습니다. 그리고 이를 더 쉽게 적용하기 위해서 매번 “Orbit” 라이브러리를 사용했습니다. 하지만 라이브러리를 통해서 쉽게 적용하는 것이 이 패턴을 더 잘 이해할 수 있게 도움을 주는지 생각이 들었습니다.

그래서 이번에는 “Orbit” 라이브러리를 사용하지 않고 MVI 패턴을 적용해보려고 합니다.

그러기 위해서는 MVI 패턴에 대해서 알아야 합니다. 이 글의 주제는 MVI 패턴이 아니기 때문에 간단하게 설명하겠습니다.

MVI?

Model, View, Intent로 구성이 됩니다.

  • Model은 앱 내의 상태를 가지고 있습니다.
  • View는 화면을 보여주면서 사용자와 상호작용을 합니다.
  • Intent는 사용자의 동작을 받아서 상태를 변경하는 동작을 수행합니다.

하지만 상태를 변경하지 않아도 되는 동작들 또한 존재합니다.
ex) 화면 이동, 토스트 메시지 등…

이러한 동작들은 Side Effect 라는 개념을 사용하여 처리하게 됩니다.

How?

BaseViewModel

ViewModel에서 상태와 Side Effect를 관리하기 때문에 BaseViewModel을 만들어 기본적인 State와 Side Effect를 선언했습니다.

interface UiState

interface SideEffect

abstract class BaseViewModel<UI_STATE: UiState, SIDE_EFFECT: SideEffect>(
    initState: UI_STATE
): ViewModel() {

    // 앱 내의 상태
    private val _uiState = MutableStateFlow(initState)
    val uiState = _uiState.asStateFlow()

    // SideEffect
    private val _sideEffect: Channel<SIDE_EFFECT> = Channel()
    val sideEffect = _sideEffect.receiveAsFlow()

	  // 최신 상태
    private val currentState: UI_STATE
        get() = _uiState.value

    // 상태 변경하기 위한 intent 함수
    protected fun intent(reduce: UI_STATE.() -> UI_STATE) {
        val state = currentState.reduce()
        _uiState.update{ state }
    }

    // SideEffect 동작 전달 함수
    protected fun postSideEffect(vararg sideEffect: SIDE_EFFECT) {
        for (effect in sideEffect) {
            viewModelScope.launch { _sideEffect.send(effect) }
        }
    }
}

Why State: Flow, Side Effect: Channel?

Flow는 LiveData 처럼 변경을 구독자에게 알려줍니다. 그래서 UI에 최신 데이터를 반영할 수 있게 해서 State는 Flow로 구현했습니다.

Channel은 Flow와 다르게 버퍼링 기능을 제공합니다. 이를 통해서 구독자가 없는 경우에 이벤트가 발생해도 해당 이벤트가 저장이 되고 이벤트는 무시되지 않습니다. 그리고 저장된 이벤트들을 순차적으로 처리할 수 있기 때문에 Side Effect는 Channel로 구현했습니다.

MainViewModel

@HiltViewModel
class MainViewModel @Inject constructor(
    private val getCategoryUseCase: GetCategoryUseCase,
    private val getJokeUseCase: GetJokeUseCase,
): BaseViewModel<MainState, MainSideEffect>(
    MainState()
) {
    ...
    
    fun showCategoryDropDown() = intent { copy(isDropDownExpanded = true) }

    private fun showJokeLoadedToast() = postSideEffect(MainSideEffect.ShowJokeLoaded)
}

ViewModel은 위에서 선언한 BaseViewModel을 상속받아서 구현하면 됩니다.

intent()를 사용하여 상태를 변경하고 postSideEffect()를 사용하여 Side Effect를 전달하면 됩니다.

MainContract

data class MainState(
    val category: Category = Category(),
    val joke: String = "Simple Joke!",
    val toastMsg: String = "",
    val toastVisible: Boolean = false,
    val isDropDownExpanded: Boolean = false,
): UiState

sealed interface MainSideEffect: SideEffect {
    object ShowJokeLoaded: MainSideEffect
}

앱 내의 상태와 Side Effect를 담고 있는 Contract는 기존과 동일하게 구현했습니다.

MainScreen

@Composable
fun MainRoute(
    viewModel: MainViewModel = hiltViewModel(),
) {
    val uiState = viewModel.uiState.collectAsStateWithLifecycle().value

    viewModel.sideEffect.collectWithLifecycle { sideEffect ->
        when(sideEffect) {
            is MainSideEffect.ShowJokeLoaded -> viewModel.onShowToast("Joke Loaded!!")
        }
    }

    LaunchedEffect(key1 = Unit) { viewModel.getCategory() }

    MainScreen(
        uiState = uiState,
        onClickDropDown = viewModel::showCategoryDropDown,
        onDismissDropDown = viewModel::hideCategoryDropDown,
        onClickCategoryItem = viewModel::getJoke
    )
}

Screen에서는 화면을 그리는 동작에 집중하기 위해서 상태 관리는 상위 Composable인 Route에서 처리하도록 구현했습니다.

여기서 State를 수집하여 UI에 최신 데이터를 반영합니다. Side Effect 또한 여기서 수집하여 대응되는 동작을 하도록 선언합니다.

.collectWithLifecycle

@Composable
inline fun <reified T> Flow<T>.collectWithLifecycle(
    minActiveState: Lifecycle.State = Lifecycle.State.STARTED,
    noinline action: suspend (T) -> Unit,
) {
    val lifecycleOwner = LocalLifecycleOwner.current

    LaunchedEffect(this, lifecycleOwner) {
        lifecycleOwner.lifecycle.repeatOnLifecycle(minActiveState) {
            this@collectWithLifecycle.collect { action(it) }
        }
    }
}

Side Effect는 위의 함수를 통해서 수집합니다. 이 함수 사용하여 컴포저블의 생명주기에만 Side Effect를 수집하여 메모리 누수를 방지할 수 있습니다.

https://github.com/BEEEAM-J/Simple-Joke

profile
https://beeamjunn.tistory.com/

0개의 댓글