[번역]Android UI에서 flow를 수집하는 안전한 길

hongbeom
11 min readJun 6, 2021

--

Android UI에서 flow를 안전하게 수집하기

Photo by Tobias Carlsson on Unsplash

본 글은 Manuel Vivo님의 글을 한국어로 번역 & 의역한 글입니다. 원문 링크 👇

Android 앱에서 Kotlin flows는 일반적으로 UI 레이어에서 수집되어 화면에 데이터를 업데이트합니다. 하지만 flow를 수집하여 필요 이상의 작업을 수행하지 않아야하며 리소스(CPU와 메모리 모두)를 낭비하거나 View가 백그라운드로 이동할 때 데이터가 누출되지 않도록 해야합니다.

이 글에서는 Lifecycle.repeatOnLifecycleFlow.flowWithLifecycle API를 사용하여 리소스를 낭비하지 않도록 보호하는 방법과 해당 API가 UI 레이어의 flow 수집에 사용하기 좋은 기본 방법인 이유에 대해 알아봅니다.

리소스 낭비

flow 생산자의 세부적인 구현에 관계없이 애플리케이션 구조의 하위 계층에서 Flow<T> API를 노출하는 것이 좋습니다. 그러나 우리는 그것을 안전하게 수집해야 합니다.

channel이나 buffer, conflate, flowOn, 혹은 shareIn과 같은 버퍼를 사용하는 연산자를 가지는 cold flow는 CoroutineScope.launch, Flow<T>.launchIn, 혹은 LifecycleCoroutineScope.launchWhenX와 같은 기존 API를 사용하여 수집하기에 안전하지 않습니다. 작업이 백그라운드로 변경될 때 코루틴을 시작한 Job을 취소해주지 않는다면 말이죠. 이런 API는 flow 생산자가 백그라운드에서 버퍼로 아이템을 방출하는(emit) 동안 active 상태로 유지되어 리소스를 낭비합니다.

참고 : cold flow는 새로운 구독자가 수집을 시작할 때 생산 블록을 실행하는 주문식 flow입니다.

callbackFlow를 사용하여 위치에 대한 업데이트를 방출하는 이 flow 예시를 보겠습니다:

참고 : 내부적으로 callbackFlow는 blocking queue와 개념적으로 매우 유사하며 channel을 사용하고, 기본 크기는 64개입니다.

앞에서 언급한 API를 사용하여 UI 레이어에서 이 flow를 수집하면 UI에 flow가 표시되지 않더라도 flow의 위치 방출이 계속됩니다!. 아래 코드를 참고해보세요.

lifecycleScope.launchWhenStarted에서 코루틴의 실행이 suspend됩니다. 새로운 위치는 처리되지 않지만 callbackFlow 생산자는 계속 위치를 전송합니다. lifecycleScope.launch또는 launchIn API를 사용하면 백그라운드에 있더라도 view가 위치를 계속 소비하게 되므로 더 위험합니다!. 앱이 크래시가 발생할 가능성이 있습니다.

이런 API에서 이 문제를 해결하려면 view가 백그라운드로 이동할 때 callbackFlow를 취소하고 위치 생산자가 아이템을 방출하며 리소스를 낭비하지 않도록 수동으로 collect(수집)를 취소해야 합니다. 예를 들면 이런 작업을 수행할 수 있습니다.

좋은 해결책이지만 이것은 보일러 플레이트입니다. 우리는 보일러 플레이트 코드를 쓰는 것을 정말 싫어합니다. 보일러 플레이트 코드를 작성할 필요가 없는 가장 큰 이점 중 하나는 코드가 적어지기 때문에 실수할 가능성이 적다는 것입니다!

Lifecycle.repeatOnLifecycle

이제 우리는 문제가 어디에 있는지 알았으니 해결책을 생각해야합니다. 솔루션은 1. 단순하고 2. 기억하기 쉽거나 이해하기 쉬워야하며 3. 안전해야 합니다! 또한 flow 구현 세부사항에 관계없이 모든 사용 사례에 적용되어야 합니다.

추가 작업 없이 사용할 수 있는 API는 Lifecycle.repeatOnLifecycle 이며 lifecycle-runtime-ktx 라이브러리에서 사용할 수 있습니다.

참고 : 이 API는 lifecycle:lifecycle-runtime-ktx:2.4.0-alpha01 라이브러리에서(혹은 그 이상 버전) 사용할 수 있습니다.

다음 코드를 살펴보세요.

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

이렇게 하면 코루틴이 더 이상 필요하지 않을 때 코루틴을 취소하는 코드가 repeatOnLifecycle에 의해 자동으로 실행되므로 보일러 플레이트 코드를 피할 수 있습니다. 예상할 수 있듯이 예기치 않은 동작을 방지하기 위해 activity의 onCreate 또는 fragment의 onViewCreated 메소드에서 이 API를 호출하는 것이 좋습니다. fragment를 사용한 아래 예시를 참고해보세요.

중요한 점 : fragment는 항상 viewLifecycleOwner를 사용하여 UI 업데이트를 트리거해야합니다. 하지만 가끔 View가 없는 DialogFragment의 경우에는 그렇지 않습니다. DialogFragment의 경우 lifecycleOwner를 사용할 수 있습니다.

내부 동작

repeatOnLifecycle은 호출한 코루틴을 suspend 시키고, 새로운 코루틴에서 lifecycle이 타겟 state로 들어오고 나갈때 re-launch하며, lifecycle이 파괴되면 호출한 코루틴을 재개합니다. 마지막 부분은 매우 중요합니다. repeatOnLifecycle을 호출하는 코루틴은 lifecycle이 파괴될 때까지 실행을 재개하지 않습니다.

다이어그램

