Sealed class + StateFlow
서론
최근들어 저는 LiveData를 사용하지 않고, StateFlow를 이용해 UI Data State holder를 처리하는 편입니다.
그런데, 이러한 방식을 이용해 로딩, 에러, 데이터 성공을 모두 표현하기 위해서는 3개의 변수를 가지고
각각의 State에 따라 collect하고 있는 View들의 Visible처리를 바꿔주는 방식으로 사용하곤 했었습니다.
그러나 회사 코드와 여러 예제들을 보니 하나의 변수를 통해, Sealed class를 이용해 위의 세가지 기능을 한번에 처리해줄 수 있다는 것을 알게됐고, 이제부터 사용방법과 이점을 알아보도록 하겠습니다.
private val _movieList = MutableStateFlow<List<MovieResult>>(emptyList())
val movieList = _movieList.asStateFlow()
private val _movieLoading = MutableStateFlow<Boolean>(true)
val movieLoading = _movieLoading.asStateFlow()
private val _movieError = MutableStateFlow<Exception?>(null)
val movieError = _movieError.asStateFlow()
이전에는 다음과 같이 각각의 상태에 따라 변수를 하나씩 들고 있고, 각각의 flow를 collect하며 해당 상태에 따라 해당 뷰를 visible하거나, invisible하는 형태로 사용을 했습니다. (로딩만 예로 들어보겠습니다.)
viewLifecycleOwner.lifecycleScope.launchWhenStarted {
viewLifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
movieViewModel.movieLoading.collectLatest { loading ->
binding.loadingView.visible = if(loading) View.VISIBLE else View.INVISIBLE
}
}
}
loadingView만 예로 들면, movieLoading의 상태를 collect하며 visible처리를 해주고 있습니다. 이러한 코드 뿐 아니라, Error, movieList를 submitList까지 하는 코드가 추가적으로 존재하게 됩니다. (data binding을 사용하지 않고, viewBinding만 사용했습니다.)
또한 data binding과 binding adapter를 이용해 flow를 처리하게 되면 코드량은 현저히 적어지겠지만, 안드로이드의 생명주기에 맞게 처리해줄 수 없는 상황도 생길수도 있습니다. (DataBinding으로 자동 생성되는 파일 내부에서는 repeatOnLifecycle이나, launchWhenX... 처리가 포함되지 않습니다)
즉 viewBinding을 써도 코드량이 많아지고, data binding을 사용해도 생명주기를 신경써주지 못하는 상황이 오게됩니다.
이러한 점에 있어 두개의 타협점을 찾는다고 생각해봤습니다. 코드량을 3개에서 한개로 줄이고, 위와같이 Android Lifecycle에 맞게 사용하기 위해 Sealed class를 이용, loading, error, data submit까지 한번에 처리해보도록 하겠습니다.
Sealed class의 장점
먼저 Sealed class의 장점에 대해서 설명하자면, Sealed class는 Sealed class를 상속받는 Child class들의 종류를 제한합니다.
이를 통해 when문을 통해서 Child class들에 대한 처리를 분기처리해줄 때 else문이 필요 없게 됩니다. else 문이 필요 없다는 것은 반대로 말하면, 예외 케이스에 대해서 생각할 필요가 없다는 뜻입니다. 선언한 Child class 외에는 컴파일러가 잡아줄테니까요!
else문이 있다면, 예상치 못한 결과에 대해서 대응을 해야하지만, Sealed class는 그럴 필요가 없습니다.
신호등을 예로 들면, 빨간색 초록색 노란색 외에는 신경써줄 다른 색(else문과 같이)이 없으니 편하겠죠.
sealed class TrafficLight {
class Red : TrafficLight()
class Green : TrafficLight()
class Yellow : TrafficLight()
}
-- viewmodel -
val data = MutableStateFlow<TrafficLight>(TrafficLight.Red())
fun trafficJam() {
when (data.value) {
is TrafficLight.Red -> {
"빨강"
}
is TrafficLight.Green -> {
"초록"
}
is TrafficLight.Yellow -> {
"노랑"
}
}
}
안드로이드에서의 Sealed Class와 StateFlow
sealed class MovieApiState {
data class Success(val data: List<MovieUiState>) : MovieApiState()
data class Error(val exception: Throwable) : MovieApiState()
object Loading : MovieApiState()
}
먼저 MovieApiState라는 Sealed Class를 만들고, 상태에 따라 데이터를 가져오는 것을 성공했는지, 실패했는지, 로딩중인지에 대해서 선언합니다. 그리고 아래와 같이 뷰모델에서 해당 Sealed Class 타입의 Ui State를 만들고, 초기값을 줍니다. 그리고 Fragment에서 해당 Ui State가 변할 때마다 관련된 처리를 해주면 이전보다 깔끔하게, 더 짧은 코드로 한번에 모든 데이터의 상태를 처리해줄 수 있게 됩니다.
-- viewmodel --
private val _movieUiState = MutableStateFlow<MovieApiState>(MovieApiState.Loading)
val movieUiState = _movieUiState.asStateFlow()
-- fragment --
private fun initCollect() {
viewLifecycleOwner.lifecycleScope.launchWhenStarted {
viewLifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
movieViewModel.movieUiState.collectLatest { movieApiState ->
when (movieApiState) {
is MovieApiState.Loading -> {
Toast.makeText(requireContext(), "로딩중", Toast.LENGTH_SHORT).show()
}
is MovieApiState.Error -> {
Toast.makeText(requireContext(), "에러는 ${movieState.exception.message}", Toast.LENGTH_SHORT).show()
}
is MovieApiState.Success -> {
movieAdapter.submitList(movieState.data)
}
}
}
}
}
}
또한 when문 안의 코드만 봐도 어떠한 상태일 때 (로딩인지, 에러인지, 성공인지) 어떤 작업을 할지 명확하게 구분해줄 수 있어서 다른 사람이 코드를 이해하기에도 수월합니다.
추가로 kotlin의 runCatching을 이용하면, 성공했을 때와 실패했을 때에 따라서 결과를 던져줄 수 있겠죠!
suspend fun getMovieList(query: String) {
kotlin.runCatching {
movieUseCase(query)
}.onSuccess {
_movieUiState.value = MovieState.Success(it)
}.onFailure {
_movieUiState.value = MovieState.Error(it)
}
}
Generic
이러한 Sealed Class를 제네릭하게 사용하면, 위와같이 MovieUiState로만 사용하는 것이 아닌, 수 많은 API 통신에 공통적으로 사용할 수 있게 됩니다.
sealed class Result<out T : Any> {
data class ResultIsSuccess<out T : Any>(val data: List<T>) : Result<T>()
data class ResultIsError<out T : Any>(val exception: Exception) : Result<T>()
object ResultIsInProgress : Result<Nothing>()
}
다음 시간에 해당 코드와 함께 Generic에 대해서 정리해보도록 하겠습니다.
마무리
이렇게 Sealed Class와 State Flow를 잘 조합해서 쓰면 코드를 더 안전하고, 짧게 작성할 수 있습니다. 하지만 data binding과 결합하기에는 안드로이드 안정성(라이프 사이클)을 해결하기 어려워 viewBinding과 사용하였습니다. 이러한 장단점을 확실하게 파악하고 사용한다면 더 깔끔하고 좋은 코드를 만들어낼 수 있을 것 같습니다!