오늘도 더 나은 코드를 작성하였습니까?

android LiveDate flow로 이전하기(Migrating from LiveData to Kotlin’s Flow) 학습 및 번역 본문

Coroutine/coroutineFlow

android LiveDate flow로 이전하기(Migrating from LiveData to Kotlin’s Flow) 학습 및 번역

hik14 2022. 11. 4. 21:34

LiveData는 2017년에 우리에게 필요했던 것이었습니다.

Observer pattern 은 우리가 코딩을 하는데 있어서 더 쉽게 만들어 주었지만,  당시에는 RxJava와 같은 옵션이 초보자에게 너무 복잡했습니다.

 

Architecture Components 팀은 Android용으로 설계된 매우 독단적인 Observable 데이터 홀더 클래스인 LiveData를 만들었습니다. LiveData는 시작하기 쉽도록 단순하게 만들었고, RxJava와 LiveData사이의 통합을 활용하여 보다 복잡한 reactive streams의 경우 RxJava를 사용하는 것이 좋습니다.

DeadData?

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

 

이것을 제외한다면, Kotlin Flows로 이전 하는 것이 좋습니다.

flow에는 여전히 가파른 학습 곡선이 있지만 Jetbrains에서 지원하는 Kotlin 언어의 일부입니다.  또한, 반응형 모델과 잘 어울리는 Compose가 출시됩니다.

 

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

이제 Android UI에서 Flows을 collecti하는 더 안전한 방법이 있으므로 완전한 마이그레이션 가이드를 만들 수 있습니다.

 

Flows를 view에 노출하는 방법, Flows를 수집하는 방법, 특정 요구 사항에 맞게 미세 조정하는 방법을 배웁니다.

 

Flow: Simple things are harder and complex things are easier

(간단한것은 더 어렵고, 복잡한것은 더 쉽게)

 

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

latest value를 caching하고 Android의 LifeCycle를 이해하면서 데이터를 노출했습니다.

 

우리는 livedata{ } 코루틴을 생성하여  복잡한 변환을 생성할 수도 있다는 것을 배웠지만,  더 복잡했습니다.

 

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

 

#1: Expose the result of a one-shot operation with a Mutable data holder

코루틴의 결과로 state holder 를 변경하는 고전적인 패턴입니다.

<!-- Copyright 2020 Google LLC.	
   SPDX-License-Identifier: Apache-2.0 -->

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
        }
    }
}

Flows에서도 동일한 작업을 수행하기 위해 (Mutable)StateFlow를 사용합니다.

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)입니다.

 

- 항상 value 값을 가지고 있다.
- 하나의 값만 가지고 있다.
- 여러 Observer에게  공유가 가능합니다.
- Active Observer의 수와 관계없이 항상 latest value를 제공합니다.

 

UI state를 View에 노출할 때, StateFlow를 사용하세요.

UI state를 유지하도록 설계된 안전하고 효율인 Observable data holder입니다.

 

#2: Expose the result of a one-shot operation

 mutable backing property 없이 코루틴 호출의 결과를 노출하는 이전 스니펫과 동일합니다.

 

 LiveData 코루틴 빌더를 사용

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

state holder는 항상 값을 가지므로 UI ​​상태를 Loading, Success 및 Error와 같은 상태를 지원하는 일종의 Result 클래스로 래핑하는 것이 좋습니다.

 

일부 configutaion을 수행해야 하기 때문에 Flow 로 구현하는것은 조금 더 복잡합니다.

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 data load with parameters

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

LiveData로 구현한다면,

lass 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)) }
    }
}

switchMap은 본문 블록{ }이 실행되고,  userId가 변경될 때 결과가 구독되는 변환입니다.

 

userId가 LiveData여야 할 이유가 없다면,

Stream을 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로 구현한다면,

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
    )
}

더 많은 flexibility이 필요한 경우 transformLatest를 사용하고 명시적으로 item을 내보낼 수도 있습니다.

    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: Observing a stream of data with parameters

 

