Android

안드로이드 [Kotlin] - UI State 관리

🤖 Play with Android 🤖 2023. 1. 18. 22:01
728x90


UI 레이어

UI의 역할은 화면에 애플리케이션 데이터를 표시하고 사용자 상호작용의 기본 지점으로서의 역할을 수행하는 것이다. 외부 이벤트로 인해 데이터 혹은 페이지가 변할 때마다 변경사항을 반영하도록 UI는 업데이트되어야 한다. 

 

 

 

UI State

UI는 그저 Data 레이어에서 가져온 데이터만을 의미하지 않는다. 데이터를 가져오기 전 기본 상태가 있을 수 있고 데이터를 가져오는 도중의 로딩 상태가 있을 수 있다. 또한 데이터를 항상 성공적으로 가져온다는 보장이 없으므로 데이터를 가져오는데 실패했을 때도 있을 것이다. 공식문서에서는 "사용자가 보는 항목이 UI라면 UI State는 앱에서 사용자가 봐야 한다고 지정하는 항목" 이라고 표현한다. 즉 앱은 UI State에 따라 사용자에게 보이는 모습을 달리해야 한다.

UI 데이터 + UI 상태 = UI

 

 

UI State를 위해 sealed class를 활용한 State Modeling 전략이 많이 사용되고 있다. sealed 클래스 Child 클래스 종류를 제한하는 특성이 있어, 정의된 하위 클래스 외에 다른 하위 클래스는 존재하지 않는다는 것을 컴파일러에게 알려주는 것과 같은 효과를 내기 때문이다.

 

 when 문으로 모든 케이스에 대해서 처리가 되어야 하기 때문에 다른 클래스들은 else 구문이 들어가야 하지만, sealed class는 컴파일 시점에 하위 클래스들이 정해져 있기 때문에 else 없이도 클래스들을 관리할 수 있다.

 

sealed class의 하위클래스는 class, data class, object 로 정의할 수 있다.

 

 

 

 

Sudo 코드 예시

sealed class 상태 {
    object 아침
    object 점심
    object 저녁
}
when (상태) {
    is 상태.아침 -> {
        씻기()
        아침먹기()
        출근하기()
    }
    is 상태.점심 -> {
        점심먹기()
        커피먹기()
    }
    is 상태.저녁 -> {
        퇴근하기()
        저녁먹기()
    }
}

이렇듯 정의된 상태에 따라 실행되는, UI 적으로는 보이는 코드가 달라지게 된다.

 

 

 

 

코드실습

이제 실제 코드에서 UI State를 간단히 사용해보자.

 

위에서 UI State를 정의하는 클래스는 sealed 클래스를 많이 사용하고 그 이유는 Sealed Class는 Child 클래스 종류를 제한하는 특성이 있어, 정의된 하위 클래스 외에 다른 하위 클래스는 존재하지 않는다는 것을 컴파일러에게 알려주는 것과 같은 효과가 있기 때문이라고 했다. 그럼 Sealed Class로 UI 상태를 표시해 보자.

 

UiState 클래스

sealed class UiState<out T>(val _data: T?) {
    object Loading : UiState<Nothing>(_data = null)
    object Error : UiState<Nothing>(_data = null)
    data class Success<out R>(val data: R) : UiState<R>(_data = data)
}

 

위와 같이 Loading과 Error는 object로 Success는 Data Class로 표현했다. 성공했을 때는 받아온 데이터를 data 프로퍼티에 저장하고, 로딩 상태와 에러 상태는 데이터를 가질 필요가 없으므로 생성자가 필요 없으므로 object로 선언해 주었다. 필자는 이렇게 UiState 클래스를 정의했지만 본인이 원하는 방식대로 바꾸어서 정의해도 전혀 상관없다. 방법은 다양하다.

 

 

ViewModel

private val _bookReport = MutableStateFlow<UiState<BookReport>>(UiState.Loading)
val bookReport: StateFlow<UiState<BookReport>> = _bookReport.asStateFlow()

fun getBookReportDetail(isbn: String) {
    viewModelScope.launch {
        bookReportRepository.getBookReportDetail(isbn)
            .catch {
                _bookReport.value = UiState.Error
            }
            .collect {
                _bookReport.value = UiState.Success(it)
            }
    }
}

필자는 Flow를 사용하여 데이터를 가져오기 때문에 Flow에서 제공하는 연산자를 적극 활용하였다. 우선 StateFlow를 선언해주고 초기값으로 UiState.Loading을 넣어주어 기본적으로 로딩상태를 만들어준다. 초기값은 꼭 Loading이 아니라 본인이 원하는 초기값이 있다면 따로 만들어 주어도 된다.

 

