StateFlow 정리 # Android Kotlin Coroutine getStateFlow StateIn
오늘은 Kotlin의 StateFlow 에 대해서 정리해 보도록 하겠습니다.
StateFlow도 Flow API의 하나인데요.
Flow에 대한 내용은 아래 글을 참조해 주세요.
>> Kotlin Coroutine Flow 총정리 part3 # launchIn
이 글은 아래의 순서대로 정리되어 있습니다.
- StateFlow
- StateFlow 구현(학생 및 admin 등록과 조회)
- MutableStateFlow에 값을 변경하는 방법
- StateFlow의 값에 접근하는 방법
- Sealed Class와 함께 사용하기
- StateIn
- savedStateHandle과 같이 사용하기
1. StateFlow
StateFlow는 업데이트가 가능한 데이터 값을 가지는 State라는 것을 가지고,
collector에게 emit(전파)하는 인터페이스인데요.
SharedFlow를 상속받았습니다.
State를 가지고 있다가,
변경되면 데이터를 흘려보내주는(emit) 해주는 역할을 하는 핫스트림이라고 생각하면 좋은데요.
핫스트림과 콜드스트림의 차이에 대해서는 아래 글을 참조해 주세요.
>> Kotlin Coroutine Flow 총정리 part3 # launchIn
이 StateFlow는 Complete이라는 개념 없이,
여러 Collector들에 의해, 계속해서 값이 관찰될 수 있습니다.
이 StateFlow에서 흘러나오는 아이템들을 collect하는 것을 subscriber 라고 부릅니다.
2. StateFlow 구현 (안드로이드 ViewModel 에서 학생 및 admin 등록과 조회)
실제로 안드로이드에서 코드를 구현해 보면서,
이해해 보겠습니다.
학생 및 admin을 등록해서 조회하는 StateFlow를 구현하고,
Activity에서 받아 그려보는 과정을 코드로 구현해 보겠습니다.
2-1. ViewModel에서 mutable 과 immutable 변수 정의
StateFlow를 구현할 때는,
캡슐화를 해서,
mutable(변경할 수 있는)과 immutable(변경불가능한) 변수를,
아래와 같이 각각 생성해 줍니다.
- _students: ViewModel에서만 수정하도록 하는 private한 mutable 변수, MutableStateFlow타입으로 정의
- students: UI등에서 보고 반응할 수 있는 public한 immutable변수, StateFlow타입으로 정의
보통 mutable변수에 대해서는,
앞에 "_"(언더스코어)를 넣어주어서 구분해 줍니다.
StateFlow를 생성시에는 무조건 초기값이 필요합니다.
그것이 emptyList()이건, null이건, 특정 초기화값이던 넣어주어야 한다는 뜻 입니다.
여기서는 emptyList()로 설정하였습니다.
아래에서는 asStateFlow()함수를 사용해,
StateFlow()타입의 students를 생성하였는데요.
이 함수는 read-only타입의 StateFlow를 만들어 줍니다.
admin에 대해서도 아래와 같이 StateFlow를 정의해 주었습니다.
null로 초기화값을 설정하였습니다.
초기화를 해야한다고 해서, null이 아니어야 한다고 정하지는 않았으니까요.
2-2. Update 함수정의
이제 데이터들을 업데이트하기 위함 함수들을 viewModel에 넣어두겠습니다.
유저입력을 받고, 해당 데이터를 업데이트할 때 사용하면 되겠지요.
학생을 추가하는 것 뿐만이 아니라, 기존 학생의 데이터를 업데이트해주는 함수도 필요하겠지요.
ViewModel에 학생을 삭제하는 함수도 추가해 보겠습니다.
2-3. Activity
이제 ViewModel에서 작성한 함수들을 Activity 에서 실행해 보도록 하겠습니다.
위에서 사용한 collectAsState 함수는,
아래 코드에서 보듯이,
핫스트림인 StateFlow에서 최신값을 Collect해주는 함수입니다.
초기값으로 StateFlow.value 가 사용되어집니다.
실행해서 학생등록 버튼을 터치하면, 아래와 같이 동작하는 것을 볼 수 있습니다.
위에서 정의해 놓은 testStudent가 데이터로 들어와서 UI에 표현되는 것을 볼 수 있습니다.
3. MutableStateFlow에 값을 변경하는 방법 2가지
MutableStateFlow에 값을 변경하는 방법은 크게 2가지가 있는데요.
3-1. Value프로퍼티 사용하기
아래와 같이 testState을 viewModel에서 선언해 줍니다.
private val _testState = MutableStateFlow(0)
val testState: StateFlow<Int> = _testState.asStateFlow()
여기에 value프로퍼티를 이용해 값을 할당할 때는 아래와 같이 해주면 됩니다.
// 직접 값 할당
_testState.value = 1
_testState.value = 88
3-2. emit()함수 사용하기
value프로퍼티 뿐만이 아니라,
코루틴스코프 내에서 emit()함수를 사용해 값을 전달하는 방법도 존재합니다.
emit()은 suspend()함수이기 때문에,
코루틴내에서 흐름제어에 영향을 주는,
중단점을 제공해 줍니다.
viewModelScope.launch {
_uiState.emit(88)
}
일반적으로 value 프로퍼티를 더 많이 사용하며,
특별한 이유가 없다면 value를 사용하는 것이 간단합니다.
4. StateFlow의 값에 접근하는 방법 2가지
StateFlow에는 값에 접근하는 방법에도 크게 2가지가 존재하는데요.
위에서는 JetpackCompose에서 CollectAsState()함수를 이용해,
StateFlow값에 접근했었는데요.
StateFlow의 값에 접근하는 방법에는,
2가지가 존재합니다.
4-1. Value로 직접 접근하는 방법
먼저 ViewModel을 아래와 같이 작성해 줍니다.
private val _uiState = MutableStateFlow(0)
val uiState: StateFlow<Int> = _uiState.asStateFlow()
다음 Activity에서 아래와 같이 쉽게 접근할 수 있습니다.
대신 이 방법은 데이터가 변경되어도 자동으로 업데이트 되지 않기 때문에,
호출하는 당시에 값을 확인할 때 사용합니다.
fun checkCurrentValue() {
val currentNumber = viewModel.uiState.value // 현재 값을 즉시 가져옵니다
Log.d("TAG", "현재 값: $currentNumber")
}
한가지 경우를 더 보겠습니다.
만약, 가져오려고 하는 값이,
아래와 같이 sealed Class를 이용한 상태를 가지는 TestState이 있다고 가정해 보겠습니다.
sealed class TestState {
data object Init : TestState()
data class Loaded(val testValue: testValue) : TestState()
}
viewModel에는 아래와 같이 선언되어 있겠지요.
private val _testState = MutableStateFlow<TestState>(TestState.Init)
val testState = _testState.asStateFlow()
이럴 때는 smart cast를 활용하여,
아래와 같이 간단하게 가져올 수 있습니다.
val state = viewModel.testState.value
if (state is TestState.Loaded) {
binding.testText.text = state.testValue.name
}
4-2. collect함수로 지속적으로 업데이트 받는 방법
collect()는 StateFlow의 값을 지속적으로 관찰해서,
새로운 값이 emit될 때마다 collect블록을 실행합니다.
주로 UI업데이트에 적합한데요.
대신 코루틴 스코프내에서 실행되어야 하기 때문에,
lifecycleScope{}의 도움이 필요하겠지요.
lifecycleScope.launch {
viewModel.uiState.collect { number ->
// number가 변경될 때마다 이 블록이 실행됩니다
binding.textView.text = "숫자: $number"
}
}
만약 특정한 생명주기에서만 업데이트 받고 싶다면 아래와 같이 해 주면 됩니다.
lifecycleScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { number ->
binding.textView.text = "숫자: $number"
}
}
}
5. Sealed Class와 함께 사용하기
5-1. SealedClass와 함께 사용하기 예제1
상태를 Sealed Class에 정의해서,
StateFlow와 함께 사용하는 방법은 매우 효과적인데요.
먼저 아래와 같이 네트워크 상태를 정의한 SealedClass를 작성해 줍니다.
sealed class NetworkState {
data object Initial : NetworkState()
data object Loading : NetworkState()
data class Success(val data: String) : NetworkState()
data class Error(val message: String) : NetworkState()
}
ViewModel코드는 다음과 같습니다.
class MainViewModel : ViewModel() {
private val _networkState = MutableStateFlow<NetworkState>(NetworkState.Initial)
val networkState: StateFlow<NetworkState> = _networkState.asStateFlow()
fun fetchData() {
// 현재 상태가 Loading이면 중복 요청 방지
if (networkState.value is NetworkState.Loading) {
return
}
viewModelScope.launch {
_networkState.value = NetworkState.Loading
try {
// 네트워크 요청
delay(1000)
_networkState.value = NetworkState.Success("데이터 로드 성공!")
} catch (e: Exception) {
_networkState.value = NetworkState.Error("데이터 로드 실패: ${e.message}")
}
}
}
}
이제 Activity에서 아래와 같이 사용해 주면 됩니다.
네트워크 상태에 따라 미리 UI를 정의해 놓고 사용하였습니다.
class MainActivity : AppCompatActivity() {
...
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
...
setupClickListeners()
}
private fun setupClickListeners() {
binding.btnFetch.setOnClickListener {
// 현재 상태 확인 후 작업 수행
when (viewModel.networkState.value) {
is NetworkState.Loading -> {
// 이미 로딩 중이면 아무것도 하지 않음
Toast.makeText(this, "이미 로딩 중입니다", Toast.LENGTH_SHORT).show()
}
is NetworkState.Error -> {
// 에러 상태면 다시 시도
viewModel.fetchData()
}
else -> {
// 그 외의 경우 데이터 가져오기
viewModel.fetchData()
}
}
}
}
private fun updateUI(state: NetworkState) {
when (state) {
is NetworkState.Initial -> {
binding.progressBar.isVisible = false
binding.tvStatus.text = "시작하려면 버튼을 누르세요"
binding.btnFetch.isEnabled = true
}
is NetworkState.Loading -> {
binding.progressBar.isVisible = true
binding.tvStatus.text = "로딩 중..."
binding.btnFetch.isEnabled = false
}
is NetworkState.Success -> {
binding.progressBar.isVisible = false
binding.tvStatus.text = state.data
binding.btnFetch.isEnabled = true
}
is NetworkState.Error -> {
binding.progressBar.isVisible = false
binding.tvStatus.text = state.message
binding.btnFetch.isEnabled = true
}
}
}
}
5-2. 구현예제2
한가지 예를 더 보도록 하겠습니다.
먼저 아래와 같은 data class가 존재합니다.
data class Student(
val id: String,
val name: String,
val grade: Int,
val className: String
)
Network의 성공이나 실패에 따른 케이스를 아래와 같이,
좀 더 자세하게 작성해 보았습니다.
sealed class NetworkState {
data object Loading : NetworkState()
data class Success(val student: Student) : NetworkState()
data class TimeOut(val isManualRetryNeeded: Boolean) : NetworkState()
data class Fail(
val e: Exception,
val toastMsg: String,
val retryMsg: String,
val showRetry: Boolean
) : NetworkState()
}
아래와 같이 StateFlow의 mutable과 immutable 변수를 선언해 줍니다.
이제 Activity에서는 아래와 같이 사용할 수 있습니다.
when (val state = networkState.value) {
is NetworkState.Loading -> showLoading()
is NetworkState.Success -> {
val student = state.student // Student 데이터에 접근 가능
displayStudentInfo(student)
}
is NetworkState.TimeOut -> handleTimeout(state.isManualRetryNeeded)
is NetworkState.Fail -> handleFailure(state)
}
6. stateIn
6-1. stateIn
stateIn함수는 원래 cold 스트림인 Flow를,
Hot스트림인 StateFlow로 전환시켜 주는데요.
그냥 flow가 아니라, StateFlow로 launch시켜주는 것이 StateIn()함수입니다.
리턴타입은 당연히 StateFlow의 제네릭타입입니다.
예를 들기 위해 한번 사례를 만들어보겠습니다.
만약 위에서와 같이 Stateflow를 이용해서 학생을 등록하고,
등록한 학생중에 특별한 학생을 찾으면 UI에 표시하려고 합니다.
이 특별한 학생의 id는 "s"로 시작한다고 가정해 보구요.
이 학생이 데이터에 들어오면 바로 찾고 UI에 반영하려고 하는데요.
이를 위해서 viewModel에서 아래와 같이 해 줍니다.
위에서 만든 immutable변수인 students에 map으로 찾는 것 까지는 알겠는데,
stateIn함수는 무엇일까요?
아래의 StateIn함수에서 인자로 들어간 값 들은 다음과 같은데요.
- viewModelScope: viewModelScope 에서 flow를 사용한다는 의미
- SharingStarted.WhileSubscribed: 이 변수를 subscribe하고 있을 때만 동작함을 의미
- null: 초기값을 null로 설정함을 의미
이제, student 가 업데이트 되거나 저장될 때,
specialStudent가 나오면 그것을 알게되어서 UI를 업데이트할 수 있습니다.
이런식으로, 다른 데이터를 변경할 때, 그것에 따라서 변경되는 데이터가 있다면,
StateIn을 활용해 주면 좋습니다.
6-2. combine 과 stateIn
combine 오퍼레이터를 이용하면 여러개의 아이템중 하나만 흘러나와도 바로 UI를 업데이트 할 수 있는데요.
flow를 핫스트림으로 만들어주는 stateIn과의 조합이 매우 좋습니다.
아래가 딱 맞는 예는 아니지만, 아래와 같은 식으로 combine과 stateIn 을 활용할 수 있는데요.
만약 teacher라고 하는 flow변수를 정의해 놓았다면,
students이건, teacher이건 둘중 하나의 아이템만 업데이트가 되어서 흘러나와도,
바로바로 UI에 반영할 수 있게 됩니다.
7. savedStateHandle과 같이 사용하기
savedStateHandle과 StateFlow를 같이 사용할 수도 있는데요.
코드의 주석에 보면, ViewModel에 내려오는 저장된 상태에 대한 핸들이라고 설명되어 있는데요.
쉽게 말하면, 안드로이드를 잠시 사용하다가 다른 앱으로 전환하거나 어떤 이유로 다른 화면에 갔다가 돌아왔을 때,
전환되기 전 그상태를 저장했다가 다시 받아올 수 있는 핸들러입니다.
유저를 위해 기존의 입력하거나 변화시켰던 값을 그대로 유지하려면 이것이 꼭 필요합니다.
이 객체는 아래와 같이, ViewModel의 생성자에서 받아야 한다고 나와있습니다.
이 SavedStatedHandle은 getStateFlow()라는 함수를 아래와 같이 제공해 주는데요.
무려, StateFlow를 emit해 줍니다.
아래와 같이 기존의 immutable한 변수와 mutable한 변수는 사라지고,
아래 코드 한줄로 대체할 수 있습니다.
savedStateHandle 데이터를 저장할 때,
아래와 같은 형식으로 쉽게 저장할 수 있습니다.
savedStateHandle["key이름"] = value
간단한 변수나 상태 등을 저장할 때,
코드가 크게 간결해진다는 장점이 있는데요.
다만, 위의 student 처럼 list를 저장할 경우,
MutableStateFlow가 가지고 있는 update함수의 도움을 받지 못하기 때문에,
오히려 코드가 길어질 수 있습니다.