Android/번역

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

jaesungLeee 2022. 8. 31. 01:29
이 게시물은 Android Developers Medium에 작성되어있는 A safer way to collect flows from Android UIs를 번역 및 의역하여 정리한 게시물입니다. 잘못된 정보는 댓글로 첨언해주시면 감사하겠습니다.
 

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

 

안드로이드 UI에서 안전하게 flow 수집하기

안드로이드 앱에서, Kotlin flows는 일반적으로 UI Layer에서 수집되어 화면에 데이터의 업데이트를 표시합니다.

하지만, 여러분은 필요 이상으로 많은 작업을 하지 않고, 자원(CPU, 메모리)을 낭비하지 않고, View가 백그라운드 상태로 들어갔을 때 데이터 누출이 생기지 않게 하면서 Flow를 수집하기를 원합니다.

 

이 아티클에서는 Lifecycle.repeatOnLifecycleFlow.flowWithLifecyle API가 어떻게 리소스 낭비로부터 사용자를 보호하는지와 왜 이들이 UI Layer에서 Flow를 수집하기에 좋은지 배울 것입니다.

 

리소스 낭비

우리는 Flow Producer의 구현 세부 사항에 관계없이 여러분의 앱 계층구조에서의 하위 계층에서 Flow<T> API를 노출하는 것을 권장하였습니다.

하지만, 여러분은 이 Flow를 안전하게 수집해야 합니다.

buffer, conflate, flowOn 또는 shareIn과 같은 버퍼가 있는 연산자를 사용하거나 channel에서 지원하는 cold flow는 Activity가 백그라운드로 이동할 때 코루틴을 시작한 Job을 수동으로 취소하지 않는 한 CoroutineScope.launch, Flow<T>.launchIn 또는 LifecycleCoroutineScope.launchWhenX와 같이 기존에 존재하는 API로 수집하는 것은 안전하지 않습니다.

이러한 API는 백그라운드에서 버퍼로 아이템을 emit하는 동안 기본 Flow Producer를 Active 상태로 두어 리소스를 낭비합니다.

Note : Cold flow는 새로운 구독자가 수집할 때 producer 코드 블럭을 실행하는 Flow의 유형입니다.

 

예를 들어, callbackFlow를 사용하여 위치 업데이트를 emit하는 Flow를 고려할 수 있습니다.

 

// Implementation of a cold flow backed by a Channel that sends Location updates
fun FusedLocationProviderClient.locationFlow() = callbackFlow<Location> {
    val callback = object : LocationCallback() {
        override fun onLocationResult(result: LocationResult?) {
            result ?: return
            try { offer(result.lastLocation) } catch(e: Exception) {}
        }
    }
    requestLocationUpdates(createLocationRequest(), callback, Looper.getMainLooper())
        .addOnFailureListener { e ->
            close(e) // in case of exception, close the Flow
        }
    // clean up when Flow collection ends
    awaitClose {
        removeLocationUpdates(callback)
    }
}

 

Note : 내부적으로, callbackFlow는 blocking queue와 개념적으로 유사한 channel을 이용하고, 기본 용량은 64개 입니다.

 

앞서 언급한 API중 하나를 이용하여 UI Layer에서 이 Flow를 수집하면 View가 UI에 Flow를 표시하지 않더라도 Flow emit이 유지됩니다!

아래 예시를 참조하세요.

 

class LocationActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // Collects from the flow when the View is at least STARTED and
        // SUSPENDS the collection when the lifecycle is STOPPED.
        // Collecting the flow cancels when the View is DESTROYED.
        lifecycleScope.launchWhenStarted {
            locationProvider.locationFlow().collect {
                // New location! Update the map
            } 
        }
        // Same issue with:
        // - lifecycleScope.launch { /* Collect from locationFlow() here */ }
        // - locationProvider.locationFlow().onEach { /* ... */ }.launchIn(lifecycleScope)
    }
}

 

lifecycleScope.launchWhenStarted는 코루틴 실행을 일시 중단합니다.

새로운 위치 갱신은 처리되지 않지만 그럼에도 불구하고 callbackFlow 생산자는 위치를 계속 보냅니다.

lifecycleScope.launch 또는 launchIn API를 사용하는 것은 View가 백그라운드에 있더라도 위치를 계속 소비하기 때문에 위험합니다.

이는 잠재적으로 앱이 다운될 수 있습니다.

 

이러한 API를 통해 이 문제를 해결하려면 callbackFlow를 취소하고 Location Provider가 아이템을 emit하고 리소스를 낭비하지 않도록 View가 백그라운드로 이동할 때 수동으로 collection을 취소해야 합니다.

 

예를 들어, 여러분은 다음과 같이 할 수 있습니다.

 

class LocationActivity : AppCompatActivity() {

    // Coroutine listening for Locations
    private var locationUpdatesJob: Job? = null

    override fun onStart() {
        super.onStart()
        locationUpdatesJob = lifecycleScope.launch {
            locationProvider.locationFlow().collect {
                // New location! Update the map
            } 
        }
    }

