본문 바로가기

안드로이드 Android

안드로이드 개발 (33) Coroutine Flow on Android

안녕하세요 Loner 입니다. 스터디에서 발표를 했던 Android 에서의 Coroutine Flow 활용을 블로그에 다시 정리합니다.

입문용에 가까운 내용이기 때문에 설명이 축약된 부분이 많을 수 있습니다. 

 

판초라는 청년이 산에 살고 있었습니다. 
판초는 물을 길러오기 위해서 호수에 가야합니다.

 

하지만 호수를 찾아갔더니 호수의 물이 말라있었습니다.

직접 호수를 찾아가지 않고 물을 길러오는 동시에 물이 말랐는지 확인할 방법을 찾던 판초는

호수와 연결된 기나긴 통로를 만들면 어떨까 생각을 하게 됩니다.

 

결국 판초는 집에서 호수까지 연결되는 기나긴 파이프를 설치합니다.

그렇게 직접 호수에 찾아갈 일은 사라졌습니다.

호수에 물이 말라 있다면 파이프에 물이 흐르지 않을 것이고

호수에 물이 남아있다면 파이프에 물이 흐를것 입니다. 

 

하지만 호수에 물이 말랐을 때, 판초는 다른 호수를 찾아가서 직접 길러야했습니다.

그래서 판초는 이미 연결된 파이프 중간에 다른 호수와 연결되는 파이프를 만들었습니다.

판초는 한 호수가 물이 나오지 않더라도 다른 호수를 통해 최종적으로 물을 받을 수 있습니다.

판초가 굳이 물이 흐르지 않는 호수를 알 필요도 없어졌으며, 직접 호수에 갈 필요도 없습니다.

 

이제 부터 판초의 사례를 통해서 반응형 프로그래밍부터 시작해서 Coroutine Flow의 활용을 얘기해보도록 하겠습니다.

크게 3가지 분류로 나눠서 이야기를 풀어갈 예정입니다.

 

반응형 프로그래밍 (Reactive Programming)
- Flow 생성, 변환, 그리고 수집 (Creating,transforming, and observing flows)
안드로이드 UI 에서 Flow (Flow in the Android UI)

 

1. 반응형 프로그래밍  

(1) 명령형 프로그래밍 

판초가 파이프를 설치하기 전의 모습이라고 예를 들 수 있습니다. 판초의 집이 View영역이고 호수가 Data 영역이라고 할때, 판초가 호수를 다녀오는 과정은 아래와 같이 됩니다.

View -> ViewModel -> Repository -> DataSource -> Repository -> ViewModel -> View  

 

(2) 반응형 프로그래밍

판초가 파이프를 설치했을 때의 모습입니다. 판초가 직접 확인하러 가는것이 아니라 최종적으로 물을 받기만 하면 됩니다. 흘러나오는 데이터를 그저 수집하고만 있습니다. 

View <- ViewModel <- Repository <- DataSource <- Remote Or Local Server
  
반응형 프로그래밍의 경우 판초의 사례 처럼 다시 호수에 갈일이 사라지고 업스트림을 관찰하고 있을 뿐입니다.

반응형 프로그래밍의 대부분 옵저버 패턴으로 이루어져있습니다.

반응형 프로그래밍에 대한 정확한 이해는 아주 많은 내용들을 살펴봐야 할 것 입니다.

 

판초가 설치한 파이프가 데이터 스트림의 경우라고 대입해서 비교를 하고 아래 내용들을 설명할 것입니다.

안드로이드 개발자 사이에서 RxJava를 사용해서 예전부터 반응형 프로그래밍을 해왔었습니다.

이 글에서는 Coroutine Flow 를 이용해서 안드로이드에서 반응형 프로그래밍을 구현하는 내용을 입문용으로 정리하려고 합니다.

 

2. Coroutine Flow

아래 내용은 간단한 Flow의 개념부터 생성, 변환, 수집 하는 것을 순차적으로 정리 하겠습니다.

(1) Flow 란?

 

https://medium.com/hongbeomi-dev/kotlin-coroutine-flow-ac07cfdca42d

 

Kotlin coroutine flow

코틀린 코루틴 플로우 공식 블로그 [번역]

medium.com

(HongBeomi 님이 flow 원문을 한글로 번역한 글)

 

코틀린의 플로우는 순차적으로 값을 내보내고 정상적으로 또는 예외로 완료되는 비동기적인 데이터 스트림입니다.

시골 청년 판초의 사례로 봤을 때 호수에 설치한 파이프로 대입할 수 있습니다. 

 

