[안드로이드] LiveData 를 Flow 로 Refactoring 하기

2023. 9. 20. 17:44· Android
목차
  1.  
  2. 화면 소개
  3. UiState LiveData to StateFlow
  4. ViewModel Test with LiveData
  5. ViewModel Test with StateFlow
  6. Event 처리 LiveData to SharedFlow
  7. ViewModelTest with SharedFlow

Festago 를 개발하면서 ViewModel 의 UiState 및 Event 를 감지하기 위해 LiveData 를 사용했다.

이상적으로 ViewModel 은 Android 를 알아선 안된다. 테스트 가능성, 메모리 누수 안전성, 모듈성을 향상시킨다.

참고: AndroidDevelopers 블로그

https://medium.com/androiddevelopers/viewmodels-and-livedata-patterns-antipatterns-21efaef74a54

 

 

이를 이유로 안드로이드 의존성을 갖는 LiveData 를 사용하던 기존 코드들을 Kotlin 의존성을 갖는 Flow 로 Migration 해보기로 했다.

 

StateFlow 와 SharedFlow 에 대한 이론적인 글은 다음 글을 참고해주세요.

https://seonghoonc.tistory.com/30

 

화면 소개

Migration 할 학교 인증(StudentVerification)을 하는 화면이다.

 

 

  1. Activity 를 시작하면 학교 ID 로 학교 이메일을 받아온다
  2. 인증 번호 확인에 성공, 실패, 타임아웃 등 이벤트를 발생시킨다.

UiState LiveData to StateFlow

sealed interface StudentVerificationUiState {
    // 로딩 상태
    object Loading : StudentVerificationUiState

    // 성공 상태
    data class Success(
        val schoolEmail: String,
        val remainTime: Int,
        val isValidateCode: Boolean = false,
    ) : StudentVerificationUiState

    // 에러 상태
    object Error : StudentVerificationUiState

    ...
}

uiState 는 위와 같이 sealed interface 로 정의된다. 로딩, 성공, 실패 상태가 있으며 그에 따라 각각 다른 화면을 보여준다.

 