reactive 하게 만들어 보겠습니다.

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

 

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

 

LiveData를 사용하면 flow를 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을 combine하고 출력만 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 변환이 없습니다

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 Combining multiple sources: MediatorLiveData -> Flow.combine

 

MediatorLiveData를 사용하면, 하나 이상의 업데이트 소스(LiveData 옵저버블)를 관찰하고 새 데이터를 얻을 때 작업을 수행할 수 있습니다. 일반적으로 MediatorLiveData의 값을 업데이트합니다.

 

 

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. combine

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

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

Configuring the exposed StateFlow (stateIn operator)

 

stateIn을 사용하여 일반 flow -> StateFlow로 변환했지만  configuration이 필요합니다.

지금 자세히 알고싶지 않고 복사하여 붙여넣기만 하면 된다면 이 조합을 추천합니다.

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

그러나  started = 5초 가 이해되지 않는다면 계속 보자.

 

stateIn에는 3개의 매개변수가 있습니다.

 

@param scope  - 공유가 시작되는 코루틴 scope입니다.
@param started -  공유가 시작 및 종료되는 방법을 정합니다.
@param initialValue -  stateFlow 의 초기 값


initialValue은 'replayExpirationMillis' 매개변수와 함께 [SharingStarted.WhileSubscribed] 전략을 사용하여 ,

state flow가 reSet될 때도 사용됩니다.

 

started 3개의 값을 가질수 있다.

 

Lazily:  첫 번째 구독자가 나타날 때 시작하고,  scope가 취소되면 중지합니다.
Eagerly: 즉시 시작하고  scope가 취소되면  중지
WhileSubscribed:  복잡합니다.

 

원샷 job의 경우 Lazily 또는 Eagerly를 사용할 수 있습니다.

 

그러나 또 다른 flow를 observe 하는 경우 아래에 설명된 대로 간단하지만,

중요한 최적화를 수행하기 위해 WhileSubscribed를 사용해야 합니다.

 

The WhileSubscribed strategy

WhileSubscribed는 collector가 없을 때 업스트림 flow을 취소합니다.

 

stateIn을 사용하여 생성된 StateFlow는 데이터를 View에 노출하지만,  다른 계층이나 앱(업스트림)에서 오는 flow도 관찰합니다.

 

이러한 flow을 active 상태로 유지하면 ,

예를 들어 데이터베이스 연결, 하드웨어 센서 등과 같은 다른 소스에서 데이터를 계속 읽는 경우 리소스가 낭비될 수 있습니다.

 

App이 Background로 이동하면,  더 이상 관찰을 하여 자원을 이용하면 안되며 이러한 코루틴을 중지해야 합니다.

 

WhileSubscribed는 두 개의 매개변수를 사용

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

 

Stop timeout

공식 문서에서

stopTimeoutMillis는 마지막 구독(collector)의 소멸과 업스트림 flow의 중지 사이의 지연(밀리초)을 구성합니다.

기본값은 0입니다(즉시 중지).

 

더 이상 flow의 값을 사용하는곳 이 지정한 시간동안 없다면, emit을 중지한다.

 

View가 잠시 동안 수신 대기를 중지한 경우 업스트림 flow을 취소하고 싶지 않기 때문에 유용합니다.

 

이런 상황은 자주 발생합니다. 예를 들어 사용자가 장치를 회전하고,  뷰가 연속적으로 빠르게 파괴되고 다시 생성될 때입니다.

 

liveData 코루틴 빌더의 솔루션은 구독자가 없으면, 코루틴이 중지된 후 5초의 지연을 추가하는 것이었습니다.

WhileSubscribed(5000)는 정확히 같은 동작을 수행합니다

 

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

- 사용자가 앱을 백그라운드로 보낼 때 다른 레이어에서 오는 업데이트는 5초 후에 중지되어 배터리를 절약합니다.

