안드로이드 - MVVM + Jetpack Compose + Flow 적응기

이우건·2024년 4월 24일
0

안드로이드

목록 보기
15/20

안드로이드 프로젝트를 MVVM 구조로 Flow를 사용하여 UI의 상태를 관리하고 렌더링한 기록을 적어보려 한다.

프로젝트는 https://github.com/merttoptas/BaseApp-Jetpack-Compose-Android-Kotlin/tree/master의 코드를 참고하여 진행하였다.

간단하게 Login을 하는 과정으로 예시를 들고자 한다.

프로젝트 구조 & 데이터 흐름도

프로젝트 아키텍처 구조 및 데이터 흐름도는 다음과 같다.

DataLayer

Service

  • Retrofit을 이용하여 서버와 통신하는 역할을 한다.
interface UserService {

    @POST("user/login")
    suspend fun login(@Body userSnsIdRequestDTO: UserSnsIdRequestDTO) : Response<ResponseDto<LoginResponse>>
   
}
data class ResponseDto<T>(
    val code: Int,
    val message: String,
    val data: T
)

Response로 받는 ResponseDto 형태는 Http code와 Message, 서버에서 받는 data로 구성하였다.

BaseRemoteDataSource

open class BaseRemoteDataSource {
    protected suspend fun <T> getResult(call: suspend () -> Response<T>): DataState<T> {
        val response = call()
        return try {
            if (response.isSuccessful) {
                val body = response.body()
                if (body != null) DataState.Success(body)
                else {
                    DataState.Error(APIError(response.code().toLong(), "오류가 발생했습니다."))
                }
            } else {
                when (response.code()) {
                    403 -> {
                        val apiError = APIError(
                            403L,
                            NEED_SURVEY
                        )
                        DataState.Error(apiError)
                    }

                    404 -> {
                        val apiError = APIError(
                            404L,
                            NEED_SIGNUP
                        )
                        DataState.Error(apiError)
                    }
                    409 -> {
                        val apiError = APIError(
                            409L,
                            ALREADY_NICKNAME
                        )
                        DataState.Error(apiError)
                    }

                    500 -> {
                        val apiError = APIError(
                            500L,
                            SERVER_INSTABILITY
                        )
                        DataState.Error(apiError)
                    }

                    else -> {
                        DataState.Error(APIError(response.code().toLong(), "오류가 발생했습니다."))
                    }
                }
            }
        } catch (e: Exception) {
            DataState.Error(APIError(-1, "오류가 발생했습니다."))
        }
    }
}

DataState

sealed class DataState<T> {
    class Success<T>(val data: T): DataState<T>()
    class Loading<T>: DataState<T>()
    class Error<T>(val apiError: APIError): DataState<T>()
}

BaseRemoteDataSource는 Service에서 받은 Response가 성공인지 실패인지에 따라서 DataState로 Success, Error로 변환하기 위해 구성하였다.

DataState는 Sealed Class로 부모 타입을 만들고 api 통신이 Success, Loading, Error 총 3가지로 상태를 분류하였다.

UserDataSourceImpl

class UserDataSourceImpl @Inject constructor(
    private val userService : UserService,
    private val bookmarkDataSource: BookmarkDataSource
) : UserDataSource, BaseRemoteDataSource(){

	override suspend fun login(userSnsIdRequestDTO: UserSnsIdRequestDTO): DataState<ResponseDto<LoginResponse>> {
        return getResult {
            userService.login(userSnsIdRequestDTO)
        }
    }
}

위에서 만든 BaseRemoteDataSource를 상속 받아 http 통신 Response의 따른 결과(상태)를 DataState로 변환하여 반환한다. (UserDataSource는 UserDataSourceImpl의 함수 정의부)

UserRepositoryImpl

