💡 Intro
위와 같은 화면을 만들기 위해 어떤 방법을 사용해야 할까 많이 고민했다. 보여줘야 하는 데이터는 읽은 쪽지와 등록한 쪽지다. 그런데 우리가 가진 API는 다음과 같다.
letterlogs?gameId={}&logtype={READ/WRITE}
특정 게임의 아이디를 통해 읽은 쪽지를 받아오거나 등록한 쪽지를 받아오거나. 그러니까 읽은 쪽지 + 등록한 쪽지를 모두 가져오려면 총 두 번의 요청이 필요하다.
어? 그러면 읽은 쪽지와 등록한 쪽지 모두를 가지고 있는 상황에서만 뷰를 띄워주어야 하는 것이 아닌가?🤔 하는 생각이 들었다. 그러면 UI State를 통해 성격이 다른 두 개의 데이터를 하나의 클래스안에 캡슐화하고, UI State가 뷰에 표시될 준비가 되면 뷰에서 보여주면 되겠다!😎 는 생각이 들었다.
하지만 UI State에 대해 많은 것을 알고 있지는 않았다. 수업에서 UI의 상태를 관리하는 방법 중 하나입니다! 여러 개의 데이터를 하나로 묶어 처리할 수 있어요! 라고만 알고 있었다. 그래서 적용하기 위해 공식문서를 살금살금 읽어보았다. 아래는 무려 영어로 된 공식문서를 DeepL과 내가 함께 읽으면서 정리한 내용이다. 그대로 가져온 내용도, 내가 이해한 방향으로 서술한 내용도 있다. 쭉 읽는 것 만으로는 내 머릿속에 잘 들어오지 않아 글로 한 번 정리하는 시간을 가졌다.
📜 UiState from 공식문서
1️⃣ UI layer architecture
데이터 계층의 역할은 앱 데이터를 보유, 관리 및 액세스 권한을 제공하는 것이기 때문에 Ui 계층은 다음의 단계를 수행해야 한다.
- 앱 데이터를 사용하고 변형하여 UI가 쉽게 렌더링할 수 있는 데이터로 변환한다.
- UI 렌더링이 가능한 데이터를 사용하고 사용자에게 표시할 수 있도록 UI 요소로 변환한다.
- UI에서 사용자 입력 이벤트를 수집하고 필요에 따라 그 효과를 UI 데이터에 반영한다.
공식문서에서는 다음과 같은 내용을 다룬다.
- UI 상태를 정의하는 방법
- UI 상태를 생성하고 관리하는 수단으로서의 단방향 데이터 흐름(UDF : Unidirectional Data Flow)
- UDF 원칙에 따라 관찰 가능한 데이터 유형으로 UI 상태를 노출하는 방법
- 관찰 가능한 UI 상태를 사용하는 UI를 구현하는 방법
2️⃣ UI State를 정의하기
UI가 사용자에게 보이는 것이라면, UI 상태는 앱이 사용자에게 보여줘야 한다고 말하는 것이다.
UI에 표시할 데이터를 UI State에 캡슐화할 수 있다.
data class NewsUiState(
val isSignedIn: Boolean = false,
val isPremium: Boolean = false,
val newsItems: List<NewsItemUiState> = listOf(),
val userMessages: List<Message> = listOf()
)
data class NewsItemUiState(
val title: String,
val body: String,
val bookmarked: Boolean = false,
...
)
불변성
UI State는 불변성을 유지해야 한다. 이를 통해 UI는 UI State의 값을 읽고 이에 따라 적절하게 UI를 업데이트하는 역할에만 집중할 수 있다.
만약 불변성을 유지하지 않는다면 액티비티에서도 값을 변경할 수 있게 되는 것이며 변경된 값은 다른 레이어와 서로 자신의 값이 맞다며 경쟁할 수도 있다.
데이터의 출처 또는 소유자만이 자신이 노출하는데 데이터를 업데이트할 책임이 있다.
네이밍 컨벤션
functionality(기능) + UiState.
뉴스를 표시하는 화면의 상태는 NewsUiState, 뉴스 항목 목록에서 뉴스 항목의 상태를 표시하는 상태를 NewsItemUiState라고 할 수 있다.
3️⃣ UDF를 이용한 흐름 관리
단방향 데이터 흐름을 통해 책임분리를 하는 방법에 대해 알아보자.
State Holders
UI State의 생성을 담당하고 해당 작업에 필요한 로직을 포함하는 클래스를 말한다.
간단한 클래스로도 충분한 경우가 있지만, 일반적으로는 ViewModel이 담당한다.
공식문서에서 예로 드는 기사 앱은 뉴스 뷰모델을 State Holder로 사용하여 해당 뷰에 표시되는 UI State를 생성한다.
여기서 이야기하는 뷰모델을 사용하는 방법은 뷰모델이 데이터 레이어에 접근하여 UI State를 관리하고, 공식문서에서 권장하는 구현이다. 뷰모델을 사용하면 구성 변경에 살아남을 수 있다. 뷰모델은 앱의 이벤트에 적용될 로직을 정의하고, 그 결과로 업데이트된 UI State를 생성한다.
UI와 뷰모델 클래스 간 상호작용은 크게 이벤트 입력과 그에 따른 출력으로 이해할 수 있고 다이어그램으로 표시하면 다음과 같다.
상태는 아래로 흐르고, 이벤트는 위로 흐르는 패턴을 UDF라고 한다. UDF는 다음을 시사한다.
- 뷰모델이 UI가 사용할 상태를 가지고 있고, UI에 노출한다. UI State는 뷰모델에 의해 가공된 데이터다.
- UI는 뷰모델에 사용자 이벤트를 알린다.
- 뷰모델은 사용자 이벤트를 처리하고 UI State를 업데이트한다.
- 업데이트 된 UI State는 UI에 피드백된다.
- 상태 변경을 유발하는 모든 이벤트에 대해 위 과정을 반복한다.
뷰모델은 리포지터리나 유스 케이스와 함께 동작하여 데이터를 가져오고 UI State로 가공하는 동시에 상태의 변경을 유발할 수 있다.
뷰모델의 책임은 상태의 생성자로서 UI State의 모든 필드를 채우고 UI가 완전히 그려지는데 필요한 이벤트를 처리하는 데 필요한 로직을 정의하는 것이다.
데이터를 가져오는 것, 데이터를 뷰에서 사용하도록 UI State를 생성하는 것, 필요한 이벤트를 처리하는 비즈니스로직은 뷰모델이 가지고 있어야 한다는 것이다.
로직의 타입
- 비즈니스 로직
앱에 대한 요구사항을 구현하는 것을 말한다. 공식문서에 나오는 예제로 따지면 기사에 북마크를 하는 것이 그 예시다. 비즈니스 로직은 일반적으로 도메인 레이어나 데이터 레이어에 배치되며, UI 레이어에는 배치되지 않는다.
- UI 로직
안드로이드 리소스를 사용하여 화면에 표시할 텍스트를 가져오거나, 사용자가 버튼을 클릭할 때 특정 화면으로 이동하거나, 토스트나 스낵바를 사용하여 화면에 메시지를 표시하는 작업을 포함한다.
컨텍스트와 같은 UI 타입들을 포함하는 경우 UI에 있어야 한다. 만약 UI가 너무 복잡해지고 테스트 용이성 및 관심사 분리를 위해 UI 로직을 다른 클래스에 위임하려는 경우 State Holder로 간단한 클래스를 만들 수 있다
왜 UDF를 사용해야 하는가?
UDF는 상태 생성의 주기를 모델링한다. 또한 상태 변경되는 곳, 변환되는 곳, 사용되는 곳을 분리한다.
이러한 분리를 통해 UI는 상태 변경을 관찰함으로써 정보를 표시하고 변경 사항을 뷰모델에 전달하여 사용자 의도를 전달할 수 있다.
UDF는 다음을 가능하게 한다.
- Data Consistency : UI가 신뢰할 수 있는 단일 소스가 있다.
- Testability : 상태 소스가 분리되어 있기 때문에 UI와 독립적으로 테스트할 수 있다.
- Maintainability : 상태 변이는 사용자 이벤트와 해당 이벤트에서 가져온 데이터 소스 모두의 결과다.
4️⃣ Expose Ui State
UI State에 대해 알아보았다. 그런데 이것을 통해 UI에 어떻게 보여줄 수 있을까?
UDF를 통해 UI State를 관리하기 떄문에 상태를 스트림으로 볼 수 있고 시간이 지나면서 다양한 UI State가 생성될 것이다. 따라서 UI State를 LiveData
혹은 StateFlow
와 같이 뷰에서 관찰 가능한 데이터 홀더의 상태로 노출해야 한다.
이렇게 함으로써 뷰모델에서 직접 데이터를 가져올 필요없이 UI가 UI State의 변경에 따라 바로 반응할 수 있도록 만들 수 있다. 또, 항상 최신 버전의 UI State를 뷰모델이 가지고 있기 때문에 구성 변경 후에도 빠르게 데이터를 복원할 수 있다.
UI State 스트림을 생성하는 방법은 다음과 같다.
class NewsViewModel(...) : ViewModel() {
private val _uiState = MutableStateFlow(NewsUiState())
val uiState: StateFlow<NewsUiState> = _uiState.asStateFlow()
...
}
그러면 뷰모델은 내부적으로 상태를 변경하는 메서드를 노출하여 UI가 사용할 업데이트를 게시할 수 있다. 예를 들어 비동기로 서버와 통신해야 하는 경우 뷰모델 스코프를 열어 코루틴을 시작하고 완료한 경우 변경 가능한 상태를 업데이트 할 수 있다.
class NewsViewModel(
private val repository: NewsRepository,
...
) : ViewModel() {
private val _uiState = MutableStateFlow(NewsUiState())
val uiState: StateFlow<NewsUiState> = _uiState.asStateFlow()
private var fetchJob: Job? = null
fun fetchArticles(category: String) {
fetchJob?.cancel()
fetchJob = viewModelScope.launch {
try {
val newsItems = repository.newsItemsForCategory(category)
_uiState.update {
it.copy(newsItems = newsItems)
}
} catch (ioe: IOException) {
// Handle the error and notify the UI when appropriate.
_uiState.update {
val messages = getMessagesFromThrowable(ioe)
it.copy(userMessages = messages)
}
}
}
}
}
위 예제에서 뷰모델의 함수를 통해 상태가 변경되는 패턴은 UDF에서 가장 널리 사용되는 구현 중 하나다.
추가로 고민할 점
- UI State는 서로 연관된 상태를 처리해야 한다.
이것은 불일치 코드가 줄어들고, 코드를 이해하는데 도움이 된다.
공식문서에서 제공하는 예를 통해 보았을 때 만약 뉴스 목록과 북마크 수를 다른 두 개의 UI State에 보관을 한다면 하나는 업데이트되고 하나는 업데이트되지 않는 불상사가 일어날 수도 있다.
또, 일부 비즈니스 로직에서 여러 데이터의 조합이 필요할 수도 있는데, 예를 들어 사용자가 로그인한 상태이고 그 사용자가 프리미엄 뉴스 구독자인 경우에만 북마크 버튼을 표시해야 할 수 있다. 이 때는 다음과 같이 UI State를 정의할 수 있다.
data class NewsUiState(
val isSignedIn: Boolean = false,
val isPremium: Boolean = false,
val newsItems: List<NewsItemUiState> = listOf()
)
val NewsUiState.canBookmarkNews: Boolean get() = isSignedIn && isPremium
북마크 버튼의 가시성은 다른 두 프로퍼티에서 파생된 프로퍼티다. 비즈니스 로직이 복잡해지면 모든 프로퍼티를 즉시 사용할 수 있는 단일 UI State 클래스를 갖는것이 더 좋다.
- UI State 를 단일 스트림으로 관리할 것이냐 여러 개의 스트림으로 관리할 것이냐
스트림을 나누는데 고려해야 할 원칙은 항목간의 관계다. 단일 스트림의 최대 장점은 편의성과 데이터 일관성이다. UI State를 사용하는 뷰의 입장에서는 언제든 항상 최신의 정보를 확인할 수 있다. 하지만 여러 개의 스트림으로 관리하는 것도 필요할 때가 있다.
UI를 그리는데 필요한 상태는 완전히 독립적일 수 있다. 이 경우나 하나의 값이 다른 값들보다 자주 업데이트 되어야 하는 경우 이 값들을 하나로 묶는 데 드는 비용이 더 많이 들 수도 있다.
UI State는 하나의 필드만 바뀌어도 뷰에 변경된 값을 방출한다. 그리고 필드가 많으면 많을수록 업데이트도, 방출도 잦아진다. 뷰에서는 이렇게 방출되는 값이 같은 것인지 다른 것인지 알 수 없기 때문에 방출할 때마다 뷰에서는 업데이트가 일어난다. 이러한 경우 Flow
를 사용하거나 라이브 데이터에서 distinctUntilChanged()
메서드를 사용하여 해결할 수 있다.
5️⃣ UI State 사용하기
UI에서 UI State를 사용하기 위해 LiveData
인 경우 observe()
메서드를, Flow
인 경우 collect()
메서드를 사용해야 한다.
UI에서 UI State를 관찰할 때는 UI의 생명 주기를 고려해야 한다. 뷰가 유저에게 표시되지 않는경우 UI가 UI State를 관찰하는 것은 비효율적이며 해서도 안된다. (공식문서에서는 UI shouldn’t be observing the UI state when the view isn’t being displayed to the user 라고 나와있다. 그냥 절대 안된단다.)
LiveData
를 사용하는 경우 라이프사이클오너는 암시적으로 이 문제를 처리한다. 하지만 Flow
를 사용하는 경우는 조금 다르다.
class NewsActivity : AppCompatActivity() {
private val viewModel: NewsViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
...
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect {
// Update UI elements
}
}
}
}
}
Flow
를 사용하는 경우는 위처럼 처리해야 한다.
로딩 표시
가장 쉬운 방법은 UI State에 로딩 상태를 표시하는 boolean
값을 추가하는 것이다.
data class NewsUiState(
val isFetchingArticles: Boolean = false,
...
)
class NewsActivity : AppCompatActivity() {
private val viewModel: NewsViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
...
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
// Bind the visibility of the progressBar to the state
// of isFetchingArticles.
viewModel.uiState
.map { it.isFetchingArticles }
.distinctUntilChanged()
.collect { progressBar.isVisible = it }
}
}
}
}
그리고 이 값을 사용하여 UI에서 로딩바를 없앨 수 있다.
에러표시
에러를 표시하는 것과 로딩을 표시하는 것은 유사하게 해결할 수도 있다. 에러 역시도 단순히 boolean
값을 추가하면 되기 때문이다. 하지만 오류에는 사용자에게 전달할 메시지나 실패한 작업을 다시 시도하는 로직 등이 포함될 수 있기 때문에 에러를 데이터 클래스를 통해 모델링하는 것이 좋다.
예를 들어 기사를 가져오는동안 로딩바를 표시한 방법을 생각해보았을 때 만약 오류가 발생했을 때 어떤 오류가 발생했는지를 사용자에게 알려줘야 한다.
data class Message(val id: Long, val message: String)
data class NewsUiState(
val userMessages: List<Message> = listOf(),
...
)
이렇게 하면 오류 메시지를 토스트 혹은 스낵바의 형태로 사용자에게 보여줄 수 있다.
🚶♂️ 좀 더 나아가서
공식문서에서는 로딩이나 에러에 대한 처리를 모두 하나의 데이터 클래스 안에서 하고 있다. 이것 말고 다른 방법은 없을까? 하는 생각을 해서 좀 더 찾아보았고 좋은 글을 보게 되었다.
글을 보면서 정리한 내용인데 짧지 않다. 만약 궁금하다면, 약간의 시간을 더 투자할 수 있다면 아래를 펼쳐 읽어보는 것을 추천한다.
Android UI State Modeling 어떤게 좋을까?
여러 개의 State를 만드는 법부터 sealed class를 활용한 State Modeling 전략까지 다양한 방법이 이용된다.
UI 모델링을 처리하는 일반적인 방법 4가지를 보자.
- 여러 개의 State를 만들고 Loading, Error를 별도로 표시하기
- sealed class로 하나의 State를 만들고 상태 개수만큼 구현체 만들기
- 하나의 State data class를 만들어 관리하기
- 하나의 State data class와 Loading, Error를 별도로 만들기
1. 여러 개의 State를 만들고 Loading, Error를 별도로 표시하기
@HiltViewModel
class State1ViewModel @Inject constructor(
private val userRepository: UserRepository
) : ViewModel() {
private val _loading: MutableStateFlow<Boolean> = MutableStateFlow(false)
val loading: StateFlow<Boolean> = _loading.asStateFlow()
private val _error: MutableStateFlow<Boolean> = MutableStateFlow(false)
val error: StateFlow<Boolean> = _error.asStateFlow()
private val _name: MutableStateFlow<String> = MutableStateFlow("")
val name: StateFlow<String> = _name.asStateFlow()
private val _age: MutableStateFlow<String> = MutableStateFlow("")
val age: StateFlow<String> = _age.asStateFlow()
init {
viewModelScope.launch {
_loading.value = true
val result = userRepository.getUser()
result
.onSuccess { user ->
_loading.value = false
_error.value = false
_name.value = user.name
_age.value = user.age.toString()
}
.onFailure {
_loading.value = false
_error.value = true
}
}
}
}
@AndroidEntryPoint
class State1Activity : StateActivity() {
private val viewModel: State1ViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lifecycleScope.launch {
viewModel.name.collect { name ->
binding.name.text = name
}
}
lifecycleScope.launch {
viewModel.age.collect { age ->
binding.age.text = age
}
}
lifecycleScope.launch {
viewModel.loading.collect { loading ->
binding.loading.isVisible = loading
}
}
lifecycleScope.launch {
viewModel.error.collect { error ->
binding.error.isVisible = error
}
}
lifecycleScope.launch {
combine(viewModel.loading, viewModel.error) { loading, error -> !loading && !error }
.collect { isVisible ->
binding.card.isVisible = isVisible
}
}
}
}
장점
- 별도의 State를 만들었기 때문에 원하는 데이터만 변경하기 쉽다.
- 각각의 State가 다른 State에 영향을 주지 않는다.
단점
- State가 많아질 수 있기 때문에 헷갈릴 수 있다.
- Event를 통해 상태가 어떻게 변경될지 예측하기 어렵다.
- 액티비티에서 State를 관찰하는 코드가 굉장히 길어지고 복잡해진다.
언제 사용할까?
- Base 구조를 사용하지 않고 간단한 화면에 적합하다.
- State와 데이터바인딩을 1:1 구조로 사용하는 경우 적합하다.
2. sealed class로 하나의 State를 만들고 상태 개수만큼 구현체 만들기
@HiltViewModel
class State2ViewModel @Inject constructor(
private val userRepository: UserRepository
) : ViewModel() {
sealed class State {
data class Success(
val name: String,
val age: String
) : State()
object Empty : State()
object Failure : State()
object Loading : State()
}
private val _state: MutableStateFlow<State> = MutableStateFlow(State.Loading)
val state: StateFlow<State> = _state.asStateFlow()
init {
viewModelScope.launch {
_state.value = State.Loading
val result = userRepository.getUser()
result
.onSuccess { user ->
val (name, age) = (user.name to user.age.toString())
if (name.isEmpty() && age.isEmpty()) {
_state.value = State.Empty
} else {
_state.value = State.Success(user.name, user.age.toString())
}
}
.onFailure {
_state.value = State.Failure
}
}
}
}
@AndroidEntryPoint
class State2Activity : StateActivity() {
private val viewModel: State2ViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lifecycleScope.launch {
viewModel.state.collect { state ->
binding.loading.isVisible = state is State.Loading
binding.error.isVisible = state is State.Failure
binding.card.isVisible = state is State.Success
if (state is State.Success) {
binding.name.text = state.name
binding.age.text = state.age
}
}
}
}
}
장점
- UI에 대한 의도를 명확하게 표현할 수 있다.
- sealed class를 통해 좀 더 객체지향적인 처리가 가능하다.
- UI를 변경하는 코드가 분산되어 있지 않기 때문에 코드를 좀 더 쉽게 이해할 수 있다.
- 한 가지 상태만 가지기 때문에 상태가 섞이지 않는다.
단점
- 표현하고자 하는 상태를 모두 sealed class 안에 정의해주어야 하기 때문에 화면이 복잡해지면 상태가 많이 생겨난다.
- UI에서 필요로 하는 값들이 Success라는 데이터 클래스의 필드로 묶여있기 때문에 부분적인 업데이트가 불가하며 이전 상태를 별도로 보관하지 않는 한 이전 상태에 대한 데이터를 복구할 수 없다.
- 공통으로 쓰이는 상태를 표현하기가 어렵다.
언제 사용할까?
- 명확한 의도를 가지고 UI를 표현하는 경우
- 로딩이나 오류를 전체 상태로 취급할 수 있는 경우
- 부분적인 데이터 수정이나 이전의 상태가 필요없는 경우
3. 하나의 State data class를 만들어 관리하기
@HiltViewModel
class State3ViewModel @Inject constructor(
private val userRepository: UserRepository
) : ViewModel() {
data class State(
val loading: Boolean = true,
val error: Boolean = false,
val name: String = "",
val age: String = ""
)
private val _state: MutableStateFlow<State> = MutableStateFlow(State())
val state: StateFlow<State> = _state.asStateFlow()
init {
viewModelScope.launch {
val result = userRepository.getUser()
_state.update { state -> state.copy(loading = true) }
result
.onSuccess { user ->
_state.update { state ->
state.copy(
loading = false,
name = user.name,
age = user.age.toString()
)
}
}
.onFailure {
_state.update { state ->
state.copy(
loading = false,
error = true
)
}
}
}
}
}
@AndroidEntryPoint
class State3Activity : StateActivity() {
private val viewModel: State3ViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lifecycleScope.launch {
viewModel.state.collect { state ->
binding.loading.isVisible = state.loading
binding.error.isVisible = state.error
binding.card.isVisible = !state.loading
binding.name.text = state.name
binding.age.text = state.age
}
}
}
}
장점
- 하나의 데이터에 필요한 모든 상태를 포함할 수 있기 때문에 UI에 대한 비즈니스 로직 처리가 편하다.
- data class를 사용함으로써 copy를 통해 업데이트할 수 있다.
- LiveData나 StateFlow의
map
과distinctUntilChanged
메서드를 조합하여 원하는 형태로 데이터바인딩에 적용가능하다.
단점
- copy를 통한 업데이트에서 동시성 이슈가 발생할 수 있다.
- 화면이 복잡해지는 경우 data class에 필요한 프로퍼티가 늘어난다.
- data class 특성상 하나의 프로퍼티 값만 바뀌어도 구독하고 있는 모든 옵저버에 변경을 알리기 때문에 애니메이션같은 1회성 동작에 주의가 필요하다.
언제 사용할까?
- UI에 대한 부분적인 수정이 빈번하게 일어나는 경우
- UI 비즈니스 로직이 다소 복잡한 경우
- 상태를 조합하여 자주 사용하는 경우
- 로딩이나 오류도 상태에 포함하여 취급할 수 있는 경우
4. 하나의 State data class와 Loading, Error를 별도로 만들기
@HiltViewModel
class State4ViewModel @Inject constructor(
private val userRepository: UserRepository
) : ViewModel() {
data class State(
val name: String = "",
val age: String = ""
) {
val isEmpty: Boolean = name.isEmpty() && age.isEmpty()
}
private val _state: MutableStateFlow<State> = MutableStateFlow(State())
val state: StateFlow<State> = _state.asStateFlow()
private val _loading: MutableStateFlow<Boolean> = MutableStateFlow(false)
val loading: StateFlow<Boolean> = _loading.asStateFlow()
private val _error: MutableStateFlow<Boolean> = MutableStateFlow(false)
val error: StateFlow<Boolean> = _error.asStateFlow()
init {
viewModelScope.launch {
_loading.value = true
val result = userRepository.getUser()
result
.onSuccess { user ->
_loading.value = false
_state.update { state ->
state.copy(
name = user.name,
age = user.age.toString()
)
}
}
.onFailure {
_loading.value = false
_error.value = true
}
}
}
}
@AndroidEntryPoint
class State4Activity : StateActivity() {
private val viewModel: State4ViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lifecycleScope.launch {
viewModel.state.collect { state ->
binding.card.isVisible = !state.isEmpty
binding.name.text = state.name
binding.age.text = state.age
}
}
lifecycleScope.launch {
viewModel.error.collect { error ->
binding.error.isVisible = error
}
}
lifecycleScope.launch {
viewModel.loading.collect { loading ->
binding.loading.isVisible = loading
}
}
}
}
장점
- UI 상태가 로딩이나 오류에 의존적이지 않아 따로 처리가 가능하다.
- Base를 사용하는 경우 확장성 있는 구조를 제공한다.
단점
- UI 상태가 로딩이나 오류와 의존성이 있는 경우 처리가 다소 복잡하다.
- 로딩 상태를 여러 곳에서 변경할 수 있기 때문에 상태 변경에 주의가 필요하다.
언제 사용할까?
- UI 상태를 로딩과 오류를 나누어서 사용하는 경우
- Base에서 로딩과 오류를 일반적으로 처리하고 싶은 경우
마무리하며
공식문서를 쭉 읽어본 결과 처음에 기술했던 이유는 UI State를 도입하는데 큰 이유가 되지 않는다는 생각이 들었다.
UI State의 장점은 1) 상태의 일관성을 유지할 수 있다 2) 성공|실패|로딩 등의 상태를 사용자에게 더 쉽게 보여줄 수 있다 3) 예외나 에러가 발생했을 때 사용자에게 적절한 메시지를 보여줄 수 있다 4) 여러개의 데이터를 하나의 클래스 안에 캡슐화 할 수 있다 라고 생각한다. 이러한 이점을 누리기 위해 다양한 방법으로 UI State를 적용할 수 있을 것 같다.
그러면 어떤 방법으로 UI State를 적용하는 것이 좋을까? 앞에서 정리한 4가지의 방법을 봐도 어떤 방법이 가장 좋다! 라고 말하기엔 각 방법의 장단점이 존재한다. 그래서 꼭 이런 방법을 사용해야 해! 라기보다는 각자의 환경에서 어떤 방법이 가장 효율적일지 생각해보고 적용하면 좋을 것 같다.
모든 화면에 UI State를 적용해야할까? 라는 생각이 들었다.
참고
https://developer.android.com/jetpack/guide/ui-layer?hl=ko
https://developer.android.com/topic/architecture/ui-layer#case-study
https://medium.com/@laco2951/android-ui-state-modeling-어떤게-좋을까-7b6232543f25