class StudentsVerificationViewModel(
    private val schoolRepository: SchoolRepository,
    ...
) : ViewModel() {

    // 로딩 상태로 초기화
    private val _uiState = MutableLiveData<StudentVerificationUiState>(StudentVerificationUiState.Loading)
    val uiState: LiveData<StudentVerificationUiState> = _uiState

    fun loadSchoolEmail(schoolId: Int) {
        ...
        viewModelScope.launch {
             schoolRepository.loadSchoolEmail(schoolId)
             // 성공하면 성공 상태로 set
             .onSuccess { email ->
                 _uiState.value = StudentVerificationUiState.Success(
                      schoolEmail = email,
                      remainTime = MIN_REMAIN_TIME,
                 )
            }
            // 실패하면 에러 상태로 set
            .onFailure {
                _uiState.value = StudentVerificationUiState.Error
            	    ...
                }
            }
       }

 

 

이때 ViewModel 의 loadSchoolEmail() 을 호출하면 repository 로 학교 이메일을 요청하고 결과에 따라UiState 를 업데이트한다.

 

이를 StateFlow 로 바꾸면?

 

class StudentsVerificationViewModel(
    private val schoolRepository: SchoolRepository,
    ...
 ) : ViewModel() {
     // 로딩 상태로 초기화 LiveData 와 달리 초기값이 필수다.
     private val _uiState = MutableStateFlow<StudentVerificationUiState>(StudentVerificationUiState.Loading)
     val uiState: StateFlow<StudentVerificationUiState> = _uiState.asStateFlow()

ViewModel 에서 코드는 거의 비슷하다! StateFlow 는 초기 값을 필수로 지정해줘야 한다. 그것 말고는 별 다른게 없다.

 

Activity 에서는 다음과 같이 변경해야한다.

  1. observe 를 collect 로 변경한다.
  2. Lifecycle 변경을 감지할 수 있도록 처리한다.
lifecycleScope.launch {
    repeatOnLifecycle(Lifecycle.State.STARTED) {
         viewModel.uiState.collect { 
             uiState -> handleUiState(uiState) 
         }
    }
}

stateFlow 는 LiveData 와 달리 알아서 LifeCycle 을 감지하지 못한다.

 

홈 화면을 누르거나 다른 액티비티에 가리는 등 생명주기가 Stopped 상태일 때 계속해서 collect 하고 있지 않도록 repeatOnLifecycle 을 사용해 처리했다.

 

이 과정에서 ViewModel 테스트는 어떻게 되었을까?

 

 

ViewModel Test with LiveData

기존 LiveData 사용 시 테스트다. 테스트에 관한 자세한 얘기는 생략하겠다.

@get:Rule
val instantExecutorRule = InstantTaskExecutorRule()

@Test
fun `이메일 불러오기에 실패하면 실패 상태가 된다`() {
    // given
    `이메일 요청 결과가 다음과 같을 때`(Result.failure(Exception()))

    // when
    viewModel.loadSchoolEmail(schoolId)

    // then 
    assertThat(viewModel.uiState.value).isExactlyInstanceOf(StudentVerificationUiState.Error::class.java)
}

 

ViewModel 의 uiState 의 value 가져와 검증한다.

안드로이드 의존성을 갖는 LiveData 를 ViewModel 에서 사용하면 ViewModel 을 단위 테스트하기 위해 Rule을 추가해줘야 한다.

 

ViewModel Test with StateFlow

테스트를 짜다보면 리팩터링 할 때 기존 테스트코드에 빨간줄이 생기는 경험을 하게된다. 하지만 이 경우엔 없다!

value 로 뽑아서 가져오는 방식이 LiveData 와 똑같기 때문이다.

 

더 이상 사용하지 않는 Rule 만 제거해주면 된다.

 

Event 처리 LiveData to SharedFlow

Event Wrapping class 를 사용한 커스텀 SingleLiveData 를 사용하고 있었다.

그 이유도 다음 글로 확인하길 바란다.

 

https://seonghoonc.tistory.com/30#SharedFlow

private val _event = MutableSingleLiveData<StudentVerificationEvent>()
val event: SingleLiveData<StudentVerificationEvent> = _event

fun confirmVerificationCode() {
    viewModelScope.launch {
        ...
            studentVerificationRepository.requestVerificationCodeConfirm(...)
                .onSuccess {
                _event.setValue(StudentVerificationEvent.VerificationSuccess)
              }.onFailure {
               _event.setValue(StudentVerificationEvent.VerificationFailure)
             }
        }

발생시키고 싶은 Event 가 있다면 MutableSingleLiveData 의 value 를 set 하면 된다.

private val _event = MutableSharedFlow<StudentVerificationEvent>()
val event: SharedFlow<StudentVerificationEvent> = _event.asSharedFlow()

fun confirmVerificationCode() {
    viewModelScope.launch {
        ...
        studentVerificationRepository.requestVerificationCodeConfirm(...)
        .onSuccess {
            _event.emit(StudentVerificationEvent.VerificationSuccess)
        }.onFailure {
            _event.emit(StudentVerificationEvent.VerificationFailure)
        }
   }

하지만 sharedFlow 는 value 를 사용하지 않는다. 발생시키고 싶은 Event 가 있다면 emit 을 해주면 된다.

lifecycleScope.launch {
    repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.event.collect { event ->
                handleEvent(event)
            }
        }
    }

event 도 마찬가지로 Lifecycle 에 맞춰 동작하기 위해 위와 같은 이유로 repeatOnLifecycle 을 적용했다.

 

여기까진 아무 문제가 없었다. 하지만 테스트에서 몇가지 문제가 발생했다.

 

ViewModelTest with SharedFlow

문제점 1. sharedFlow 는 getValue() 로 값을 얻어올 수 없다.

sharedFlow 는 LiveData 혹은 StateFlow 와 다르게 value 를 가져올 수 없다. 그렇기 때문에 방출되는 값을 반환받을 방법이 필요했다.

해결 방법 : Flow 의 first() 확장함수 사용

 

 

first 의 내부 구조를 보면 알 수 있듯 이 함수는 방출되는 가장 첫번째 값을 반환하며 일정 시간동안 방출되지 않으면 NoSuchElementException() 을 발생시킨다.

@Test
fun `... 인증 번호 확인이 성공하면 인증 성공 이벤트가 발생한다`() = runTest {
    // given
    ...

    // when
    viewModel.confirmVerificationCode()

    // then
    assertThat(vm.event.first()).isEqualTo(StudentVerificationEvent.VerificationSuccess)
}

flow 는 코루틴 위에서 동작한다. 따라서 suspend function 인 first() 를 사용하려면 runTest 를 사용해 TestScope 를 열어줘야 한다.

하지만 이것은 곧바로 다음 문제를 직면한다.

 

문제점 2. sharedFlow 가 값을 방출할 때 까지 기다리지 않는다.

이전의 코드는 처음으로 방출된 event 를 받아오지만 viewModel.confirmVerificationCode() 와 vm.event.first() 가 동기적으로 실행된다.

 

 

방출된 이후에 값을 기다리게 되며 기다리기만 하다가 결국 예외가 발생한다.

 

값이 들어올 때 까지 기다리게 하기 위해서 async scope 를 열고 값이 반환된 후에 assertThat 검증이 실행되도록 변경해주어야 한다.

@Test
fun `given ... 인증 번호 확인이 성공하면 인증 성공 이벤트가 발생한다`() = runTest(UnConfinedTestDispather()) {
    // given
    ...

    // sharedFlow Event 기다리기
    val deferredEvent = async {
        vm.event.first()
    }

    // when
    `인증 번호를 확인하면`()

    // then
    assertThat(deferredEvent.await()).isEqualTo(StudentVerificationEvent.VerificationSuccess)
}

이렇게 하면 처음으로 방출되는 이벤트가 반환될 때까지 기다렸다가 검증하여 테스트가 정상적으로 작동한다.

이때 주의할 점은 각 코드의 즉각적인 실행을 위해 UnConfinedTestDispatcher 를 사용해야하고 예상 가능한 결과를 테스트할 수 있다. 

 

테스트의 정상 작동은 확인했지만 그래도 아직 마음에 들지 않는다.

 

 

문제점3. 테스트는 편리해야 한다.

 

매번 deferred type 으로 기다리게 하고 await 으로 값을 체크하는 것은 용납할 수 없는 불편함이다.

 

그러다 찾은 것이 바로 turbine 이다.

 

turbine 은 Flow Test third-party 라이브러리로 공식문서에서 편리한 Flow 테스트를 위해 사용해라고 소개되어있다.

 

안드로이드 공식문서 Flow Test

https://developer.android.com/kotlin/flow/test

 

Turbine Github Repository

https://github.com/cashapp/turbine


이 turbine 라이브러리를 사용하면 turbine 스코프를 열 수 있는데 이를 사용하면 훨씬 간단하게 위의 테스트를 진행할 수 있다.

@Test
fun `... 인증 번호 확인이 성공하면 인증 성공 이벤트가 발생한다`() = runTest {
    // given
    ...
    vm.event.test { // turbineScope
        // when
        viewModel.confirmVerificationCode()

        // then
        assertThat(awaitItem()).isEqualTo(StudentVerificationEvent.VerificationSuccess)
        cancelAndIgnoreRemainingEvents()
    }
}

test turbine 스코프 내에서 awaitItem() 을 사용할 때마다 방출되는 값을 받을 수 있고 cancelAndIgnoreRemainingEvents() 를 사용해 남은 이벤트를 받지 않고 코루틴 스코프를 cancel 할 수 있다.

 

아무 이벤트도 일어나지 않음을 테스트하려면 expectNoEvents() 를 사용하면 된다.

 

Turbine 공식 문서 내용

 

Turbine 도 UnconfinedTestDispatcher 에 의존하고 있다. 이는 테스트를 더 쉽게 만들지만 이를 인지하고 사용해야 한다.

 

다음은 학습을 위해 작성한 테스트 코드이다.

class SharedFlowTest {

    private val _sharedFlow = MutableSharedFlow<Int>(replay = 0)
    val sharedFlow = _sharedFlow.asSharedFlow()

    // 실패
    @Test
    fun `test sharedFlow in StandardTestDispatcher`() = runTest {
        // when
        _sharedFlow.emit(1)

         // then
        assertThat(sharedFlow.first()).isEqualTo(1)
    }

    // 실패
    @OptIn(ExperimentalCoroutinesApi::class)
    @Test
    fun `test sharedFlow in UnconfinedTestDispatcher`() = runTest(UnconfinedTestDispatcher()) {
        // when
        _sharedFlow.emit(1)

        // then
        assertThat(sharedFlow.first()).isEqualTo(1)
    }

    // 실패
    @Test
    fun `test sharedFlow with async in StandardTestDispatcher`() = runTest {
        val deferred = async {
            sharedFlow.first()
        }
        // when
        _sharedFlow.emit(1)

        // then
        assertThat(deferred.await()).isEqualTo(1)
    }

    // 성공
    @OptIn(ExperimentalCoroutinesApi::class)
    @Test
    fun `test sharedFlow with async in UnconfinedTestDispatcher`() = runTest(UnconfinedTestDispatcher()) {
        val deferred = async {
            sharedFlow.first()
        }
        // when
        _sharedFlow.emit(1)

        // then
        assertThat(deferred.await()).isEqualTo(1)
    }

    // 성공
    @Test
    fun `test sharedFlow with turbine in StandardTestDispatcher`() = runTest {
         sharedFlow.test {
            // when
            _sharedFlow.emit(1)

            // then
            assertThat(awaitItem()).isEqualTo(1)
        }
    }
    
    // 성공
    @OptIn(ExperimentalCoroutinesApi::class)
    @Test
    fun `test sharedFlow with turbine in UnconfinedTestDispatcher`() = runTest(UnconfinedTestDispatcher()) {
        sharedFlow.test {
            // when
            _sharedFlow.emit(1)

            // then
            assertThat(awaitItem()).isEqualTo(1)
         }
     }

 

이를 돌려보고 결과를 확인하면 더 쉽게 이해할 수 있을 것이다.

'Android' 카테고리의 다른 글

[안드로이드] Repository Pattern (저장소 패턴)  (0) 2023.10.26
뷰가 그려지는 과정(View Lifecycle) 이해하기 : [우아한테크코스 5기 AN_베르]  (0) 2023.10.22
Reflection [우아한테크코스 5기 AN_베르]  (0) 2023.09.10
[안드로이드] 테스트 라이브러리 Robolectric  (0) 2023.09.03
[안드로이드] Flow 공식문서로 이해하기 - 2차 StateFlow & SharedFlow  (0) 2023.09.03
  1.  
  2. 화면 소개
  3. UiState LiveData to StateFlow
  4. ViewModel Test with LiveData
  5. ViewModel Test with StateFlow
  6. Event 처리 LiveData to SharedFlow
  7. ViewModelTest with SharedFlow
'Android' 카테고리의 다른 글
  • [안드로이드] Repository Pattern (저장소 패턴)
  • 뷰가 그려지는 과정(View Lifecycle) 이해하기 : [우아한테크코스 5기 AN_베르]
  • Reflection [우아한테크코스 5기 AN_베르]
  • [안드로이드] 테스트 라이브러리 Robolectric
베르_최성훈
베르_최성훈
베르_최성훈
베르의 안드로이드
베르_최성훈
전체
오늘
어제
  • 분류 전체보기 (57)
    • 우아한테크코스 (15)
    • Android (19)
    • kotlin (2)
    • 회고 (5)
    • Bug Fix (2)
    • 알고리즘 (6)
    • 기타 (1)
    • Jetpack Compose (6)

블로그 메뉴

  • 홈
  • 태그
  • 방명록

공지사항

인기 글

태그

  • 우아한테크코스
  • ViewModel
  • Test
  • MVVM
  • Android
  • FRAGMENT
  • 생명주기
  • 안드로이드
  • MVP
  • Lifecycle
  • coroutine
  • 페스타고
  • flow
  • 코틀린
  • Kotlin
  • SharedFlow
  • 프래그먼트
  • 우테코
  • 회고
  • PRESENTER

최근 댓글

최근 글

hELLO · Designed By 정상우.v4.2.1
베르_최성훈
[안드로이드] LiveData 를 Flow 로 Refactoring 하기
상단으로

티스토리툴바

단축키

내 블로그

내 블로그 - 관리자 홈 전환
Q
Q
새 글 쓰기
W
W

블로그 게시글

글 수정 (권한 있는 경우)
E
E
댓글 영역으로 이동
C
C

모든 영역

이 페이지의 URL 복사
S
S
맨 위로 이동
T
T
티스토리 홈 이동
H
H
단축키 안내
Shift + /
⇧ + /

* 단축키는 한글/영문 대소문자로 이용 가능하며, 티스토리 기본 도메인에서만 동작합니다.