기존에 안드로이드 프로젝트에 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 변수로 성공/실패만 체크하는 방법보다 훨씬 가독성도 좋고 편리한 듯하다.
'안드로이드 > 이론' 카테고리의 다른 글
[ 안드로이드 ] BottomSheetDialog 테두리 둥글게 설정하기 (0) | 2023.04.04 |
---|---|
[ 안드로이드 ] - Selector를 이용하여 버튼, 체크박스 등에 클릭 효과 주기 (0) | 2023.03.29 |
[ 안드로이드 ] 해상도 대응, 다양한 화면 크기 지원 (0) | 2023.01.31 |
[ 안드로이드 ] local.properties를 이용하여 API Key 안전하게 보관하기 (0) | 2023.01.25 |
[ 안드로이드 ] Fragment Add시 밑의 Fragment 클릭되는 문제 (0) | 2022.11.16 |