class UserRepositoryImpl @Inject constructor(
    private val userDataSource: UserDataSource,
    private val userDataStoreRepository: UserDataStoreRepository
) : UserRepository{

	override suspend fun login(userSnsIdRequestDTO: UserSnsIdRequestDTO): Flow<DataState<LoginState>> {
        return flow {
            val loginResponse = userDataSource.login(userSnsIdRequestDTO)

            when (loginResponse) {
                is DataState.Success -> {
                    Log.d(TAG, "login: 로그인 성공 : ${loginResponse.data.code}}")
                    userDataStoreRepository.setToken(loginResponse.data.data.toJwtToken())
                    emit(DataState.Success(loginResponse.data.toLoginState(loginResponse.data.data.needSurvey)))
                }

                is DataState.Loading -> {
                    emit(DataState.Loading())
                }

                is DataState.Error -> {
                    emit(DataState.Error(loginResponse.apiError))
                }
            }
        }.onStart{
            emit(DataState.Loading())
        }

    }
}

LoginState (model)

data class LoginState(
    val isLoading : Boolean = false,
    val code : Int = 0,
    val message : String = "",
    val needSurvey : Boolean = false
)

Mapper.kt

fun LoginResponse.toJwtToken() : JwtToken {
    return JwtToken(
        this.jwtToken.accessToken,
        this.jwtToken.refreshToken
    )
}

fun<T> ResponseDto<T>.toLoginState(needSurvey : Boolean) : LoginState {
    return if (this.code == 200 && needSurvey) {
        LoginState(
            code = this.code,
            message = NEED_SURVEY,
            needSurvey = needSurvey
        )
    } else {
        LoginState(
            code = this.code,
            message = this.message,
            needSurvey = needSurvey
        )
    }
}

Repository 클래스는 DataSource에서 받아온 ResponseDto를 앱에서 사용할 Model로 변환하고 Flow로 감싸서 return 하는 역할을 한다.
Mapper 파일에서 Dto를 Model로 변환하고 DataState로 감싸고 Flow로 감싸서 DataState의 상태에 따라 emit으로 Flow에 데이터를 보내준다.

이 예시에서는 Login을 요청하는 과정을 나타낸 것이므로 DataStoreRepository에 JwtToken을 저장하는 과정을 포함한다.

초기 상태는 Api 요청을 하고 응답을 받기 까지 기다리는 것이므로 DataState.Loading이다.

Domain Layer

GetLoginStateUseCase

class GetLoginStateUseCase @Inject constructor(
    private val userRepository: UserRepository
) {
    suspend fun getLoginStateCode(userSnsIdRequestDTO: UserSnsIdRequestDTO) : Flow<DataState<LoginState>> {
        return userRepository.login(userSnsIdRequestDTO)
    }
}

GetLoginStateUseCase에서는 UserRepository에서 받아온 Flow 객체를 viewModel로 넘겨주는 역할을 한다. (Data Layer와 Presentation Layer를 이어주는 다리 느낌)
Domain Layer에 해당하는 UseCase는 비즈니스 로직을 수행하는 역할을 하는데 프로젝트의 비즈니스 로직은 무엇을 의미하는지, UI 비즈니스 로직도 이 곳에서 수행해야 하는지를 정확히 정의하지 못해서 일단은 Data Layer와 Presentation Layer의 중간 다리 역할로만 구현하였다.

Presentation Layer

LoginViewModel