다시 처음 예시로 돌아가서, lifecycleScope.launch로 시작하는 코루틴에서 locationFlow로 직접 수집되는 방법은 View가 백그라운드에 있어도 수집이 계속 진행되어 위험했습니다.

repeatOnLifecycle은 lifecycle이 타겟 state로 들어오고 나갈때 flow 수집을 중지했다가 다시 시작하므로 리소스 낭비 및 애플리케이션 크래시를 방지합니다.

repeatOnLifecycle API의 사용 여부에 대한 차이

Flow.flowWithLifecycle

수집할 flow가 오직 하나만 있는 경우에도 Flow.flowWithLifecycle 연산자를 사용할 수 있습니다. 이 API는 내부적으로 repeatOnLifecycle API를 사용하며, Lifecycle이 타겟 상태로 들어올 때 아이템을 방출하며 나갈 때 생산 작업을 취소합니다.

참고 : 이 API의 이름은 Flow.flowOn(CoroutineContext) 연산자를 선례로 삼는데, Flow.flowWithLifecycle은 다운스트림 flow를 수집하는데 사용되는 CoroutineContext를 변경하기 때문입니다. 또한 flowOn과 마찬가지로 Flow.flowWithLifecycle은 소비자가 생산자를 따라가지 못할 경우에 대비하여 버퍼를 추가합니다. 이는 callbackFlow를 사용한 구현 때문입니다.

기본 생산자의 구성

이러한 API를 사용하더라도, 아무도 수집하지 않음에도 불구하고 리소스를 낭비할 수 있는 hot flow를 주의해야합니다! 몇 가지 유효한 유즈케이스가 있지만 필요한 경우 참고하고 문서화하세요. 기본 flow 생산자가 백그라운드에서 활성 상태라서 리소스를 낭비하더라고 일부 유즈케이스에는 유용할 수 있습니다. 오래된 데이터를 일시적으로 유지하거나 표시하는 대신 즉시 새로운 데이터를 사용해야 하는 경우입니다. 우리는 유즈케이스에 따라 생산자가 항상 활성 상태여야 하는지 여부를 결정하고 사용해야합니다.

MutableStateFlowMutableSharedFlow API는 해당 필드가 0일때 기본 생산자를 중지하는 subscriptionCount 필드를 가지고 있습니다. 기본적으로 flow 인스턴스를 포함하는 객체가 메모리에 있는 한 생산자는 활성 상태를 유지합니다. ViewModel에서 StateFlow를 사용하여 UiState를 UI에 노출하는 유즈케이스 등등의 유효한 유즈케이스가 존재할 수 있습니다. 이것은 괜찮습니다! 이 유즈케이스에서는 ViewModel이 항상 View에 최신 UI 상태를 제공해야합니다.

마찬가지로, Flow.stateInFlow.shareIn 연산자는 이에 대한 sharing started policy를 구성할 수 있습니다. WhileSubscribed()는 활성화 상태의 옵저버가 없을 때 기본 생산자를 중지합니다. 반대로 EagerlyLazilyCoroutineScope가 활성화 상태를 유지하는 한 기본 생산자를 계속 활성화합니다.

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

Jetpack Compose에서 안전한 Flow 수집

Flow.collectAsState 함수는 composable에서 flow를 수집하고 값을 State<T>로 표현하여 Compose UI를 업데이트하기 위해 Compose에서 사용됩니다. 호스트 activity나 fragment가 백그라운드로 있을 때 Compose가 UI를 recompose하지 않더라도 flow 생성자는 여전히 활성 상태이며 리소스를 낭비할 수 있습니다. Compose는 View 시스템과 동일한 문제를 겪을 수 있습니다.

Compose에서 flow를 수집할 때 다음과 같이 Flow.collectAsStateWithLifecycle 연산자를 사용하세요.

하나의 키가 변경되지 않는 한 항상 동일한 flow를 사용할 수 있는 키로 locationFlowlifecycleOwner를 사용하여 lifecycle aware한 remember가 필요한 것을 알 수 있습니다.

Compose에서 side-effect는 제어할 수 있는 환경에서 수행되어야 합니다. 이를 위해 LaunchedEffect를 사용하여 composable의 lifecycle을 따르는 코루틴을 작성합시다. 이 블록에서 호스트 lifecycle이 특정 State 에 있을 때 코드 블록을 re-launch해야하는 경우 suspend인 Lifecycle.repeatOnLifecycle을 호출할 수 있습니다.

LiveData와 비교

이 API가 LiveData와 비슷하게 동작한다는 사실을 눈치챘을 수도 있습니다. LiveData는 Lifecycle aware하며, 재시작 동작으로 UI에서 데이터 스트림을 관찰하는데 이상적입니다. Lifecycle.repeatOnLifecycle, Flow.flowWithLifecycle API의 경우도 마찬가지입니다.

이런 API를 사용하여 flow를 수집하는 것은 Kotlin-only 앱의 LiveData를 자연스럽게 대체하는 것입니다. 해당 API를 flow 수집에 사용할 경우 LiveData는 coroutine과 flow에 비해 어떠한 이점도 제공하지 않습니다. flow는 모든 Dispatcher에서 수집할 수 있고 모든 연산자와 함께 사용할 수 있기 때문에 더욱 유연합니다. 제한된 연산자만 사용할 수 있고 UI 스레드에서 항상 값이 관찰되는 LiveData와는 반대로 말이죠.

data binding에서 StateFlow 지원

이와 다르게, LiveData를 사용하는 이유 중 하나는 data binding에서 지원되기 때문입니다. 하지만 StateFlow도 마찬가지입니다! data binding에서 StateFlow 지원에 대한 자세한 내용은 공식 문서를 참조해보세요.

읽어주셔서 감사합니다! 🙌

--

--