-  latest value은 여전히 ​​캐시되므로 사용자가 다시 돌아올 때 보기에 일부 데이터가 즉시 표시됩니다.

구독이 다시 시작되고,  New Value이 입력되어 사용 가능한 경우 화면을 새로 고칩니다.

Replay expiration

사용자가 너무 오랫동안 사라진 오래된 데이터를 보고 싶지 않고 로딩 화면을 표시하고 싶다면 WhileSubscribed에서 replayExpirationMillis 매개변수를 확인하세요.

 

위와 같은 상황에서  매우 편리하며 캐시된 값이 stateIn에 정의된 초기 값으로 복원되기 때문에 약간의 메모리도 절약됩니다.

앱의 background에서 복귀하는것은 빠르고, 이전 데이터는 표시되지 않습니다.

 

 

replayExpirationMillis

 

sharing 코루틴의 stop와 replaycache 재설정 사이의 지연(밀리초)을 구성합니다(이는 shareIn 연산자의 경우 캐시를 비우고, stateIn 연산자의 경우 캐시된 값을 원래 initialValue로 재설정함).

기본값은 Long.MAX_VALUE입니다(재생 캐시를 영원히 유지하고 버퍼를 재설정하지 않음).

캐시를 즉시 만료시키려면 0 값을 사용하십시오.

 

Observing StateFlow from the view

 

지금까지 살펴보았듯이 ViewModel의 StateFlow가 더 이상 수신 대기하지 않는다는 것을 뷰가 알 수 있도록 하는 것이 매우 중요합니다. 그러나 수명 주기와 관련된 모든 것이 그렇듯이 그렇게 간단하지 않습니다.

 

Activity.lifecycleScope.launch:  코루틴을 즉시 시작하고 Activity가 파괴되면(onDestory) 취소합니다.

Fragment.lifecycleScope.launch: 코루틴을 즉시 시작하고 프래그먼트가 파괴되면(onDestory) 취소합니다.

Fragment.viewLifecycleOwner.lifecycleScope.launch: 코루틴을 즉시 시작하고 프래그먼트의 뷰 수명 주기가 파괴되면 코루틴을 취소합니다. UI를 수정하는 경우 뷰 수명 주기를 사용해야 합니다.

 

LaunchWhenStarted, launchWhenResumed…

launchWhenX라는 특수 버전의 launch는

lifecycleOwner가 X 상태가 될 때까지 기다렸다가 lifecycleOwner가 X 상태 아래로 떨어질 때 코루틴을 suspend합니다.

 

수명 주기 소유자가 소멸될 때까지 코루틴을 취소하지 않는다는 점에 유의하는 것이 중요합니다.

 

 

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

 

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

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

 

lifecycle.repeatOnLifecycle to the rescue

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

특정 state에서 코루틴을 시작하고 수명 주기 소유자가 그 아래로 떨어지면 중지합니다.

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

Fragment 보기가 STARTED일 때 collecting을 시작하고 RESUMED까지 계속되며 STOPPED로 돌아가면 중지됩니다.

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

https://medium.com/androiddevelopers/a-safer-way-to-collect-flows-from-android-uis-23080b1f8bda

 

A safer way to collect flows from Android UIs

Learn how the repeatOnLifecycle API protects you from wasting resources and why it’s a good default for flow collection in the UI layer.

medium.com

 

 

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

 

경고: 최근에 데이터 바인딩에 추가된 StateFlow 지원은 업데이트를 수집하기 위해 launchWhenCreated를 사용하며 안정에 도달하면 대신 repeatOnLifecycle`을 사용하기 시작합니다.

 

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

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

 

다른 조합은 업스트림 흐름을 활성 상태로 유지하여 리소스를 낭비합니다.

❌ WhileSubscribed를 사용하여 노출하고 lifecycleScope.launch/launchWhenX 내부에서 수집
❌ Lazily/Eagerly를 사용하여 노출하고 repeatOnLifecycle로 수집