Kotlin Flows in practice

JINA·2022년 3월 8일
0

Android/정리

목록 보기
1/1
post-thumbnail

Kotlin Flows in practice
위의 영상을 보고 정리한 글입니다.😊

데이터는 한 방향으로 흐르게 하는 것이 좋은데, 이는 오류가 발생할 가능성이 줄고, 관리하기 쉽기 때문이다. 데이터를 한 방향으로 흐르게 하고 수도관 역할을 하는 인프라를 구현해서 데이터 스트림을 결합하고 변환하는 것이 좋다. 예를 들어 무언가 바뀌어서 수정이 필요한 경우 사용자가 로그아웃하면 관을 다시 설치할 수 있다. 이렇게 데이터 스트림을 결합하고 변환하려면 정교한 무언가가 필요한데, 그게 오늘 정리해볼 Flow이다!

flow는 사용자 데이터나 UI상태가 될 수 있다. 생산자는 데이터를 flow에 입력하고
소비자는 flow에서 데이터를 수집한다. Android는 데이터 소스나 레포지토리가
전형적인 앱 데이터 생산자이다. 최종적으로 화면에 데이터를 표시하는 UI가 소비자 역할을 한다.


Creating Flows

Flow를 생성하는 방법에 대해 알아보자!
데이터 소스에서 사용하는 라이브러리는 코루틴과 Flow에 이미 통합되어 있어서 대부분의 경우 우리가 직접 flow를 만들 필요가 없다.

자주 사용하는 라이브러리인 DataStore, Retrofit, Room, WorkManager 등은 Flow를 사용하여 데이터를 제공하는 댐 역할을 하기때문에 개발자는 데이터 생성 방법을 몰라도 파이프에 연결하기만 하면 된다.

Room을 예시로 들자면, X타입의 Flow를 노출하여 데이터베이스의 변경 사항을 알 수 있고,
Room 라이브러리가 생산자 역할을 맡아서 업데이트가 있을 때마다 쿼리 내용을 전송해준다.

Flow를 직접 만들어야한다면? 여러가지 옵션이 있지만 Flow builder를 알아보자
userMessagesDataSource에서 수시로 앱에서 온 매세지를 확인하려고 한다고 생각해보자.
List형식의 Message를 flow로 노출할 수 있는데, 먼저 flow를 만들려면 flow builder을 사용해야한다. flow builder는 suspend 블록을 매개변수로 받기때문에 suspend함수를 호출할 수 있다. 이는 Flow가 코루틴의 컨텍스트에서 실행되기 때문이다.
내부에서는 while(true) 루프로 로직을 주기적으로 반복한다.

먼저 API에서 메세지를 가져온 다음, emit suspend 함수로 flow에 결과를 추가한다.
그러면 컬렉터가 항목을 받을 때까지 코루틴을 중단하고, 마지막으로 일정 시간 코루틴을 중단한다.
위의 flow는 동일한 코루틴에서 연산을 순차로 실행한다. while(true) 루프로 인해 lateMessages를 계속 가져오고, 관찰자가 떠나거나 데이터 수집을 중단하면 멈추게된다.

flow builder에 전달된 suspend 블록은 생산자 블록이라고도한다.
Android에서 생산자와 소비자 간의 계층은 그 이후의 게층 요구 사항에 맞게 데이터 스트림을 수정할 수 있다. flow를 변환하려면 중간 연산자를 사용한다.

latestMessages 스트림이 flow의 시작점이라고 한다면 map 연산자를 사용해서 데이터를 다른 유형으로 변환할 수 있다.

예를 들어 map 람다 내부의 데이터 소스에서 받은 원본 메세지를 MessagesUiModel로 변환하는데, 이는 앱의 이 계층에 대해서는 더욱 우수한 추상화를 제공한다.

각 연산자는 기능에 따라 데이터를 전송하는 새로운 flow를 생성하는데, 스트림을 필터링하여 중요한 알림이 포함된 해당 메세지의 flow를 가져올 수 있다.

스트림에서 발생하는 오류는 어떻게 처리할까?
catch 연산자는 업스트림 flow 항목을 처리하는 동안 발생할 만한 예외를 찾아낸다.

