본문 바로가기
오늘은 뭘 배울까?/삽질 기록

stateflow가 같은 값을 update 하는 이유

by Kim Juhwan 2023. 1. 26.

1. 증상
2. 한 번만 update하는 stateflow 예제

3. 여러 번 update하는 stateflow 예제
4. 해결 방법

    4-1. state flow 나누기
    4-2. state를 관리하지 않고 단발성으로 emit 하기
5. 결론

 

 

 


 

 

1. 증상

stateflow는 분명 이전 값과 현재 값이 일치하면 다시 update하지 않는다.

예를 들어 이전 값이 false였고 현재 값도 false면 update 하지 않으며

현재 값이 true가 되면 그때서야 update 함수가 먹힌다.

 

하.지.만

내 코드에서 stateflow를 collect 하는 부분이 계속 호출되는 문제가 발생했다.

로그를 찍어보니 계속 똑같은 값을 update 하고 있는데 내 머리로는 이해가 가지 않았다.

알고 보니 엄청 허무하고 부끄러운 실수였는데, 이 삽질에 대해 적어보려 한다.

 

2. 한 번만 update 하는 stateflow 예제

class MainActivity : AppCompatActivity() {
    private val viewModel: MainViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        subscribeToObservers()
        val button = findViewById<Button>(R.id.btn)
        button.setOnClickListener {
            viewModel.stopLoading()
        }
    }

    private fun subscribeToObservers() {
        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                launch {
                    viewModel.uiState.collect {
                        Log.d(TAG, "isLoading: ${it.isLoading}")
                    }
                }
            }
        }
    }

    companion object {
        const val TAG = "TODAY_CODE"
    }
}
class MainViewModel : ViewModel() {
    private val _uiState = MutableStateFlow(MainUiState())
    val uiState = _uiState.asStateFlow()

    fun stopLoading() {
        _uiState.update {
            it.copy(
                isLoading = false
            )
        }
    }
}

data class MainUiState(
    val isLoading: Boolean = false,
)

stateflow를 사용하는 간단한 예제를 만들어봤다.

버튼을 누르면 isLoading을 false로 update하는 함수가 호출된다.

앱을 실행하면 처음에 로그가 한 번 찍히고

그 뒤로는 버튼을 아무리 눌러도 로그가 찍히지 않는다.

왜냐하면 false -> false로 값이 변하지 않기 때문이다.

 

아주 정상적인 아무런 문제가 없는 예제이다. 👍

 

3. 여러 번 update하는 stateflow 예제

class MainActivity : AppCompatActivity() {
    private val viewModel: MainViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        subscribeToObservers()
        val button = findViewById<Button>(R.id.btn)
        button.setOnClickListener {
            viewModel.increaseNumber()
        }
    }

    private fun subscribeToObservers() {
        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                launch {
                    viewModel.uiState.collect {
                        Log.d(TAG, "isLoading: ${it.isLoading}")
                    }
                }
            }
        }
    }

    companion object {
        const val TAG = "TODAY_CODE"
    }
}
class MainViewModel : ViewModel() {
    private val _uiState = MutableStateFlow(MainUiState())
    val uiState = _uiState.asStateFlow()

    fun increaseNumber() {
        _uiState.update {
            it.copy(
                number = it.number + 1
            )
        }
    }
}

data class MainUiState(
    val isLoading: Boolean = false,
    val number: Int = 0,
)

[목차 2]의 예제를 살짝 바꿔보았다.

MainUiState에 number라는 변수를 추가하고

버튼이 눌릴 때마다 숫자를 1씩 증가시켰다.

로그는 여전히 isLoading을 찍는다.

이때 버튼을 클릭하면 로그가 찍힐까 안 찍힐까?

 

같은 값인데도 로그가 찍힌다.

 

정답은 로그가 찍힌다!이다.

분명 false라는 같은 값을 가지고 있음에도 계속 collect에서 값을 수집한다.

 

그 이유는 위 코드에서 isLoading이 변할 때마다 update가 되는 것이 아니라

MainUiState가 변할때마다 update가 되기 때문이다.

(false, 0) -> (false, 1) -> (false, 2) -> ...

이렇게 isLoading 값이 변하지 않더라도 data class 내 다른 값이 변한다면

update를 하게 되고 Activity의 collect 부분에서는 계속 값을 수집하게 되는 것이다.

 

지금 생각하면 너무 당연한 것인데

"stateflow는 같은 값을 emit 하지 않는다"라는 원칙이 머릿속에 박혀있던 나는 이 부분이 헷갈렸던 것이다. (바보..)

 

4. 해결 방법

4-1. stateflow 나누기

