Subject
반응형 프로그래밍을 Kotlin에서 구현할 때, 사용하는 라이브러리 중 대표적인 것으로 Flow가 있습니다.
이번 파트에서는 Flow의 구조와 Android에서의 기본적인 사용 방법에 대해 기록하고자 합니다.
또한 Xml을 위한 DataBinding의 KSP 미지원 등의 제약이 생김에 따라 추세에 맞추어 권장사항인 Compose를 기반하여 작성합니다.
Kotlin Flow
Flow는 Coroutine을 기반으로 동작하는 비동기 데이터 스트림입니다.
Flow는 Sequence(Collection의 상위 클래스)를 생성하는 Iterator(Collection에 저장된 요소를 순회하기 위한 인터페이스)와 비슷하지만, Suspend(정지 함수)를 사용하여 비동기적으로 생성하고 소비합니다.
이로인해, 기본 스레드를 차단하지 않고 안전하게 데이터를 수집, 전파가 가능합니다.
데이터 스트림은 아래와 같이 3개의 항목으로 나뉩니다.
이는 앞서 살펴보았던 ReactiveX와 매우 유사한 구조를 갖습니다.
- Producer(생산자): Stream에 추가할 데이터를 생산합니다. Coroutine을 사용하기 때문에 Flow에서 비동기적으로 데이터를 생산할 수 있습니다.
- Intermediary(중개자): Stream에 내보내는 값이나, Stream 자체를 수정, 값 필터링 등이 가능합니다.
- Consumer(소비자): Stream에서 내보내는 값을 소비합니다. 값을 수집하여 이벤트를 수행합니다.
Android에서 Flow 사용하기
안드로이드에서 Flow는 비동기적으로 데이터를 가져와야하는 네트워크 호출에 주로 사용됩니다.따라서 위의 구조를 안드로이드에 맞춘다면 아래와 같이 연관됩니다.
※ 주의할 점
예제를 찾다보면 View와 ViewModel간의 데이터 전달 시, Flow가 아닌 LiveData를 사용하는 경우가 있습니다.
이 둘의 동작방식은 유사한 면이 많지만 차이점이 있습니다.
Xml을 이용한 레이아웃에 DataBinding을 사용한다고 가정합니다.
LiveData의 경우 Activity의 LifeCycle에 의존하여 onDestory 시 구독이 해제되며, onResume/onStart 상태에서 마지막 상태를 다시 전달합니다.
(Fragment의 경우 lifeCycleOwner 대신 viewLifeCycleOwner를 전달하여, onDestryView 상태를 onDestory로 판단할 수 있게끔 할 수 있습니다.)
그러나 Flow를 사용하고자 한다면 ViewModel 내에서 StateFlow 변수를 만들어 View에 데이터를 전달하는데,
기본적으로 lifeCycle에 의존하지 않으므로 대부분의 경우(예외도 있기 때문에..) StateFlow 변수 생성 시 WhileSubscrived() 속성을 추가하여 데이터의 흐름을 제어해야 합니다.
이 부분에 대해서는 추후 설명에 추가하겠습니다.
Flow(흐름) 만들기
이 파트의 예제는 Flow의 흐름을 파악하기 위한 코드로,
Domain 레이어와 같은 디자인 패턴, Hilt를 통한 의존성 주입, design system등의 일부 요소들을 생략하였습니다.
아래는 예시 코드입니다.
class WeatherRemoteDataSource @Inject constructor(
// Hilt를 사용한 Retrofit Service 주입
private val weatherService: WeatherService
) {
// Repository 에서 사용할 DataSource 선언
suspend fun getTodayWeather(location: String): WeatherTodayResponse = weatherService.getTodayWeather(location)
...
}
class WeatherRepositoryImpl @Inject constructor(
private val weatherRemoteDataSource: WeatherRemoteDataSource
) : WeatherRepo {
override fun getTodayWeather(location: String): Flow<String> {
return flow {
val todayWeather = weatherRemoteDataSource.getTodayWeather(location)
emit(todayWeather)
}
}
}
WeatherRemoteDataSource 클래스는 네트워크 응답을 요청하고 데이터를 반환하는 정지 함수들이 있습니다.
그리고 WeatherRepositoryImpl 클래스는 DataSource에 접근하여 가져온 데이터를 Flow 객체로 반환하는 역할을 합니다.
아래 WeatherRepositoryImpl 클래스 내의 getTodayWeather() 함수는 네트워크 응답을 받으면 결과값을 내보내는 Flow를 반환합니다.
Flow는 빌더 함수에 emit() 함수가 포함된 일급 객체를 전달하여, 새 값을 데이터 스트림으로 내보내는 Flow 객체를 만들 수 있습니다.
return flow {
val todayWeather = weatherRemoteDataSource.getTodayWeather(location)
emit(todayWeather)
}
ViewModel에서 Flow 수집
이제 Flow를 만드는 함수가 있으니, 비동기 데이터 스트림을 수집해야 합니다.
아래 코드는 viewModelScope 빌더를 이용하여, ViewModel이 살아있는 동안 Coroutine내에서 비동기로 날씨정보가 담긴 Flow를 수집합니다.
@HiltViewModel
class WeatherViewModel @Inject constructor(
// Hilt를 통해 WeatherRepository가 주입됩니다.
private val weatherRepository: WeatherRepository,
) : ViewModel() {
private val _todayWeather: MutableStateFlow<WeatherUiState> =
MutableStateFlow(WeatherUiState.Loading)
val TodayWeatherUiState: StateFlow<WeatherUiState> =
_todayWeather.asStateFlow()
fun updateTodayWeather(location: String) {
viewModelScope.launch {
weatherRepository.getTodayWeather(location).collectLatest { todayWeather ->
_todayWeather.value = todayWeather.Success(WeatherData(todayWeather))
}
}
}
...
}
sealed interface WeatherUiState {
object Loading : WeatherUiState
// 이번 파트에서는 사용하지 않습니다.
object Error : WeatherUiState
// WeatherData는 WeatherScreen.kt 파일에 선언합니다.
data class Success(val weatherData: WeatherData) : WeatherUiState
}
collectLatest 함수 는 종료 연산을 수행하며 전달된 일급객체에 데이터를 방출합니다.
위의 코드에서는 값을 수신할 때마다 _todayWeatherUiState에 수신한 날씨 값을 넣어줍니다.
수집된 데이터 띄우기
이제 Composable 내에서 updateTodayWeather() 함수 호출로 날씨 정보 데이터를 갱신할 수 있습니다.
ViewModel에 선언한 StateFlow인 TodayWeatherUiState는 Hot flow이므로 Consumer(소비)가 없어도 데이터를 방출합니다.
Cold Flow: 각각의 소비자(Consumer)가 수집을 시작하면 데이터를 발행합니다. (각 호출마다 실행되므로 서로 다른 Flow 객체로 부터 수집)
How Flow: 하나 이상의 소비자(Consumer)에게 동일한 값을 전달 할 수 있으며, 소비자가 없을 때도 데이터를 방출할 수 있습니다.
이 객체를 UI에 표시하기 위해 Activity와 Composable 함수를 사용합니다.
위에서 설명했듯히 설명을 위해 실제 앱 설계와 다른 단순한 구조로 Scaffold 등의 주요 Composable 함수를 제외하고 작성하였습니다.
@AndroidEntryPoint
class WeatherActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MyAppTheme() {
WeatherRoute(
// 종료 버튼 클릭 시 액티비티 종료
onNavigateBack = {
this.finish()
},
)
}
}
}
}
@OptIn(ExperimentalLifecycleComposeApi::class)
@Composable
fun WeatherRoute(
onNavigateBack: () -> Unit,
viewModel: WeatherViewModel = hiltViewModel() // WeatherViewModel 주입
) {
val context = LocalContext.current
val errorMsg = stringResource(id = R.string.roulettegame_error_msg)
// 오늘의 날씨 관련 Ui 상태
// Composable의 가시성이 사라지면 자동으로 수집 중단
val todayWeatherUiState by viewModel.TodayWeatherUiState.collectAsStateWithLifecycle()
// 처음 컴포지션 호출 시 기본값 위치로 날씨 정보 갱신
LaunchedEffect(key1 = true) {
viewModel.updateTodayWeather(DEFAULT_LOCATION)
}
MyAppTheme() {
Column(
modifier = Modifier
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
WeatherScreen(
modifier = Modifier
.wrapContentSize(),
weatherUiState = weatherUiState,
onError = {
context.showToast(errorMsg)
},
onNavigateBack = {
onNavigateBack()
},
}
}
}
const val DEFAULT_LOCATION = "seoul"
data class WeatherData(
val todayWeather: String,
)
@Composable
internal fun WeatherScreen(
modifier: Modifier = Modifier,
weatherUiState: WeatherUiState,
onError: () -> Unit,
onNavigateBack: () -> Unit,
) {
LaunchedEffect(key1 = weatherUiState) {
when (weatherUiState) {
// 로딩 중 원형 인디케이터 표시
is weatherUiState.Loading -> {
Box(modifier = Modifier.fillMaxSize()) {
CircularProgressIndicator(
modifier = Modifier
.align(Alignment.Center)
.size(30.dp)
)
}
}
// 날씨가 정상적으로 불러와지면 텍스트 표시
is weatherUiState.Success -> {
// 날씨 String 문자열
val weatherResult = weatherUiState.todayWeather
// 날씨 문자열 출력
Text(
modifier = Modifier
.wrapContentSize(),
text = stringResource(id = R.string.weather_result, weatherResult),
style = MaterialTheme.typography.semiBold.copy(
fontSize = dimensionResource(id = R.dimen.text_medium_size).toSp()
)
)
}
// 이번 파트에서는 사용되지 않습니다.
is weatherUiState.Error -> {
onError()
}
}
}
// 버튼 클릭 시 액티비티 종료
Button(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight(),
onClick = onNavigateBack,
) {
...
}
}
두번째 소스코드에서 WeatherRoute는 최상단 컴포저블입니다.
이곳에서는 상태 호이스팅을 통해 공동적으로 다루는 데이터를 유지하는데,
아래 구문을 통해 StateFlow를 라이프사이클에 따라 수집/중단 할 수 있으며, 새로운 데이터가 발행 될 때마다 리컴포지션(Recomposition)이 일어납니다.
val todayWeatherUiState by viewModel.TodayWeatherUiState.collectAsStateWithLifecycle()
새로운 데이터가 발행될 때 마다 리컴포지션이 발생하는데에는 Gap buffer를 이용하여 만들어진 Compose의 마법같은 구조 덕분입니다.
그 후 WeatherScreen는 WeatherUiState.Loading 또는 WeatherUiState.Success를 전달받고 화면에 표시하게 됩니다.
다음 파트에서는 Flow를 좀 더 적극적으로 활용할 수 있도록 StateFlow, SharedFlow의 활용과, 중간연산자와 에러 처리 등에 대해 서술하도록 하겠습니다.
감사합니다.
ⓒ 굿햄 2022. daryeou@gmail.com all rights reserved.
'Kotlin&Java > Reactive Programming' 카테고리의 다른 글
[Reactive Programming] 비동기 데이터 스트림 ReactiveX 개념 정리 (0) | 2023.03.01 |
---|---|
[Reactive Programming] 반응형 프로그래밍 이해하기 (1) | 2023.02.28 |
댓글