Upstream flow란?
프로듀서 블록에서 생성한 flow이고, 현재 연산자 전에 이들을 호출한다.

Downstream flow란?
마찬가지로 현재 연산자 이후에 발생하는 모든 것을 의미한다.

catch는 필요하거나 새 값을 전송할 때 예외를 다시 발생시킬 수 있다. 예를 들어 위의 코드는 IllegalArgumentExceptions를 다시 발생시키지만, 다른 예외가 발생하면 빈 목록을 전달한다.


Observing Flows

지금까지 스트림이 어떻게 생성되고 수정할 수 있는지 살펴봤고, 이제 수집 방법을 알아보자.
일반적으로 flow수집은 화면 데이터를 표시하기 위해 UI계층에서 일어난다.

판초가 최신 메세지가 표시되는 목록을 보려면 terminal 연산자를 사용해서 값을 수신해야한다.
이때, 스트림의 모든 값을 전송 즉시 가져오려면 collect를 사용하면 된다.

collect는 새로운 값이 생길 때마다 호출되는 함수를 매개변수로 받는데, 이는 suspend 함수이므로
코루틴 내에서 실행해야한다.

terminal 연산자를 flow에 적용하면 필요에 따라 플로가 생성되고 값을 전송하기 시작한다.
반면, intermediate 연산자는 일련의 연산자만 설정하며 항목을 flow로 전송했을 때
간격을 두고 연산자를 실행한다.

userMessages에서 collect를 호출할 때마다 새로운 flow가 생성된다. 생산자 블록은 정해진 간격에 따라 API에서 메세지를 새로고침하기 시작한다.

위의 flow를 코루틴 용어로 cold flow라고하는데, 이는 필요에 따라 생성되고 관찰되는 중에만 데이터를 전송하기 때문이다.


Flows in Android UI

이제 Android UI에서 최적으로 flow를 수집하는 방법에 대해 알아볼 것이다.
고려해야 할 점은 크게 두 가지이다.

  1. 앱이 백그라운드에 있을 때 리소스를 낭비하지 않아야 함.
  2. 구성 변경(Configuration Change)

MessageActivity에서 화면에 메세지 목록을 표시해야한다고 생각해보자.

그렇다면 flow에서 얼마나 오래 수집해야 할까?
UI가 적절히 동작하고, 화면에 UI가 표시되지 않을 때는 flow에서 수집을 중단해야한다.
(판초가 이를 닦거나 낮잠을 잘때는 수도꼭지를 잠가야하는거처럼!)
UI도 마찬가지로, 화면에 정보를 표시하지 않을 때는 플로에서 수집해서는 안된다.

여기에는 여러 옵션이 있는데, 위의 모든 옵션이 UI 수명 주기를 인식한다. 수명 주기 코루틴별 API나 LiveData를 사용할 수 있다. 예를 들어 repeatOnLifecycleflowWithLifecycle 등이 있다.

asLiveData flow 연산자는 flow를 LiveData로 변환해서 UI가 화면에 표시되는 동안에만 항목을 관찰하고, UI에서는 평소처럼 LiveData를 소비하면된다.

UI 계층에서 flow를 수집할 때 repeatOnLifecycle을 사용하는 게 좋은데, 이는 Lifecycle.State를 매개변수로 받는 suspend 함수이다. 이 API는 수명 주기를 인식하며 수명 주기가 해당 상태에 도달하면
블록을 전달할 새 코루틴이 자동으로 시작되고, 수명 주기가 상태 아래로 떨어지면 진행 중인 코루틴이 취소된다. 블록 안쪽은 코루틴 컨텍스트이므로 collect를 호출할 수 있다.

repaetOnLifecycle은 suspend 함수이므로 코루틴에서 호출해야한다. 액티비티 안에 있기때문에 lifecycleScope로 시작할 수 있다.

수명 주기가 onCreate 될때 이 함수를 호출하는 것이 좋고, 수명주기가 onDestroy될 때까지 실행을 다시 시작하지 않는다.

여러 flow에서 수집을해야 할 경우
repaetOnLifecycle 블록에서 launch를 사용해 여러 코루틴을 생성해야한다.