    override fun onStop() {
        // Stop collecting when the View goes to the background
        locationUpdatesJob?.cancel()
        super.onStop()
    }
}

 

좋은 해결책이지만 보일러플레이트입니다.

그리고, 안드로이드 개발자에게 보편적인 사실이 있다면 우리는 이러한 보일러플레이트 코드를 절대적으로 싫어합니다.

보일러 플레이트 코드를 작성하지 않아도 된다는 가장 큰 장점 중 하나는 코드가 적을 수록 실수할 가능성이 적다는 것입니다.

 

Lifecycle.repeatOnLifecycle

이제 문제가 어디에 있는지 알고 있으므로 해결책을 제시할 때입니다.

해결책은 1) 간단해야 하고, 2) 친숙하거나 기억/이해하기 쉽고, 더 중요하게는 3) 안전해야 합니다!

Flow 구현체의 세부 정보에 관계없이 모든 사용 사례에서 작동해야 합니다.

 

더이상 고민하지 않고 사용해야 하는 API는 lifecycle-runtime-ktx 에서 사용할 수 있는 Lifecycle.repeatOnLifecycle 입니다.

 

Note : 이 API는 androidx.lifecycle:lifecycle-runtime-ktx:2.4.0 또는 그 이후 라이브러리에서 사용할 수 있습니다.

 

아래 코드를 살펴봅시다.

 

class LocationActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // Create a new coroutine since repeatOnLifecycle is a suspend function
        lifecycleScope.launch {
            // The block passed to repeatOnLifecycle is executed when the lifecycle
            // is at least STARTED and is cancelled when the lifecycle is STOPPED.
            // It automatically restarts the block when the lifecycle is STARTED again.
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                // Safely collect from locationFlow when the lifecycle is STARTED
                // and stops collection when the lifecycle is STOPPED
                locationProvider.locationFlow().collect {
                    // New location! Update the map
                }
            }
        }
    }
}

 

repeatOnLifecycle은 매개변수로 전달된 Lifecycle.State가 해당 state에 도달하면 블록에 있는 새 코루틴을 자동으로 생성 및 시작하고, lifecycle이 state 아래로 떨어질 때 블록 안에 있는 실행 중인 코루틴을 취소하는 suspend 함수입니다.

 

이렇게 하면 코루틴이 더 이상 필요하지 않을 때 repeatOnLifecycle에 의해 코루틴을 취소하는 코드가 자동으로 수행되기 때문에 보일러 플레이트 코드를 피할 수 있습니다.

여러분이 예상할 수 있듯이 예상치 못한 동작을 피하기 위해 Activity의 onCreate 또는 Fragment의 onViewCreated 메서드에서 이 API를 호출하는 것을 권장합니다.

 

다음은 Fragment에서 사용하는 예시입니다.

 

class LocationFragment: Fragment() {
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        // ...
        viewLifecycleOwner.lifecycleScope.launch {
            viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
                locationProvider.locationFlow().collect {
                    // New location! Update the map
                }
            }
        }
    }
}

 

중요 : 프래그먼트는 항상 viewLifecycleOwner를 사용하여 UI 업데이트를 트리거해야 합니다.

하지만 가끔 View가 없는 DialogFragment의 경우에는 그렇지 않습니다.

DialogFragment의 경우 lifecycleOwner를 사용할 수 있습니다.

 

Note : 이 API는 androidx.lifecycle:lifecycle-runtime-ktx:2.4.0 또는 그 이후 라이브러리에서 사용할 수 있습니다.

 

내부 동작

repeatOnLifecycle은 호출한 코루틴을 일시정지하고, Lifecycle이 새 코루틴에서 대상 state 안팎으로 이동할 때 블록을 re-launch하며, Lifecycle이 Destroy되면 호출한 코루틴을 재개합니다.

마지막 부분이 매우 종요합니다.

repeatOnLifecycle을 호출하는 코루틴은 Lifecycle이 Destroy될 때까지 실행을 재개하지 않습니다.

 

class LocationActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // Create a coroutine
        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.RESUMED) {
                // Repeat when the lifecycle is RESUMED, cancel when PAUSED
            }

            // `lifecycle` is DESTROYED when the coroutine resumes. repeatOnLifecycle
            // suspends the execution of the coroutine until the lifecycle is DESTROYED.
        }
    }
}

 

다이어그램

처음으로 돌아가서 lifecycleScope.launch로 시작된 코루틴에서 직접 locationFlow를 수집하는 것은 View가 백그라운드에 있을 때에도 수집이 발생하기 때문에 위험했습니다.

repeatOnLifecycle은 lifecycle이 대상 state로 들어오고 나갈 때 flow 수집을 중지하고 재시작하기 때문에 리소스 낭비와 앱 크래시를 방지합니다.

repeatOnLifecycle API 사용 여부에 따른 차이점

 

Flow.flowWithLifecycle

여러분은 오직 한가지 flow만 수집해야 할 경우에도 Flow.flowWithLifecycle 연산자를 사용할 수 있습니다.