@HiltViewModel
class LoginViewModel @Inject constructor(
    private val getLoginStateUseCase: GetLoginStateUseCase,
    private val setLoginType : SetLoginType,
    private val setUserSnsIdUseCase: SetUserSnsIdUseCase
) : ViewModel(){

    private val _loginState = MutableStateFlow(LoginState())
    val loginState : StateFlow<LoginState> = _loginState

    private val _needSurvey = MutableStateFlow(false)
    val needSurvey = _needSurvey.asStateFlow()

    private val _movePageState = MutableStateFlow(0)
    val movePageState = _movePageState.asStateFlow()

    private val _dialogState = MutableStateFlow(false)
    val dialogState = _dialogState.asStateFlow()

    private val _isNextPageState = MutableStateFlow(false)
    val isNextPageState = _isNextPageState.asStateFlow()

    fun login(userSnsIdRequestDTO : UserSnsIdRequestDTO, loginType : String) {
        viewModelScope.launch {
            setLoginType.setLoginType(loginType)   // 로그인 타입 저장
            setUserSnsIdUseCase.setUserSnsId(userSnsIdRequestDTO.userSnsId) // uid 저장
            getLoginStateUseCase.getLoginStateCode(userSnsIdRequestDTO).collect {
                when (it) {
                    is DataState.Success -> {
                        _loginState.emit(it.data)
                        _needSurvey.emit(it.data.needSurvey)
                    }

                    is DataState.Error -> {
                        _loginState.emit(
                            LoginState(
                                code = it.apiError.code.toInt(),
                                message = it.apiError.message,
                                isLoading = false
                            )
                        )
                    }

                    is DataState.Loading -> {
                        _loginState.emit(
                            LoginState(
                                isLoading = true
                            )
                        )
                        delay(3000)
                    }
                }
            }
        }
    }

    fun movePage(
        loginState : LoginState,
        isNextPage : Boolean
    ) {
        when (loginState.code) {
            200 -> {
                // 200일 때 needSurvey 값에 따라 Home 화면으로 갈지
                // 설문조사 화면으로 갈지 정함
                if (loginState.needSurvey) {
                    changeDialogState(true)

                    if (isNextPage) {
                        _movePageState.value = MOVE_SURVEY_PAGE_CODE
                        initLoginStateCode()
                        changeDialogState(false)
                    }
                } else {
                    _movePageState.value = MOVE_HOME_PAGE_CODE
                }
            }

            400 -> {
                changeDialogState(true)
            }

            404 -> {
                changeDialogState(true)

                if (isNextPage) {
                    _movePageState.value = MOVE_SIGNUP_PAGE_CODE
                    initLoginStateCode()
                    changeDialogState(false)
                }
            }

            500 -> {
                changeDialogState(true)
            }
        }
    }

    fun initLoginStateCode(){
        _loginState.value = LoginState()
    }

    fun initMovePageState() {
        _movePageState.value = 0
    }

    fun changeDialogState(showDialog : Boolean) {
        _dialogState.value = showDialog
    }

    fun changeIsNextPageState(isNextPage : Boolean) {
        _isNextPageState.value = isNextPage
    }

    companion object {
        const val MOVE_HOME_PAGE_CODE = 100
        const val MOVE_SIGNUP_PAGE_CODE = 200
        const val MOVE_SURVEY_PAGE_CODE = 300
    }
}

LoginViewModel에서는 GetLoginStateUseCase에서 받은 Flow 값을 collect하여 수집한다.
수집한 값은 StateFlow에 emit하여 상태를 변화시키고 UI에서 이 상태가 변화하는 것을 감지하면 로직을 수행하는 식으로 구현하였다.

이 LoginViewModel의 경우
1. Login을 요청했을 때 가능 여부 상태
2. 설문 조사가 필요한지에 대한 상태
3. 로그인 성공 or 실패 후 이동하는 페이지에 대한 상태
4. 다이얼로그의 상태 (compose는 다이얼로그를 true, false의 상태로 열고 닫을 수 있음)
5. 다이얼로그에서 확인을 눌렀을 때 다음 페이지로 넘어갈 수 있는 상태
로 관리하고 있다.

처음에는 이 상태들을 viewModel에서 관리하지 않고 UI에서 직접 관리했는데 Composable 함수가 많아지고 함수 인자와 상태 호이스팅으로 UI의 상태를 관리하기가 매우 복잡해져 모두 viewModel에서 상태를 관리하고 viewModel의 함수를 통해 상태를 업데이트 하는 식으로 변경하였다.

LoginScreen.kt