flowWithLifecycle 연산자는 수집할 flow가 하나 뿐일때 repaetOnLifecycle 대신 사용할 수 있다.
이 API는 수명주기가 대상 상태에서 들어가고 나갈 때 항목을 전송하고 기본 생산자를 취소한다.

시각적으로 작동원리를 확인해보자!
액티비티의 수명 주기를 생성 시점부터 살펴보면 사용자가 홈 버튼을 누르면 백그라운드로 전송되고,
액티비티에서 onStop 신호를 받은 다음, 다시 onStart가 호출되었을 때 다시 앱을 연다.

STARTED 상태로 repaetOnLifecycle를 호출하면 UI가 화면에 표시되는 동안 flow 전송을 처리하고, 앱이 백그라운드로 이동하면 수집이 취소된다.

repaetOnLifecycle, flowWithLifecyclelifecycle-runtime-ktx:2.4.0 라이브러리에 새로 추가된 API이다. 이는 새로운 API이므로 Android UI에서 다른 방식으로 flow를 수집할 수도 있다.

예를 들면 lifecycleScope에서 시작한 코루틴에서 바로 수집할 수도 있지만, 이는 앱이 백그라운드에 있을 때에도 flow에서 수집하고 UI요소를 업데이트를 하기 때문에 이런 방식의 flow 수집은 위험할 수 있다.

다른 예시로 LifecycleCoroutineScope.launchWhen X API도 비슷한 문제가 있다.
(판초가 물을 낭비하면 안되듯이)
항목이 화면에 표시되지 않을 때는 우리도 flow에서 수집해서는 안된다.

lifecycleScope.launch에서 직접 수집하는 경우 액티비티가 백그라운드에 있을 때도 계속 flow 업데이트를 받는다. 이는 낭비일 뿐만 아니라 위험하기도 한데, 예를 들어 앱이 백그라운드일 때 다이얼로그를 표시하면 충돌이 일어난다.

위의 문제를 해결하려면 onStart에서 수동으로 수집을 시작하고, onStop에서 수집을 중단해야하지만,
repeatOnLifecycle을 사용하면 작성했던 상용구 코드(보일러플레이트)를 제거할 수 있다.

launchWhenStarted를 대안으로 고려할 경우, 앱이 백그라운드에 있을 때 flow 수집을 중단하기 때문에 lifecycleScope.launch보다 나은 편이다. 하지만 이 방법은 flow 생산자를 계속 활성화시켜서 화면에 표시되지 않을 아이템으로 메모리를 채울 수 있는 아이템으로 백그라운드에서 계속 전달할 수도 있다.

UI에서 flow 생산자의 구현 방법을 알 수 없으므로 repeatOnLifecycle이나 flowWithLifecycle을 안전하게 사용하는게 좋다.


이제 앱에서 구성 변경이 일어날 경우 몇 가지 요령을 알아보자.

flow를 뷰에 노출하면 수명주기가 서로 다른 두 요소 사이에 데이터를 전달해야 한다는 걸
고려해야한다.

기기가 회전되었거나 구성 변경을 수신하면 모든 액티비티를 다시 시작하지만 ViewModel은 그렇지 않다.

ViewModel에서 모든 flow를 노출하는 건 아니다. 예를 들면 위의 cold flow인데, cold flow는 처음으로 수집될 때마다 다시 시작하기 때문에 리포지토리는 한 번 회전 후 다시 호출될 것 이다.
이 때문에 우리에게는 일종의 버퍼가 필요하다. 재생성 횟수와 상관없이 데이터를 보관하고 있다가 여러 컬렉터 사이에 공유하면 된다.

StateFlow가 바로 이런 목적으로 생성되었다. StateFlow는 물로 비유하면 물탱크이다.
수집이 없더라도 데이터를 보관할 수 있다. 일회성 수집이 아닐 수 있으므로 액티비티나 프래그먼트와 함께 사용하는게 안전하다.

예를 들어 위의 코루틴에서처럼 StateFlow의 여러 버전을 사용하고 필요할 때마다 값을 업데이트할 수 있다. 하지만 위의 방식이 반응형이라고 하긴 어렵다.

