Hanbit the Developer
UI State Flow 이슈 해결 본문
문제 상황
로딩 화면에서 Success.NotEmpty 상태가 되어야 하는데, 중간에 Success.Empty 화면이 아주 짧게 보이는 문제가 발생하였다.
기존 코드
@HiltViewModel
class TodoViewModel @Inject constructor(
// ...
) : ViewModel() {
private val _uiState: MutableStateFlow<TodoUiState> = MutableStateFlow(TodoUiState.Loading)
val uiState = _uiState.asStateFlow()
private val activeUserId: StateFlow<Int?> = getActiveUserIdUseCase()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null)
@OptIn(ExperimentalCoroutinesApi::class)
val taskList: StateFlow<List<Task>> = activeUserId
.flatMapLatest { getAllTasksByUserIdUseCase(it) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), listOf())
init {
viewModelScope.launch(Dispatchers.IO) {
taskList.collectLatest {
updateSuccessUiState()
}
}
}
private fun updateSuccessUiState() {
_uiState.value =
if (taskList.value.isEmpty()) TodoUiState.Success.Empty else TodoUiState.Success.NotEmpty
}
}
sealed interface TodoUiState {
object Loading : TodoUiState
sealed interface Success : TodoUiState {
object Empty : Success
object NotEmpty : Success
}
}
문제 분석
예상 원인 1
uiState에 대해 로그를 찍어보니 다음과 같은 결과가 나왔다(uiState.collect):
com.hanbikan.nook.feature.todo.TodoUiState$Loading@46ad2e3
com.hanbikan.nook.feature.todo.TodoUiState$Success$Empty@2e49dc3
com.hanbikan.nook.feature.todo.TodoUiState$Success$NotEmpty@a46c10
다음과 같은 순서로 작업이 진행되어 문제가 발생한 것이 아닌가 의심이 된다:
- uiState = Loading으로 초기화
- taskList.collectLatest 호출
- taskList가 초기값인 listOf()로 초기화 → uiState = Success.Empty
- getAllTasksByUserIdUseCase()가 작업을 마침 → uiState = Success.NotEmpty
즉, 2번과 3번의 순서가 서로 뒤바뀌어야 하는데 flow 함수로 인해 taskList 초기화가 지연된 것이 문제 원인으로 예상된다.
하지만 로그를 찍어보니 초기화 순서는 올바랐다.
예상 원인 2
uiState와 taskList에 대해 로그를 찍어보니 다음과 같았다(uiState.collect):
uiState = com.hanbikan.nook.feature.todo.TodoUiState$Loading@46ad2e3
taskList = []
----------------------------------------------------------------
uiState = com.hanbikan.nook.feature.todo.TodoUiState$Success$Empty@2e49dc3
taskList = []
----------------------------------------------------------------
uiState = com.hanbikan.nook.feature.todo.TodoUiState$Success$NotEmpty@f5959a4
taskList = [Task(id=2, userId=1, name=Rach, isDone=false)]
작업 순서를 유추해보면 다음과 같다:
- taskList.collectLatest()
- uiState = Loading
- (첫번째 로그)
- ???
- taskList.collectLatest 트리거
- (두번째 로그)
- getAllTasksByUserIdUseCase()가 작업을 마침
- taskList.collectLatest 트리거
- (세번째 로그)
여기서 내가 원하는 것은 4~6번이 제거되는 것이다.
5번이 트리거 되는 이유(4번)를 알아내기 위해 연관되어 있는 변수인 activeUserId를 로깅해보았다. 이를 통해 알아낸 점은, activeUserId가 최초에 null로 초기화 되고 이후에 1과 같은 정수로 변경되는데, 값이 null일 때 유즈케이스를 실행된다는 점이다.
Solution 1
그럼 다음과 같이 drop(1)으로 최초 연산을 버리면 어떻게 될까?
viewModelScope.launch(Dispatchers.IO) {
taskList.drop(1).collectLatest {
updateSuccessUiState()
}
}
로그 결과는 다음과 같다:
uiState = com.hanbikan.nook.feature.todo.TodoUiState$Loading@46ad2e3
taskList = []
----------------------------------------------------------------
uiState = com.hanbikan.nook.feature.todo.TodoUiState$Success$NotEmpty@acebe5f
taskList = [Task(id=2, userId=1, name=Rach, isDone=false)]
하지만 문제가 있다. getAllTasksByUserIdUseCase()의 결과가 빈 리스트이면 collectLatest가 트리거 되지 않는다는 것이다. 게다가 drop(1)로 최초 연산을 버리는 것은 로직이 깔끔하지 않고 직관적이지 않다.
Solution 2
더 디테일한 핸들링을 위해, collectLatest를 쓰는 대신 아래 코드처럼 분기를 나누고 onEach를 사용해보았다:
@OptIn(ExperimentalCoroutinesApi::class)
val taskList: StateFlow<List<Task>> = activeUserId
.flatMapLatest {
if (it == null) {
flowOf(listOf())
} else {
getAllTasksByUserIdUseCase(it).onEach { taskList ->
_uiState.value =
if (taskList.isEmpty()) TodoUiState.Success.Empty else TodoUiState.Success.NotEmpty
}
}
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), listOf())
즉 collect를 하되, 최초에 activeUserId가 null일 때를 걸러주는 것이다.
이렇게 했을 때 getAllTasksByUserIdUseCase()가 반환하는 리스트가 비었을 때와 그렇지 않을 때 모두 정상 작동한다.
마치며
flow 처리가 복잡해질수록 로직이 더러워지면서 이슈가 발생하기 쉬워지는 것 같다. 이런 로직은 최대한 신경써서 직관적이고 깔끔하게 유지해야겠다..
'Android' 카테고리의 다른 글
[Compose] SwipeToAction 구현(AnchoredDraggableState, Layout) (0) | 2024.02.16 |
---|---|
Android Dependent Use Case 이슈 해결 (1) | 2024.02.05 |
Android Document | Processes and app lifecycle (3) | 2024.01.25 |
Android Document | App startup time (1) | 2024.01.25 |
Room Migration 방법 (0) | 2024.01.17 |