catch

해당 연산자는 Flow를 수집할 때 예외가 발생하면 해당 예외를 잡아준다. 이때 catch 연산자는 중간 연산자로써 오직 Upstream에서 발생한 exception만 처리할 수 있다. 즉 catch 아래의 연산자에서 발생한 exception은 처리하지 못한다. 즉 collect {...} 블록에서 일어난 예외는 catch의 downstream에서 일어난 예외임으로 catch가 처리할 수 없다.

 

collect

flow에서 방출된 값은 collect 함수에서 수집되므로 collect까지 데이터가 들어왔다면 정상적으로 데이터가 수집되었다고 볼 수 있다. 따라서 UiState.Success 안에 레포지토리로부터 가져온 데이터를 넣어주었다.

 

수집한 데이터를 StateFlow에 보관된다. MutableStateFlow 앞에는 _ 가 붙은 네이밍 컨벤션을 확인할 수 있고 StateFlow는 커스텀 게터를 통해 MutableStateFlow에 접근하고 있는데, 이것은 https://kotlinlang.org/docs/coding-conventions.html#names-for-backing-properties 코틀린 공식 홈페이지의 backing properties 관련 코틀린 컨벤션에 따른 표현이다.

 

 

 

View

private fun setBookReport() {
    collectStateFlow(bookReportDetailViewModel.bookReport) { uiState ->
        when (uiState) {
            is UiState.Loading -> {
                binding.progressBar.isVisible = true
            }
            is UiState.Success -> {
                binding.progressBar.isVisible = false
                val bookReport = uiState.data
                with(binding) {
                    // UI 설정
                }
            }
            is UiState.Error -> {
                binding.progressBar.isVisible = false
                Toast.makeText(requireContext(), "데이터를 불러오지 못했습니다.", Toast.LENGTH_SHORT).show()
            }
        }
    }
}

이제 View에서 UiState에 따라 사용자에게 보여줄 데이터를 표시해야 한다.

  • Loading 상태일 때는 ProgressBar를 보여줘 데이터를 불러오고 있음을 가시적으로 사용자에게 알려준다.
  • Success 상태일 때는 ProgressBar의 Visible을 false로 변경한 뒤 불러온 데이터를 uiState.data를 통해 가져오고 필요한 View와 연결해 준다.
  • Error 상태일 때는 ProgressBar의 Visible을 false로 변경한 뒤 Toast 메시지를 통해 데이터를 불러오지 못했다는 사실을 고지한다.

 

 

DataBinding과 UiState 함께 사용하기

위 코드는 View에서 UiState.data를 통해 데이터를 직접 가져와서 동적 코드로 View를 표시해 주었다. 만약 DataBinding을 사용 중이라면 ViewModel에 있는 데이터와 직접 연결해주고 있다면 어떠게 해야 할까?

 

<data>

    <variable
        name="viewModel"
        type="com.example.ui.viewmodel.ViewModel" />
</data>

<androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context=".ui.view.sample">

        <ImageView
            android:id="@+id/iv_sample"
            setProfileImageAtDetail="@{viewModel.sample}"
            android:layout_width="120dp"
            android:layout_height="120dp"
            android:layout_marginTop="140dp"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />
            
            ...

 

@BindingAdapter("setProfileImageAtDetail")
fun setProfileImageAtDetail(view: ImageView, uiState: UiState<UiData>?) {
    if (uiState is UiState.Success) {
        view.load("https:${uiState.data.profileImage}") {
            placeholder(R.drawable.ic_baseline_image_not_supported_20) // 로딩 도중 이미지 표시
            error(R.drawable.ic_baseline_image_not_supported_20) // 이미지 로딩 실패시 실패 표시
            transformations(CircleCropTransformation())
        }
    }
}

@BindingAdapter("setTitleAtDetail")
fun setTitleAtDetail(view: TextView, uiState: UiState<UiData>?) {
    if (uiState is UiState.Success) {
        view.text = uiState.data.sample
    }
}

 

위 xml에서 setProfileImageAtDetail="@{viewModel.sample}" 로 선언되어 있는 부분은 ViewModel에서 StateFlow<UiState<Data>>에 보관되어 있는 데이터와 연결되어 있다. 그리고 BindingAdapter 함수 안에서 if 문을 통해 UiState.Success 즉 성공적으로 데이터를 불러왔을 때 DataBinding을 연결해 주면 된다.

 

 

이상으로 안드로이드에서 UiState를 통해 데이터를 관리 및 표시하는 방법에 대해 알아보았다.