class MainActivity : AppCompatActivity() {
    private val viewModel: MainViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        subscribeToObservers()
        val button = findViewById<Button>(R.id.btn)
        button.setOnClickListener {
            viewModel.increaseNumber()
        }
    }

    private fun subscribeToObservers() {
        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                launch {
                    viewModel.loading.collect {
                        Log.d(TAG, "isLoading: ${it.isLoading}")
                    }
                }
                launch {
                    viewModel.uiState.collect {
                        Log.d(TAG, "number: ${it.number}")
                    }
                }
            }
        }
    }

    companion object {
        const val TAG = "TODAY_CODE"
    }
}
class MainViewModel : ViewModel() {
    private val _loading = MutableStateFlow(Loading())
    val loading = _loading.asStateFlow()

    private val _uiState = MutableStateFlow(MainUiState())
    val uiState = _uiState.asStateFlow()

    fun increaseNumber() {
        _uiState.update {
            it.copy(
                number = it.number + 1
            )
        }
    }
}

data class MainUiState(
    val number: Int = 0,
)

data class Loading(
    val isLoading: Boolean = false,
)

첫 번째 해결 방법은 state를 나누는 것이다.

다른 값의 변화에 영향을 받지 않아야 하는 값이 있다면 state를 각각 만들면 문제는 해결된다.

 

실행 결과

 

요로코롬 로그도 예상하는 대로 잘 찍히는 것을 확인할 수 있다.

 

4-2. state를 관리하지 않고 단발성으로 emit 하기

class MainActivity : AppCompatActivity() {
    private val viewModel: MainViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        subscribeToObservers()
        val button = findViewById<Button>(R.id.btn)
        button.setOnClickListener {
            viewModel.increaseNumber()
        }
    }

    private fun subscribeToObservers() {
        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                launch {
                    viewModel.uiState.collect {
                        Log.d(TAG, "number: ${it.number}")
                    }
                }
                launch {
                    viewModel.uiEffect.collect {
                        when (it) {
                            MainUiEffect.StartLoading -> Log.d(TAG, "isLoading: true")
                            MainUiEffect.StopLoading -> Log.d(TAG, "isLoading: false")
                        }
                    }
                }
            }
        }
    }

    companion object {
        const val TAG = "TODAY_CODE"
    }
}
class MainViewModel : ViewModel() {
    private val _uiState = MutableStateFlow(MainUiState())
    val uiState = _uiState.asStateFlow()

    private val _uiEffect = MutableSharedFlow<MainUiEffect>()
    val uiEffect = _uiEffect.asSharedFlow()

    fun increaseNumber() {
        _uiState.update {
            it.copy(
                number = it.number + 1
            )
        }
    }
}

data class MainUiState(
    val number: Int = 0,
)

sealed interface MainUiEffect {
    object StartLoading : MainUiEffect
    object StopLoading : MainUiEffect
}

만약 state를 가지고 있지 않아도 되는 값이라면,

viewModel에서 Activity로 단발성 이벤트를 쏴도 되는 상황이라면 위와 같이 코드를 작성하는 방법도 있을 것이다.

 

5. 결론

stateflow가 같은 값을 emit 하지 않는 것은 맞지만

data class 내의 변수 중 하나의 값이라도 변하면 전부다 collect 된다는 것을 유의해야 한다.

 

또, [목차 3]에서 간단한 예제로 버튼을 클릭하면 update가 되는 상황을 만들었지만

코드가 복잡해지면 자칫하다가 무한루프에 빠지는 상황이 올 수도 있다.

실제로 내가 잘못된 코드를 짜서 api 호출을 미친 듯이 때린 적도 있다.

 

만약 collect 하는 부분에서 api 호출을 하는 로직이 필요하다면,

중복된 호출이 발생하지 않을지 유심히 검토해봐야 할 것 같다.

또, 정말 collect에서 호출을 해야 하는지 의심해 보자.

viweModel에서 바로 호출할 수 있지 않은지,

api 호출을 위한 state 검사 로직이 꼭 collect에서 수행되어야 하는지,

viewModel에서 할 수 없는지 의심해 보자.

 

아무튼 이렇게 해서 내 하루를 통째로 날린 삽질 기록. 끝!

 

 


💡 느낀 점

  • state를 여러 개 만들기는 싫고 한 번에 관리하는 게 편한데... 상황에 따라 분리를 해줘야 하니 흠
    내가 모르는 또 더 좋은 방법이 있을까?
  • 내가 직면한 문제를 해결하기 위해 필요한 코드는 단 2줄이었는데
    삽질하면서 많은 걸 배울 수 있었다. 하하 🙃

 

 

반응형

댓글