[ 안드로이드 ] Handler를 통한 Viewmodel 변수 상태 체크하기
기존에 안드로이드 프로젝트에 MVVM만 적용했을 땐, ViewModel을 기능 기준으로 분류를 했었다. 화면마다 ViewModel를 만들기에는 사용하는 Repository가 너무 많이 겹쳤기 때문에 클래스간 의존성이 너무 높아져서 기능별로 ViewModel-Repository 세트를 구현한다음에, 뷰에서 필요한 ViewModel을 가져다 쓰는 방식으로 개발을 진행했었다.
개발하면서도 위의 문제가 거슬렸었는데, 최근 클린 아키텍쳐를 공부하면서 해결 방안을 찾은 것 같다.
클린 아키텍쳐에선 Repository의 기능 중 필요한 기능만 Use Case로 뽑아서 쓰고, Use Case는 Repository 인터페이스를 참조하기 때문에 의존성이 높지 않다.
위의 방식을 이용하면 화면마다 ViewModel을 만들어도 크게 부담스럽지 않다. 오히려 화면마다 ViewModel을 만들 수 있어서 관리가 더욱 편할 듯하다.
ViewModel을 공용으로 쓸 때는 여러 화면에서의 상태 변수들이 하나의 ViewModel에 모여있는 문제가 있었는데, 클린 아키텍쳐를 도입하면서 이러한 문제들이 어느정도 해결이 되었다.
특정 화면의 상태 변수만 관리할 수 있게 되어서 Handler를 통한 상태 체크 방법을 기록하고자 한다.
State 클래스 생성
sealed class LoginActivityState {
object Init : LoginActivityState()
data class IsLoading(val isLoading: Boolean) : LoginActivityState()
data class ShowToast(val message: String) : LoginActivityState()
data class SuccessLogin(val loginEntity: LoginEntity) : LoginActivityState()
data class ErrorLogin(val rawResponse: WrappedResponse<LoginResponse>) : LoginActivityState()
}
State 클래스 내에는 총 4가지 상태를 나타내는 클래스가 있다.
- IsLoading 클래스
- 로딩 중임을 나타내는 클래스, 매개변수로 로딩 플래그 변수를 전달 받는다.
- ShowToast 클래스
- Toast 메세지를 출력하는 클래스, 매개변수로 출력할 메세지를 전달 받는다.
- SuccessLogin 클래스
- 로그인 성공을 나타내는 클래스, 로그인 정보를 매개변수로 전달 받는다.
- ErrorLogin 클래스
- 로그인 실패를 나타내는 클래스, 에러에 대한 응답을 매개변수로 전달 받는다.
로그인을 담당하는 LoginActivity 클래스를 예시로 작성했지만, 화면의 역할에 따라 다양한 상태 클래스를 만들어 활용하면 좋을 듯하다.
상태 변수(state) 선언
@HiltViewModel
class LoginViewModel @Inject constructor(private val loginUseCase: LoginUseCase): ViewModel() {
private val state = MutableStateFlow<LoginActivityState>(LoginActivityState.Init)
val mState: StateFlow<LoginActivityState> get() = state
/.../
}
LoginActivity의 State를 관리할 변수를 생성한다. StateFlow로 선언하여 Observing이 가능하도록 한다. (Handler가 해당 변수를 관찰하며 상태값에 따른 처리를 할 예정)
private fun setLoading(){
state.value = LoginActivityState.IsLoading(true)
}
private fun hideLoading(){
state.value = LoginActivityState.IsLoading(false)
}
private fun showToast(message: String){
state.value = LoginActivityState.ShowToast(message)
}
상태에 따른 동작을 행할 메소드를 지정해준다. 로그인 성공, 에러에 대한 처리는 login API 요청 응답을 받으면서 처리하면 되기 때문에 Loading과 Toast 메세지에 대한 메소드만 작성해준다.
요청 결과에 따른 상태 값 세팅
fun login(loginRequest: LoginRequest){
viewModelScope.launch {
loginUseCase.execute(loginRequest)
.onStart {
setLoading()
}
.catch { exception ->
hideLoading()
showToast(exception.message.toString())
}
.collect { baseResult ->
hideLoading()
when(baseResult){
is BaseResult.Error -> state.value = LoginActivityState.ErrorLogin(baseResult.rawResponse)
is BaseResult.Success -> state.value = LoginActivityState.SuccessLogin(baseResult.data)
}
}
}
}
UseCase로 요청을 보냄과 동시에 로딩 상태로 전환한다.
에러가 생기면 로딩을 중지하고 토스트 메세지로 에러를 출력한다.
요청이 완료되면 응답 결과에 따라 상태를 지정한다.
ViewModel 전체 코드
@HiltViewModel
class LoginViewModel @Inject constructor(private val loginUseCase: LoginUseCase): ViewModel() {
private val state = MutableStateFlow<LoginActivityState>(LoginActivityState.Init)
val mState: StateFlow<LoginActivityState> get() = state
private fun setLoading(){
state.value = LoginActivityState.IsLoading(true)
}
private fun hideLoading(){
state.value = LoginActivityState.IsLoading(false)
}
private fun showToast(message: String){
state.value = LoginActivityState.ShowToast(message)
}
fun login(loginRequest: LoginRequest){
viewModelScope.launch {
loginUseCase.execute(loginRequest)
.onStart {
setLoading()
}
.catch { exception ->
hideLoading()
showToast(exception.message.toString())
}
.collect { baseResult ->
hideLoading()
when(baseResult){
is BaseResult.Error -> state.value = LoginActivityState.ErrorLogin(baseResult.rawResponse)
is BaseResult.Success -> state.value = LoginActivityState.SuccessLogin(baseResult.data)
}
}
}
}
}
sealed class LoginActivityState {
object Init : LoginActivityState()
data class IsLoading(val isLoading: Boolean) : LoginActivityState()
data class ShowToast(val message: String) : LoginActivityState()
data class SuccessLogin(val loginEntity: LoginEntity) : LoginActivityState()
data class ErrorLogin(val rawResponse: WrappedResponse<LoginResponse>) : LoginActivityState()
}
Handler 선언
State의 값에 따라 행동을 정할 Handler 메소드를 선언한다.
private fun handleStateChange(state: LoginActivityState){
when(state){
is LoginActivityState.Init -> Unit
is LoginActivityState.ErrorLogin -> handleErrorLogin(state.rawResponse)
is LoginActivityState.SuccessLogin -> handleSuccessLogin(state.loginEntity)
is LoginActivityState.ShowToast -> showToast(state.message)
is LoginActivityState.IsLoading -> handleLoading(state.isLoading)
}
}
각 상태마다 실행할 메소드를 선언한다.
private fun handleErrorLogin(response: WrappedResponse<LoginResponse>){
showGenericAlertDialog(response.message)
}
private fun handleLoading(isLoading: Boolean){
binding.loginButton.isEnabled = !isLoading
binding.registerButton.isEnabled = !isLoading
binding.loadingProgressBar.isIndeterminate = isLoading
if(!isLoading){
binding.loadingProgressBar.progress = 0
}
}
private fun handleSuccessLogin(loginEntity: LoginEntity){
sharedPrefs.saveToken(loginEntity.token)
showToast("Welcome ${loginEntity.name}")
goToMainActivity()
}
토스트 메세지를 출력하는 showToast 메소드는 다른 화면에서도 공통으로 많이 쓰므로 Common 디렉토리에 확장 함수로 선언한다.
fun Context.showToast(message: String){
Toast.makeText(this, message, Toast.LENGTH_LONG).show()
}
Handler 옵저빙
viewModel 내의 mState 변수에 observer로 handleStateChange 메소드를 등록한다.
private fun observe(){
viewModel.mState
.flowWithLifecycle(lifecycle, Lifecycle.State.STARTED)
.onEach { state -> handleStateChange(state) }
.launchIn(lifecycleScope)
}
이런 식으로 작성하면 상태에 따른 다양한 처리를 해줄 수 있다. Boolean 변수로 성공/실패만 체크하는 방법보다 훨씬 가독성도 좋고 편리한 듯하다.