본문 바로가기
Android/번역

(번역) Migrating from LiveData to Kotlin's Flow

by jaesungLeee 2022. 8. 31.
이 게시물은 Android Developers Medium에 작성되어있는 Migrating from LiveData to Kotlin's Flow를 번역 및 의역하여 정리한 게시물입니다. 잘못된 정보는 댓글로 첨언해주시면 감사하겠습니다.
 

Migrating from LiveData to Kotlin’s Flow

In this post you’ll learn how to expose Flows to a view, how to collect them, and how to fine-tune it to fit specific needs.

medium.com

 

LiveData에서 Kotlin Flow로 이전

LiveData는 2017년에 우리에게 필요한 것이었습니다. (Google I/O `17에서의 AAC 공개)

옵저버 패턴은 우리의 삶을 더 쉽게 만들었지만, RxJava와 같은 옵션은 초보자에게는 복잡했습니다.

Architecture Component 팀은 Android 용으로 설계된 매우 독단적으로 관찰 가능한 데이터 홀더 클래스인 LiveData를 만들었습니다.

LiveData는 사용하기 쉽도록 단순하게 유지했으며, 둘 사이의 통합을 활용하여 복잡한 반응형 스트림의 경우 RxJava를 사용하는 것이 좋습니다.

 

Dead Data?

LiveData는 여전히 Java 개발자 또는 초보자 및 간단한 상황을 위한 좋은 솔루션입니다.

하지만, 다른 경우에는 Kotlin Flow로 마이그레이션하는 것이 좋습니다.

Flow는 여전히 가파른 학습 곡선을 가지고 있지만, Jetbrains에서 지원하는 Kotlin 언어의 일부입니다.

또한, 반응형 모델과 잘 어울리는 Compose가 출시됩니다.

 

우리는 View와 ViewModel을 제외하고 앱의 다른 부분을 연결하기 위해 한동안 Flow를 사용하는 것에 대해 이야기 했습니다.

이제 Android UI에서 Flow를 수집하는 더 안전한 방법이 있으므로 LiveData에서 Flow로 마이그레이션하는 완전한 가이드를 만들 수 있게 되었습니다.

 

이 포스트에서는 Flow를 View에 노출하는 방법, Flow를 수집하는 방법, 특정 요구사항에 맞게 조정하는 방법에 대해 소개합니다.

 

Flow : 간단한 것은 어렵고 복잡한 것은 쉽다

LiveData는 한 가지 일을 잘 수행했습니다.

최신의 값을 캐싱하고 Android의 수명주기를 이해하면서 데이터를 노출했습니다.

이후에 우리는 코루틴을 시작하고 복잡한 변환을 생성할 수도 있다는 것을 배웠지만 이것은 조금 더 복잡했습니다.

 

일부 LiveData 패턴과 이에 상응하는 Flow를 살펴보겠습니다.

 

#1: Mutable 데이터 홀더로 one-shot 작업 결과 노출

아래는 코루틴의 결과로 State Holde 보유자를 변경하는 고전적인 패턴입니다.

Mutable LiveData로 One-shot 작업의 결과를 노출하는 방법

 

class MyViewModel {
    private val _myUiState = MutableLiveData<Result<UiState>>(Result.Loading)
    val myUiState: LiveData<Result<UiState>> = _myUiState

    // Load data from a suspend fun and mutate state
    init {
        viewModelScope.launch { 
            val result = ...
            _myUiState.value = result
        }
    }
}

 

같은 작업을 Flow에서도 사용하기 위해 StateFlow를 사용합니다.

Mutable StateFlow로 One-shot 작업의 결과를 노출하는 방법

 

class MyViewModel {
    private val _myUiState = MutableStateFlow<Result<UiState>>(Result.Loading)
    val myUiState: StateFlow<Result<UiState>> = _myUiState

    // Load data from a suspend fun and mutate state
    init {
        viewModelScope.launch { 
            val result = ...
            _myUiState.value = result
        }
    }
}

 

StateFlow는 LiveData와 가장 유사한 특별한 종류의 SharedFlow (특수 유형의 Flow)입니다.

 

  • StateFlow는 항상 값을 가집니다.
  • StateFlow는 오직 한가지의 값만 가집니다.
  • StateFlow는 여러 Observer를 지원합니다. 그렇기 때문에 Flow는 Shared됩니다.
  • Active 상태의 Observer 수와 관계없이 항상 구독하는 최신의 값을 사용합니다.
UI의 상태를 View에 노출할 때는 StateFlow를 사용하세요. UI 상태를 유지하도록 설계된 안전하고 효율적인 Observer입니다.

 

#2: one-shot 작업의 결과 노출

이전 스니펫과 유사하지만 변경가능한 Backing Property 없이 코루틴 호출의 결과를 노출합니다.

 

우리는 LiveData와 함께 아래 상황을 위해 liveData 코루틴 빌더를 사용했습니다.

LiveData에서 One-shot 작업 결과 노출하는 방법

 

class MyViewModel(...) : ViewModel() {
    val result: LiveData<Result<UiState>> = liveData {
        emit(Result.Loading)
        emit(repository.fetchItem())
    }
}

 

State Holder는 항상 값을 가지기 때문에 UI 상태를 Loading, SuccessError와 같은 상태를 지원하는 일종의 Result 클래스로 Wrapping하는 것이 좋습니다.

 

Flow의 경우, 몇가지 구성을 수행해야 하기 위해 조금 더 복잡합니다.

StateFlow에서 One-shot 작업 결과 노출하는 방법

 

class MyViewModel(...) : ViewModel() {
    val result: StateFlow<Result<UiState>> = flow {
        emit(repository.fetchItem())
    }.stateIn(
        scope = viewModelScope, 
        started = WhileSubscribed(5000), // Or Lazily because it's a one-shot
        initialValue = Result.Loading
    )
}

 

stateIn은 Flow를 StateFlow로 변환하는 Flow 연산자 입니다.

파라미터에 대한 설명을 하려면 복잡해지므로 일단 이러한 파라미터를 신뢰합시다.

 

#3: 파라미터가 포함된 one-shot 데이터 로드

사용자 ID에 의존하는 일부 데이터를 로드하고 이 정보를 Flow를 노출하는 AuthManager 클래스에서 얻는 상황을 가정해 보겠습니다.

LiveData에서 파라미터를 이용한 One-shot 데이터를 로딩하는 방법

 

LiveData를 사용하면 아래처럼 유사한 작업을 수행할 수 있습니다.

 

class MyViewModel(authManager..., repository...) : ViewModel() {
    private val userId: LiveData<String?> = 
        authManager.observeUser().map { user -> user.id }.asLiveData()

    val result: LiveData<Result<Item>> = userId.switchMap { newUserId ->
        liveData { emit(repository.fetchItem(newUserId)) }
    }
}

 

switchMapuserId가 변경될 때 블록이 실행되고 결과가 구독되는 변환입니다.

 

만약, userId가 LiveData일 이유가 없다면 스트림을 Flow와 결합하고 최종적으로 노출된 결과를 LiveData로 변환하는 것이 더 나은 대안입니다.

 

class MyViewModel(authManager..., repository...) : ViewModel() {
    private val userId: Flow<UserId> = authManager.observeUser().map { user -> user.id }

    val result: LiveData<Result<Item>> = userId.mapLatest { newUserId ->
       repository.fetchItem(newUserId)
    }.asLiveData()
}

 

Flow를 사용하여 이 작업을 수행하는 것은 이전 방법과 매우 유사합니다.

StateFlow에서 파라미터를 이용한 One-shot 데이터를 로딩하는 방법

 

class MyViewModel(authManager..., repository...) : ViewModel() {
    private val userId: Flow<UserId> = authManager.observeUser().map { user -> user.id }

    val result: StateFlow<Result<Item>> = userId.mapLatest { newUserId ->
        repository.fetchItem(newUserId)
    }.stateIn(
        scope = viewModelScope, 
        started = WhileSubscribed(5000), 
        initialValue = Result.Loading
    )
}

 

더 많은 유연성이 필요한 경우 transformLatest를 사용하여 명시적으로 아이템들을 emit할 수 있습니다.

 

val result = userId.transformLatest { newUserId ->
    emit(Result.LoadingData)
    emit(repository.fetchItem(newUserId))
}.stateIn(
    scope = viewModelScope, 
    started = WhileSubscribed(5000), 
    initialValue = Result.LoadingUser // Note the different Loading states
)

 

#4: 파라미터를 사용하여 데이터 스트림 관찰

이제 예제를 더 반응적으로 만들어보겠습니다.

데이터를 가져오는 것이 아니라 Observe하므로 데이터 소스의 변경사항을 UI에 자동으로 전파합니다.

 

예제를 계속 진행하면 데이터 소스에서 fetchItem을 호출하는 대신 Flow를 반환하는 가상의 observeItem 연산자를 사용합니다.

 

LiveData를 사용하면 Flow를 LiveData로 변환하고 모든 업데이트를 emitSource를 사용하여 내보낼 수 있습니다.

파라미터가 있는 스트림 관찰 (LiveData)

 

class MyViewModel(authManager..., repository...) : ViewModel() {
    private val userId: LiveData<String?> = 
        authManager.observeUser().map { user -> user.id }.asLiveData()

    val result = userId.switchMap { newUserId ->
        repository.observeItem(newUserId).asLiveData()
    }
}

 

또는, 선택적으로 flatMapLatest를 사용하여 두 Flow를 결합하고 출력만 LiveData로 변환할 수도 있습니다.

 

class MyViewModel(authManager..., repository...) : ViewModel() {
    private val userId: Flow<String?> = 
        authManager.observeUser().map { user -> user?.id }

    val result: LiveData<Result<Item>> = userId.flatMapLatest { newUserId ->
        repository.observeItem(newUserId)
    }.asLiveData()
}

 

Flow 구현은 비슷하지만 LiveData 변환은 없습니다.

파라미터가 있는 스트림 관찰 (StateFlow)

 

class MyViewModel(authManager..., repository...) : ViewModel() {
    private val userId: Flow<String?> = 
        authManager.observeUser().map { user -> user?.id }

    val result: StateFlow<Result<Item>> = userId.flatMapLatest { newUserId ->
        repository.observeItem(newUserId)
    }.stateIn(
        scope = viewModelScope, 
        started = WhileSubscribed(5000), 
        initialValue = Result.LoadingUser
    )
}

 

노출된 StateFlow는 사용자가 변경되거나 Repository의 사용자 데이터가 변경될 때마다 업데이트를 수신합니다.

 

#5: 여러 소스와 결합: MediatorLiveData -> Flow.Combine

MediatorLiveData를 사용하면 하나 이상의 업데이트 소스 (LiveData Observables)를 관찰하고 새로운 데이터를 얻을 때 작업을 수행할 수 있습니다.

일반적으로 MediatorLiveData의 값을 업데이트합니다.

 

val liveData1: LiveData<Int> = ...
val liveData2: LiveData<Int> = ...

val result = MediatorLiveData<Int>()

result.addSource(liveData1) { value ->
    result.setValue(liveData1.value ?: 0 + (liveData2.value ?: 0))
}
result.addSource(liveData2) { value ->
    result.setValue(liveData1.value ?: 0 + (liveData2.value ?: 0))
}

 

Flow는 훨씬 더 간단합니다.

 

val flow1: Flow<Int> = ...
val flow2: Flow<Int> = ...

val result = combine(flow1, flow2) { a, b -> a + b }

 

combineTransform 이나 zip을 사용할 수도 있습니다.

 

노출된 StateFlow 구성(stateIn 연산자)

이전에 stateIn을 사용하여 일반 Flow를 StateFlow로 변환했지만 일부 구성이 필요합니다.

지금 자세히 알고싶지 않고, 복사 붙여넣기만 하면 되는 경우라면 이 조합을 추천합니다.

 

val result: StateFlow<Result<UiState>> = someFlow
    .stateIn(
        scope = viewModelScope, 
        started = WhileSubscribed(5000), 
        initialValue = Result.Loading
    )

 

하지만, 겉보기에 무작위로 보이는 5초 started 변수가 확실하지 않은 경우 계속 읽으십시오.

 

문서에 의하면, stateIn에는 3개의 파라미터가 있습니다.

@param scope 공유가 시작되는 코루틴 범위입니다.
@param started 공유 시작 및 중지 시기를 제어하는 전략입니다.
@param initialValue StateFlow의 초기 값입니다.
이 값은 replayExpirationMillis 파라미터와 함께 [SharingStarted.WhileSubscribed] 전략을 사용하여 State Flow가 재설정될 때도 사용됩니다.

 

started는 3가지 값을 가집니다.

 

  • Lazily : 첫번째 subscriber가 나타날 때 시작하고 scope가 취소되면 중지됩니다.
  • Eagerly : 즉시 시작하고 scope가 취소되면 중지됩니다.
  • WhileSubscribed : 이것은 복잡합니다.

One-shot 작업의 경우 Lazily 또는 Eagerly를 사용할 수 있습니다.

그러나 다른 Flow를 관찰하는 경우 아래 설명된 대로 작지만 중요한 최적화를 위해 WhileSubscribed를 사용해야 합니다.

 

WhileSubscribed 전략

WhileSubscribed는 Collector가 없을 때 Upstream Flow를 취소합니다.

stateIn을 사용하여 생성된 StateFlow는 데이터를 View에 노출하지만 다른 레이어나 앱으로부터 오는 Flow도 관찰합니다.

이러한 Flow를 활성 상태로 유지하면 데이터베이스 연결, 하드웨어 센서와 같은 다른 소스에서 데이터를 계속 읽는 경우 리소스 낭비가 발생할 수 있습니다.

앱이 백그라운드로 이동하면 코루틴을 중단해야 합니다.

 

WhileSubscribed는 두 개의 파라미터를 사용합니다.

 

public fun WhileSubscribed(
    stopTimeoutMillis: Long = 0,
    replayExpirationMillis: Long = Long.MAX_VALUE
)

 

Stop timeout

공식 문서에서는

stopTimeoutMillis는 마지막 Subscriber의 소멸과 Upstream Flow의 중지 사이의 지연(밀리초)을 구성합니다.
기본 값은 0입니다. (즉시 중지)

이 기능은 View가 잠시 동안 수신 대기를 중지한 경우 Upstream Flow를 취소하고 싶지 않으려는 경우에 유용하게 사용할 수 있습니다.

예를 들어, 사용자가 장치를 회전하고 View가 Destroy되었다가 Recreate될 때, 이 문제는 항상 발생합니다.

 

LiveData 코루틴 빌더의 솔루션은 Subscriber가 없으면 코루틴이 중지된 후 5초의 지연을 추가하는 것이였습니다.

WhileSubscribed(5000)은 정확하게 다음을 수행합니다.

 

class MyViewModel(...) : ViewModel() {
    val result = userId.mapLatest { newUserId ->
        repository.observeItem(newUserId)
    }.stateIn(
        scope = viewModelScope, 
        started = WhileSubscribed(5000), 
        initialValue = Result.Loading
    )
}


이러한 접근 방식은 아래 항목들을 충족합니다.

 

  • 사용자가 앱을 백그라운드로 보낼 때 다른 레이어에서 오늘 업데이트는 5초 후에 중지되어 배터리를 절약합니다.
  • 최신 값이 계속 캐시되므로 사용자가 포그라운드 상태로 다시 돌아올 때 View에서 즉시 일부 데이터가 표시됩니다.
  • Subscribe가 다시 시작되고 새 값이 입력되어 사용 가능한 경우 화면을 새로 고칩니다.

 

Replay expiration

사용자에게 너무 오랫동안 사라진 오래된 데이터를 보이는 것을 원하지 않고 로딩 화면을 표시하고 싶다면 WhileSubscribed에서 replayExpirationMillis 파라미터를 확인하세요.

이 경우, 캐시된 값이 stateIn에 정의된 초기 값으로 복원되기 때문에 매우 편리하며 약간의 메모리 절약도 됩니다.

앱으로 돌아가는 것은 빠르지 않지만, 이전 데이터는 표시되지 않습니다.

 

replayExpirationMillis — 공유하는 코루틴의 중지와 반복 캐시 재설정 사이의 지연 (밀리초)를 구성합니다.
(이는 shareIn 연산자의 경우 캐시를 비우고 stateIn 연산자의 경우 캐시된 값을 원래 initialValue로 재설정합니다.)
기본 값은 Long.MAX_VALUE입니다. (반복 캐시를 영원히 유지하고 버퍼를 재설정하지 마십시오.)
캐시를 즉시 만료시키려면 값에 0을 할당하십시오.

 

View에서 StateFlow 관찰

지금까지 살펴보았듯이 ViewModel의 StateFlow가 더 이상 수신 대기 하지 않는다는 것을 View가 알 수 있도록 하는 것이 매우 중요합니다.

그러나, 수명 주기와 관련된 모든 것이 그렇듯이 그렇게 간단하지 않습니다.

 

Flow를 수집하려면 코루틴이 필요합니다.

액티비티와 프래그먼트는 다음과 같이 코루틴 빌더를 제공합니다.

 

  • Activity.lifecycleScope.launch : 코루틴을 즉시 시작하고 액티비티가 destroy되면 취소합니다.
  • Fragment.lifecycleScope.launch : 코루틴을 즉시 시작하고 프래그먼트가 destroy되면 취소합니다.
  • Fragment.viewLifecycleOwner.lifecycleScope.launch : 코루틴을 즉시 시작하고 프래그먼트의 View lifecycle이 destroy되면 코루틴을 취소합니다. : UI를 수정하는 경우 View lifecycle을 사용해야 합니다.

 

LaunchWhenStarted, launchWhenResumed …

launchWhenX라는 특수 버전의 launchlifecycleOwner가 X상태가 될 때까지 기다렸다가 lifecycleOwner가 X 상태 이하로 떨어질 때 코루틴을 일시 중단합니다.

lifecycle owner가 destroy될 때까지 코루틴을 취소하지 않는다 는 점에 유의하는 것이 좋습니다.

 

launch / launchWhenX로 Flow를 수집하는 것은 안전하지 않습니다.

앱이 백그라운드에 있는 동안 업데이트를 수신하면 충돌이 발생할 수 있으며, 이는 View에서 컬렉션을 일시 중단하여 해결됩니다.

그러나 앱이 백그라운드에 있는 동안 Upstream Flow가 활성 상태로 유지되어 리소스를 낭비할 수 있습니다.

 

이 것은 StateFlow를 구성하기 위해 지금까지 우리가 한 모든 것이 매우 쓸모가 없다는 것을 의미합니다.

그러나 새로운 API가 있습니다.

 

lifecycle.repeatOnLifecycle을 사용한 해결 방법

이 새로운 코루틴 빌더(lifecycle-runtime-ktx 2.4.0-alpha01)는 우리가 필요로 하는 것을 정확히 수행합니다.

특정 상태에서 코루틴을 시작하고 lifecycle owner가 그 이하로 떨어지면 중지합니다.

다양한 Flow 수집 메서드

 

예를들어, 프래그먼트에서는 아래와 같이 사용할 수 있습니다.

 

onCreateView(...) {
    viewLifecycleOwner.lifecycleScope.launch {
        viewLifecycleOwner.lifecycle.repeatOnLifecycle(STARTED) {
            myViewModel.myUiState.collect { ... }
        }
    }
}

 

이것은 Fragment의 View가 STARTED일 때 수집을 시작하고 RESUMED까지 계속되며 STOPPED로 돌아가면 중지됩니다.

Android UI에서 Flow를 수집하는 더 안전한 방법에서 이에 대해 모두 읽어보세요.

 

repeatOnLifecycle API를 위의 StateFlow 지침과 함께 사용하면 기기의 리소스를 잘 활용하면서 최고의 성능을 얻을 수 있습니다.

 

WhileSubscribed(5000)로 노출되고 repeatOnLifecycle(STARTED)로 수집된 StateFlow

 

주의점 : 최근에 DataBinding에 추가된 StateFlow 지원launchWhenCreated를 사용하여 업데이트를 수집하고 안정화에 도달하면 대신 repeatOnLifecycle을 사용하기 시작합니다.

DataBinding의 경우 모든 곳에서 Flow를 사용해야 하며 단순히 asLiveData()를 추가하여 View에 노출해야 합니다. lifecycle-runtime-ktx:2.4.0이 안정화되면 DataBinding이 업데이트 됩니다.

 

정리

ViewModel에서 데이터를 노출하고 View에서 수집하는 가장 좋은 방법은 다음과 같습니다.

 

  • 시간 초과가 있는 WhileSubscribed 전략을 사용하여 StateFlow를 노출합니다. [예시]
  • repeatOnLifecycle을 이용하여 수집합니다. [예시]

 

다른 조합은 Upstream Flow를 활성 상태로 유지하여 리소스를 낭비합니다.

 

  • WhileSubscribed를 사용하여 노출하고 lifecycleScope.launch 또는 launchWhenX 내부에서 수집
  • Lazily, Eagerly를 사용하여 노출하고 repeatOnLifecycle로 수집

 

'Android > 번역' 카테고리의 다른 글

(번역) A safer way to collect flows from Android UIs  (0) 2022.08.31