flow -> StateFlow
이렇게 변경하면 StateFlow가 업스트림 flow에서 모든 업데이트를 받아 최신값을 저장한다. 컬렉터가 없거나 많을 수 있으므로 ViewModel에 사용하기 좋다. 여러 유형의 flow가 있지만 StateFlow를 매우 정확하게 최적화할 수 있으므로 이 방식을 권장한다.

flow를 StateFlow로 변환할때 stateIn 연산자를 사용할 수 있다.
이는 세 가지 매개변수를 받는다.

  • initialValue: StateFlow에 항상 값이 있어야 하기 때문
  • scope: 코루틴 범위는 공유가 시작되는 시점을 제어하는데
    여기에 viewModelScope를 사용할 수 있다.
  • started: 흥미로운 부분인데, WhileSubscribed(5000)
    무슨 의미인지 이어서 두 가지 시나리오를 살펴보자.

첫 번째 시나리오
flow의 컬렉터인 액티비티가 일정 시간 파괴되었다가 다시 생성시키는 회전 시나리오이다.

두 번째 시나리오
홈으로 이동해서 앱을 백그라운드로 보내는 것

회전 시나리오에서는 최대한 빠르게 전환하려면 flow를 다시 시작해서는 안된다.
그러나 홈으로 이동하는 두 번째 시나리오일 경우에는 배터리와 다른 리소스를 아끼기 위해 모든 flow를 중단해야한다.

그러면 어떤 시나리오인지 어떻게 탐지할까?
-> 정답은 시간 초과를 사용하는 것이다.

StateFlow의 수집이 중단되었을 때 모든 업스트림 flow를 즉시 중단하는 것은 아니다.
오히려 약 5초정도 잠시 기다린다. 시간 초과 전에 flow를 수집하면 업스트림 flow가 취소되지 않는다.

WhileSubscribed(5000)은 바로 그런 일을 하는 파라미터이다.

위의 표에는 앱이 백그라운드에 갔을 때의 반응을 시각적으로 볼 수 있다. 홈 버튼을 누르기 전에 뷰가 업데이트를 수신하고 StateFlow는 정상적으로 업스트림 flow를 생성한다.

이제 뷰가 중단되면 수집이 즉시 종료된다. 그러나 StateFlow는 구성 방법으로 인해
업스트림 flow를 중단하는데 5초가 걸리고, 제한시간이 지나면 업스트림 flow가 취소된다.

사용자가 앱을 다시 열 경우 업스트림 flow가 자동으로 다시 시작된다.

그러나 회전 시나리오에서 뷰는 5초 이내로 잠시 중단된다. 따라서 StateFlow는 절대 복원되지 않고
모든 업스트림 flow를 활성 상태로 유지하며 아무 일도 없었던 것처럼 사용자에게 회전 인스턴스를 보낸다.

다시말해서 StateFlow를 사용하여 ViewModel에서 flow를 노출하거나 asLiveData를 사용해서
이와 동일한 작업을 실행하는게 좋다.


Testing Flows

flow 테스트는 데이터 스트림이라 까다로울 수 있어서 몇 가지 요령을 살펴보도록 하자.
두 가지 시나리오가 있는데

첫 번째는 테스트 대상이 무엇이든 UnitUnderTest가 flow를 받는 시나리오이다.

이 경우에는 종속성을 가상 생산자로 교체하여 테스트하는 게 편리한데,
예를 들어 이 가상 리포지토리를 프로그래밍하여 각 테스트 사례에 필요한 걸 전송할 수 있다.

간단한 cold flow의 예를 들자면, 테스트 자체는 테스트 아래 주제의 결과에 대한 assertion을 만든다.
이는 flow나 다른게 될 수도 있다.

두 번째는 UnitUnderTest가 flow를 노출하고 이 값이나 값 스트림을 인증하고 싶다면
여러가지 방법으로 수집할 수 있다.

flow에서 first() 메서드를 호출하면 첫 항목을 수신하고 수집을 중단할 때 까지 계속 수집한다.

또한 take(5)와 같은 연산자로 toList terminal 연산자를 호출해 메세지를 5개만 수집할 수도 있는 유용한 방법이다.

더 많은 자료는 위 링크를 확인해주세요!

0개의 댓글