Hanbit the Developer

UI State Flow 이슈 해결 본문

Mobile/Android

UI State Flow 이슈 해결

hanbikan 2024. 1. 25. 22:42

문제 상황

로딩 화면에서 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

다음과 같은 순서로 작업이 진행되어 문제가 발생한 것이 아닌가 의심이 된다:

  1. uiState = Loading으로 초기화
  2. taskList.collectLatest 호출
  3. taskList가 초기값인 listOf()로 초기화 → uiState = Success.Empty
  4. 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)]

작업 순서를 유추해보면 다음과 같다:

  1. taskList.collectLatest()
  2. uiState = Loading
  3. (첫번째 로그)
  4. ???
  5. taskList.collectLatest 트리거
  6. (두번째 로그)
  7. getAllTasksByUserIdUseCase()가 작업을 마침
  8. taskList.collectLatest 트리거
  9. (세번째 로그)

여기서 내가 원하는 것은 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 처리가 복잡해질수록 로직이 더러워지면서 이슈가 발생하기 쉬워지는 것 같다. 이런 로직은 최대한 신경써서 직관적이고 깔끔하게 유지해야겠다..