이 글에서 Flow는 크게 2가지 개념을 설명 할겁니다. 

- Producer
Producer 는 데이터 생산해내어 방출을 하는 구간입니다.

시골 청년 판초의 사례로 보면 호수의 파이프 입구인 것 입니다. 


- Collect

Collect 는 Producer 에서 생성한 데이터를 받는 구간입니다.

시골 청년 판초의 사례로 보면 파이프에서 최종적으로 물을 받고 있는 출구입니다.

 

(2) Flow Bulider (Flow 생성)

판초의 사례로 봤을 때 Flow 생성은 많은 호수의 물을 적정량으로 파이프에 방출하는 댐으로 비유할 수 있겠습니다.

- 판초가 5초마다 호수에서 파이프에 물을 방출한 상황일때 DataSource 클래스에서 아래와 같이 작성이 가능합니다

class WaterDataSource(
 private val api:LakeApi
 private val refresh:Long = 5000
){
  val waterList = Flow<List<Water>> = flow{
  whlie(true){
      emit(api)
      delay(refresh)
    }
  }
}

Flow<T> 로  flow를 생성 할 수 있습니다.
emit() 함수가 데이터를 흘러보내는 기능을합니다. 
flow{....} 안에서 데이터를 emit 하는 구간을 Producer Block 이라고 부릅니다. 
판초는 위 코드를 통해 5초마다 물을 받게 됩니다.

 

(3) Flow 변환

Flow 를 생성해서 emit 을 했다면 바로 UI 계층에 넘기기 전에 데이터 변환이 가능합니다.   
판초가 만약 물을 마시기위한 용도로 받는다면 호수의 더러운물을 걸러내는 기능이 필요할 것 입니다.

 

class WaterRepository(
 private val dataSource:WaterDataSource
){
 val resultList = dataSource.waterList.map{
 it.마실수있는물로()
 }.filter{
  it.물상태등급 == A급 
 }.catch{ e ->
     if(e is illegalargumentexception) throw e 
     else emit(emptyList)
  }
}


위 코드처럼 다른 모델로 변환하거나 데이터를 필터링 할 수 있고 예외 값을 던지거나 예외처리가 가능합니다. 
위 코드에서 catch 이후의 코드는 다운 스트림이라 부릅니다.

 

(4) Flow 수집

최초 방출 ~ 변환 라인을 지나온 데이터는 최종적으로 수집하는 곳에 도착하게 됩니다.

판초는 물을 언제든지 받을 수 있게 되었습니다.

 viewModel.waterList.collect{
      //판초가 물을 받음
  }


collect 함수를 통해서 Flow 에서 흐르는 데이터를 수집하게 됩니다. 기본적인 Flow의 collect 는 생성한 데이터 스트림의 데이터를 수집 하고 다른 데이터 스트림과 공유하지 않습니다.  위 같은 경우 Cold Flow 라고 부릅니다. 

이제 판초는 5초마다(생성) 깨끗한 물을(변환) 계속 받을 수 있게 되었습니다.(수집)

하지만 문제가 있습니다.

 

3. Android 에서의 Flow 수집

판초는 이를 닦거나, 잠을 잘때에 파이프에서 물을 받고 싶지 않습니다. 그래서 상황에 따라 자동적으로 파이프의 출구를 닫아야합니다. 

 

Android와 상황을 대입해보면 판초가 이를 닦거나 잠을 잘때의 상황은 Background에 있는 상황일 겁니다. Background에서 collect가 유지가 된다면 collect 에서 어떠한 데이터를 받을시 Dialog를 띄운다고 하는 구문이 있다면 앱 크래쉬가 생길겁니다. 

 

그래서 LifeCycle에 따라 효율적으로 수집을 유지하거나 닫을지 결정하는 Api를 Android 에서 제공합니다. 

 

이 Api는 androidx.lifecycle:lifecycle-*:2.4.0 버전부터 안정화 되어 지원됩니다.

*아래 설명 보기전에 참고로, Flow 는 coroutine Builder 혹은 suspend function 안에서만 사용할 수 있기 있음


(1) repeatOnLifeCycle

coroutine Scope 내에서 repeatOnLifeCycle 함수를 사용할 수 있습니다. 인자로 LifeCycle 을 받아서 처리합니다.

repeatOnLifeCycle은 여러 launch를 만들어 셋팅할 수 있도록 하는 용도입니다.