이 API는 내부적으로 repeatOnLifecycle API를 사용하며, Lifecycle이 대상 state로 들어올 때 item을 방출하며, 나갈 때 생산자를 취소합니다.

 

class LocationActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // Listen to one flow in a lifecycle-aware manner using flowWithLifecycle
        lifecycleScope.launch {
            locationProvider.locationFlow()
                .flowWithLifecycle(lifecycle, Lifecycle.State.STARTED)
                .collect {
                    // New location! Update the map
                }
        }
        
        // Listen to multiple flows
        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                // As collect is a suspend function, if you want to collect
                // multiple flows in parallel, you need to do so in 
                // different coroutines
                launch {
                    flow1.collect { /* Do something */ }   
                }
                
                launch {
                    flow2.collect { /* Do something */ }
                }
            }
        }
    }
}

 

Note: 이 API는 Flow.flowOn(CoroutineContext)연산자를 선례로 삼는데, Flow.flowWithLifecycle은 업 스트림 flow를 수집하는데 사용되는 CoroutineContext를 변경하고 다운 스트림은 영향을 받지 않은 채로 두기 때문입니다.
또한, flowOn과 유사하게 Flow.flowWithLifecycle은 소비자가 생산자를 따라가지 못할 경우를 대비하여 버퍼를 추가합니다. 이는 구현에서 callbackFlow를 사용하기 때문입니다.

 

기본 생산자의 구성

이러한 API를 사용하더라도, 아무도 수집하지 않더라도 리소스를 낭비하는 hot flow에 대해 주의하십시오!

이 경우에 대해 유용한 유스케이스가 있지만, 이를 염두해두고 필요한 경우 문서화하십시오.

리소스를 낭비하더라도 기본 flow 생산자를 백그라운드에서 활성화하는 것은 몇몇 유스케이스에 대해 유용할 수 있습니다. (오래된 데이터를 일시적으로 유지하거나 표시하는 대신 새로운 데이터를 즉시 사용해야 하는 경우)

여러분은 유스케이스에 따라 생산자가 항상 활성화 되어야 하는지에 대한 여부를 결정하고 사용해야 합니다.

 

MutableStateFlowMutableSharedFlow API는 subscriptionCount가 0일 때 기본 생산자를 중지하는데 사용할 수 있는 subscriptionCount필드를 갖고 있습니다.

기본적으로, flow 인스턴스를 포함하는 객체가 메모리에 있는 한 생산자는 활성 상태를 유지합니다.

예를 들어, StateFlow를 사용하여 ViewModel에서 UI로 노출되는 UiState와 같은 몇가지 유용한 유스케이스가 있습니다.

이것은 괜찮습니다! 이 유스케이스에서는 ViewModel이 항상 View에 최신 UI상태를 제공해야 합니다.

 

이와 유사하게, Flow.stateInFlow.shareIn 연산자는 이에 대한 sharing started policy를 구성할 수 있습니다.

WhileSubscribed()는 활성화 상태의 Observer가 없을 때 기본 생산자를 중지합니다.

반대로 EagerlyLazilyCoroutineScope가 활성화 상태를 유지하는 한 기본 생산자를 계속 활성화합니다.

 

Note : 이 문서에 표시된 API는 UI에서 flow를 수집하기 위한 좋은 방법이며 flow 구현의 세부 사항에 관계없이 사용할 수 있습니다.
이러한 API들은 UI가 화면에 표시되지 않으면 수집을 중지합니다. 항상 활성화 상태여야 하는지에 대한 여부는 flow의 구현에 달려있습니다.

 

Jetpack Compose에서 안전한 Flow 수집

(추후 예정)

 

LiveData와의 비교

여러분은 이미 이 API가 LiveData와 유사하게 동작한다는 사실을 눈치채셨을 것입니다. 사실입니다!

LiveData는 Lifecycle Aware하며, 재시작 동작으로 인해 UI에서 데이터 스트림을 관찰하는데에도 이상적입니다.

Lifecycle.repeatOnLifecycleFlow.flowWithLifecycle API에서도 마찬가지입니다!

 

위 API들을 사용하여 Flow를 수집하는 것은 Kotlin으로 작성된 앱에서 LiveData를 자연스럽게 대체합니다.

여러분이 만약 Flow 수집에서 이 API들을 사용한다면, LiveData는 코루틴과 Flow에 비해 어떤 이점도 제공하지 않습니다.

또한, 모든 Dispatcher에 의해 수집될 수 있고, 모든 연산자와 함께 사용될 수 있기 때문에 Flow는 더욱 유연합니다.

사용 가능한 연산자가 제한적이고 항상 UI 쓰레드에서 관찰되는 LiveData와는 대조적입니다.

 

Data Binding에서 StateFlow 지원

다른 참고로, LiveData를 사용하는 이유 중 하나는 Data Binding이 지원되기 때문입니다.

StateFlow도 지원 합니다!

Data Binding의 StateFlow 지원에 대한 자세한 내용은 공식 문서를 확인하세요.

 

 

Lifecycle.repeatOnLifecycle Flow.flowWithLifecycle API를 사용하여 Android UI 레이어에서 flow를 안전하게 수집하세요.