@Composable
fun LoginScreen(
    viewModel : LoginViewModel,
    onMoveSignupPage : () -> Unit,
    onMoveSurveyPage : () -> Unit,
    onMoveHomePage : () -> Unit
) {

    val context = LocalContext.current
    val socialLoginManager = SocialLoginManager(context) // 소셜 로그인의 이벤트를 관리하는 객체

    val loginState : LoginState by viewModel.loginState.collectAsState()

    val movePageState : Int by viewModel.movePageState.collectAsState()
    val dialogState : Boolean by viewModel.dialogState.collectAsState()
    val isNextPageState : Boolean by viewModel.isNextPageState.collectAsState()

    Log.d(TAG, "LoginScreen: $loginState")
    Log.d(TAG, "isNextPageState: $isNextPageState")


    ShowLoadingDialog(isLoading = loginState.isLoading)

    viewModel.movePage(
        loginState = loginState,
        isNextPage = isNextPageState
    )

    movePage(
        movePageState = movePageState,
        viewModel = viewModel,
        onMoveSignupPage = onMoveSignupPage,
        onMoveHomePage = onMoveHomePage,
        onMoveSurveyPage = onMoveSurveyPage,
    )

    ShowMovePageDialog(
        viewModel = viewModel,
        dialogState = dialogState,
        loginState = loginState,
    )

    Column(
        modifier = Modifier
            .fillMaxSize(),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Spacer(
            modifier = Modifier
                .padding(top = 100.dp)
        )
        ImageComponent(
            modifier = Modifier
                .size(300.dp),
            imageResource = R.drawable.sungchef
        )

        Spacer(
            modifier = Modifier
                .padding(top = 40.dp)
        )

        LoginImageComponent(
            modifier = Modifier
                .width(300.dp)
                .height(60.dp)
                .clickable {
                    socialLoginManager.kakaoLogin(viewModel = viewModel)
                },
            imageResource = R.drawable.kakao_login,
        )

        Spacer(
            modifier = Modifier
                .padding(top = 20.dp)
        )

        LoginImageComponent(
            modifier = Modifier
                .width(300.dp)
                .height(60.dp)
                .clickable {
                    socialLoginManager.naverLogin(viewModel = viewModel)
                },
            imageResource = R.drawable.naver_login
        )
    }
}

@Composable
fun ShowLoadingDialog(
    isLoading : Boolean
) {
    if (isLoading) {
        Dialog(
            onDismissRequest = {
                
            },
            // 다이얼로그 시 뒤로 가기와 바깥 클릭 시 종료 안되게
            properties = DialogProperties(
                dismissOnBackPress = false,
                dismissOnClickOutside = false
            )
        ) {
            val composition by rememberLottieComposition(
                LottieCompositionSpec.RawRes(R.raw.loading_animation)
            )

            LottieAnimation(
                modifier = Modifier
                    .size(300.dp),
                composition = composition,
                iterations = 50 // 애니메이션을 50번 반복
            )
        }
    }
}

fun movePage(
    movePageState : Int,
    viewModel : LoginViewModel,
    onMoveSignupPage: () -> Unit,
    onMoveHomePage: () -> Unit,
    onMoveSurveyPage: () -> Unit
) {
    when (movePageState) {
        LoginViewModel.MOVE_SIGNUP_PAGE_CODE -> {
            onMoveSignupPage()
            viewModel.initLoginStateCode()
        }

        LoginViewModel.MOVE_HOME_PAGE_CODE -> {
            onMoveHomePage()
            viewModel.initLoginStateCode()
        }

        LoginViewModel.MOVE_SURVEY_PAGE_CODE -> {
            onMoveSurveyPage()
            viewModel.initLoginStateCode()
        }
    }

}

LoginScreen에서는 LoginViewModel에서 만든 StateFlow의 상태를 collectAsState()로 수집하며 상태의 변화를 감지하고 이를 UI에 반영한다.
상태를 업데이트 할 때는 ViewModel의 함수를 호출하여 상태를 업데이트하였다.
만약 다른 Screen으로 이동하는 로직이 수행되면 상태를 초기 상태로 모두 초기화한다. (초기화를 하지 않을 시 뒤로 가기 버튼으로 다시 LoginScreen을 부르면 남아있던 상태가 반영되기 때문)

후기

아직 compose와 상태관리에 대한 이해가 부족하여 잘못된 코드 구조일 수도 있습니다. 앞으로의 프로젝트는 사용자의 이벤트에 따른 상태를 관리하는 Class를 만들어서 관심사 분리를 해 볼 예정이고 MVVM, MVI 구조에 대해서도 공부해 볼 예정입니다.

profile
머리가 나쁘면 기록이라도 잘하자

0개의 댓글