lifecycleScope.launch{
  repeatOnLifeCycle(LifeCycle.State.STARED){
      viewModel.dataList.collect{
        //판초가 마실 물을 받음 
      }
      
      launch{
       // 판초가 샤워할 물을 받음
      }
      launch{
      // 판초가 흙탕물을 받음
      }      
   }
}

 


(2) flowWithLifeCycle 사용

주로 collect 를 하나만 사용할때 사용 합니다. repeatOnLifeCycle의 경우 다른 launch를 내부에서 만들수 있었지만 flowWithLifeCycle은 하나의 collect를 보일러플레이트 없이 작성할 수 있도록 돕습니다.

lifecycleScope.launch{
    viewModel.dataList.flowWithLifeCycle(lifecycle,State.STARED).collect{
        //판초가 물을 받음 
    }
}

 

(3) lifecycleScope.launch 혹은 lifecycleScope.when..만 사용하지 않는 이유?

기존에 lifecycleScope 를 안다면 위의 두 api를 사용하지 않고 lifecyclerScope만 써도 해결되지 않을까 라는 의구심이 생길겁니다.
하지만 항상 collect 가 먼저 실행 되고 UI 업데이트가 되기 때문에 안전하지 못한 코드가 된다고 합니다.

 

- lifecycleScope.launch 만 사용했을 경우, 실제로 백그라운드에서 수집이 됩니다.   

- lifecycleScope.launchWhenStared 를 사용했을 경우 백그라운드에서 수집은 하지 않지만 Flow 생산자를 계속 활성화시켜서 화면에서 사용한적 없는 데이터로 메모리를 채우게 된다고 합니다.

 

(4) 구성 변경 시 

하지만 구성변경에 대해서도 대응이 필요합니다. 대표적으로 화면이 세로였다가 가로로 바뀌는 순간이 있습니다.

구성 변경이 생길 시 액티비티는 다시 시작하지만, 뷰모델은 계속 유지 됩니다. 여기서 문제는 기본적인 Flow의 collect는 cold Flow 입니다. coldFlow의 경우 collect가 호출되면 새로운 데이터 스트림을 연결합니다. 

그래서 화면 변경시 Activity의 onCreate가 다시 호출되면서 collect 를 호출하는 순간 데이터스트림을 새로 생성합니다.

// 액티비티 내의 코드..
lifecycleScope.launch{
// 화면 변경시 아래 collect 가 다시 실행됨 
    viewModel.dataList.flowWithLifeCycle(lifecycle,State.STARED).collect{
       ...
    }
}

 

  //ViewModel의 코드.. 액티비티의 onCreate 때문에 아래 repository.dataList가 다시 셋팅 됨
  val result = Flow<Result<UiState>> = flow{
     emit(repository.dataList)
  }


위와 같은 상황을 막아야하는 상황이면 Flow 대신 StateFlow 를 사용합니다. 

- StateFlow
  StateFlow 는 Hot Flow 입니다. 
  HotFlow의 경우 Collect 가 없더라도 데이터를 보관합니다.
  판초의 사례로 예를들면 StateFlow 는 물탱크 개념입니다. 모았다가 꺼내쓸 수 있게 됩니다.

val result:StateFlow<Result<UiState>> = repository.dataList.stateIn(
...
)

 

stateIn()은 StateFlow 로 변환하는 함수 입니다. StateFlow로 구성 변경을 대비할 수 있습니다.
   

 

4. 그외..


(1) 기존 LiveData 활용 

flow 의 collect로 수집하고 않고 기존 LiveData를 응용하고 싶다면 asLiveData() 함수를 통해 flow를 liveData로 변환해서 사용할 수 있습니다.
   

//ViewModel 에서..
val result:LiveData<Data> = reposistory.dataList.asLiveData()



(2) Android 에서의 Flow Bulider 가 지원되는 Api   


Android 에서 Flow 를 지원하는 Api 가 있습니다.   
대표적으로 Room, WorkManager, DataStore 가 있습니다.   


Room 에서 기본 지원 되는 Flow 는 해당 테이블의 데이터가 변경이 생기면 바로 emit 을 합니다.

//Room에서의 Flow
@Dao
interface TestDao{
   @Query("SELECT...")
   fun dataList():Flow<List<TestData>>
}

 

이상 Coroutine Flow에 대해 알아봤습니다. Flow를 알면 알수록 더 깊게 공부를 해야할 필요성이 느껴집니다. 앞으로 android Api 보다 Kotlin Api를 더 활용하는 모습으로 발전해가는 듯 합니다. 이상입니다.