대외활동/SOPT

SOPT 34th 안드로이드파트 미미나 - State와 SideEffect로 Compose를 관리해보자

chattymin 2024. 6. 10. 03:19
728x90
반응형

안녕하세요. SOPT 34기 안드로이드 파트 OB 박동민입니다.

 

벌써 3번째 미미나네요. 오늘은 Compose를 이용해서 Android를 개발할 때 중요한 상태관리를 알아보고자 합니다.

 

 

목차

  • Flow에 대해서 알아보자
    • StateFlow
    • SharedFlow
  • MVI란?
    • MVVM과 뭐가 다를까?
    • MVI는 어떤걸까?
  • 예시로 알아보자

 

 

 

Flow에 대해서 알아보자

Flow는 Kotlin 코루틴을 사용한 새로운 비동기 데이터 스트림 처리 방법이다.

어려운걸 다 때고 말하면 내부 값이 변화했을 때 이 변화를 collect할 수 있다. 즉, 변화가 발생한다면 이를 감지하고 내가 원하는 동작을 할 수 있게 해주는 트리거 역할을 해준다고 생각하면 쉽다.

 

물론 Flow를 사용하는 큰 이유는 코루틴의 활용일 것이다. 하지만 지금 글에서는 데이터를 방출하고 감지하여 원하는 동작을 수행한다 는 기능에 집중할 예정이다.

 

Flow중에서 우리가 집중할 친구는 StateFlow와 SharedFlow가 있다.

 

 

StateFlow

StateFlow는 현재 상태와 새로운 상태를 관찰할 수 있는 친구다.

쉽게 말해서 "상태"를 수집하고 있는 친구라고 생각하면 된다.

 

이 예시를 보자.

 

여기서는 사람들의 사진, 이름, 한줄 자기소개 등을 나타내고 있다. 

이렇게 정보들을 계속해서 가지고 있어야 한다.

즉, 우리가 서버통신으로부터 가져온 정보를 화면에 지속적으로 나타내야하는 것들을 State, 상태라고 부르고 StateFlow를 주로 사용한다. 

private val _userInfoState = MutableStateFlow<UiState<UserProfileResponseModel>>(UiState.Empty)
val userInfoState: StateFlow<UiState<UserProfileResponseModel>> 
	get() = _userInfoState



 

SharedFlow

SharedFlow는 알림을 주는 친구다.

즉, 지속된 데이터를 가지고 있는게 아니라 한번 알려주고 끝난다.

 

이 예시를 보자

 

우리는 snackBar가 떠있는 상태인지, 아닌지 알 필요가 없다.

단순히 띄워라! 라는 명령만 필요한 상태다

 

그렇기 때문에 SharedFlow를 활용해서 단발성으로  알려주는 기능을 사용한다. 

private val _isChangedSuccess = MutableSharedFlow<Boolean>()
val isChangedSuccess: SharedFlow<Boolean>
    get() = _isChangedSuccess

 

 

 

MVI란?

지금까지 이 둘을 활용해서 MVVM으로 코드를 많이 작성했을 것이다.

여기서 한발 더 나아간 관리기법. MVI를 소개하려고 한다.

 

MVVM과 뭐가 다를까?

MVVM의 문제점을 해결하고자 MVI가 나왔다.

 

  • Multiple Inputs
    • ViewModel은 많은 input, output을 관리해야 하는 경우가 있다. 이때 백그라운드 스레드를 사용하게 되면 thread safety 하지 못한 문제가 발생할 수 있다.
  • Multiple States
    • 복잡하게 분산된 상태와 복잡한 비즈니스 로직을 가질 수 있다. 이를Observer Pattern을 이용해 상태를 동기화 하는 방법을 사용하는데, 이 또한 행위의 충돌을 일으킬 수 있다.

 

 

MVI는 어떤걸까?

Intent : 의도를

Model : 상태로 만들어

View : 표시하는 것

 

- How it works?

User 상호작용 → Intent 생성 → State or SideEffect 생성 → View가 State or SideEffect 변화를 collect → view 변경

 

 

- Pure Cycle

순수 함수 : 함수의 입력만이 함수의 결과에 영향을 주는 함수.

 

Pure Cycle 또한 순수 함수와 같은 맥락. 모든 행동(intent)의 결과는 state로 귀결됨.

각 페이지별로 하나의 state만 만들어두고, 해당 state를 덮어쓰기 하며 사용함. → 단 하나의 state만 존재하기 때문에 여러 state가 중복된다거나, 충돌하는 경우가 없음.

 

 

- Side Effect Cycle

부수효과(Side Effect) : 외부세계 상태를 변화시키거나, 외부 세계로부터 상태 변화

 

SideEffectCycle == Pure Cycle + SideEffect

 

일반적으로 화면이 변해야 하는경우(Recomposition)이 아닌 모든 경우가 SideEffect라고 생각하면 됨.

Pure Cycle에서 벗어난 모든 상태는 SideEffect!  ex) toast

 

 

 

 

가장 중요한것은 “단방향 흐름”이다.

모든 순서는 User의 이벤트 → 이벤트 감지 및 Intent or SideEffect 생성 → Model의 변화 → View에서 Model의 변화 감지 → View의 변화 로 이루어져야 한다.

 

User의 이벤트

버튼 클릭과 같이 유저와 상호작용하는 모든것이다.

 

이벤트 감지 및 State or SideEffect 생성

이벤트가 발생한다면(사용자의 이벤트) 그에 맞는 기능을 수행해야 한다.

View(Screen)에서 이벤트 발생을 감지하고 ViewModel에 처리를 부탁한다.

ViewModel에서 연산, 서버 통신, 이벤트 수행 등을 한 후 State or SideEffect를 발생시킨다.

단, 이때 State와 SideEffect는 동시에 발생할 수 있다!!!!!

 

Model의 변화

이렇게 발생된 State와 SideEffect는 아래와 같은 타입으로 변환된다.

State → State : StateFlow

SideEffect → SideEffect: SharedFlow

Intent는 State를 덮어쓰기 하며 Model을 생성하고

SideEffect는 SideEffect를 emit하여 Model을 방출한다.

 

View에서 Model의 변화 감지

View에서 collectWithLifecycle 을 활용하여 변화를 감지한다.

LiveData를 감지하는 것과 비슷하다.

 

View의 변화

위에서 감지한 값에 따라 View를 변화시킨다.

State가 변화했다면 화면 내부의 변화를, SideEffect가 발생했다면 화면 외부의 변화를 발생시킨다.

 

 

 

예시로 알아보자!

@Composable
fun SignInRoute(
    onSignUpClick: () -> Unit,
    onMainClick: () -> Unit,
    onShowErrorSnackBar: (Int) -> Unit,
    viewModel: SignInViewModel = hiltViewModel(),
) {
    val state by viewModel.state.collectAsStateWithLifecycle()
    val lifecycleOwner = LocalLifecycleOwner.current

    val scope = rememberCoroutineScope()

    LaunchedEffect(viewModel.sideEffect, lifecycleOwner) {
        viewModel.sideEffect.flowWithLifecycle(lifecycle = lifecycleOwner.lifecycle)
            .collect { sideEffect ->
                when (sideEffect) {
                    SignInSideEffect.NavigateToMain -> onMainClick()

                    SignInSideEffect.NavigateToSignUp -> onSignUpClick()

                    is SignInSideEffect.SnackBar -> onShowErrorSnackBar(sideEffect.message)
                }
            }
    }

    LaunchedEffect(true) {
        viewModel.setInfo()
    }

    SignInScreen(
        id = state.id,
        password = state.password,
        signInBtnClicked = {
            scope.launch {
                viewModel.signInBtnClicked()
            }
        },
        signUpBtnClicked = {
            scope.launch {
                viewModel.signUpBtnClicked()
            }
        },
        fetchId = { viewModel.fetchId(it) },
        fetchPassword = { viewModel.fetchPw(it) }
    )
}

@Composable
fun SignInScreen(
    id: String,
    password: String,
    signInBtnClicked: () -> Unit,
    signUpBtnClicked: () -> Unit,
    fetchId: (String) -> Unit,
    fetchPassword: (String) -> Unit,
) {
    Scaffold(
        modifier = Modifier.addFocusCleaner(LocalFocusManager.current),
        topBar = {
            Box(
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(40.dp),
                contentAlignment = Alignment.Center
            ) {
                Text(
                    text = stringResource(id = R.string.sign_in_title),
                    fontSize = 30.sp,
                    fontWeight = FontWeight.ExtraBold
                )
            }
        },
        bottomBar = {
            Column(
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(horizontal = 20.dp)
                    .padding(bottom = 30.dp),
                horizontalAlignment = Alignment.CenterHorizontally
            ) {
                Button(
                    modifier = Modifier
                        .fillMaxWidth(),
                    onClick = signInBtnClicked
                ) {
                    Text(text = stringResource(id = R.string.sign_in_title))
                }
                Text(
                    modifier = Modifier.noRippleClickable {
                        signUpBtnClicked()
                    },
                    text = stringResource(id = R.string.sign_up_btn),
                    fontSize = 12.sp,
                    fontWeight = FontWeight.Thin,
                    color = Color.LightGray
                )
            }
        }
    ) { paddingValues ->
        Column(
            modifier = Modifier
                .fillMaxSize()
                .padding(paddingValues)
                .padding(horizontal = 20.dp),
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            TextFieldWithTitle(
                title = stringResource(id = R.string.id),
                value = id,
                hint = stringResource(id = R.string.id_hint),
                singleLine = true,
                onValueChanged = { id ->
                    fetchId(id)
                }
            )

            Spacer(modifier = Modifier.height(30.dp))

            TextFieldWithTitle(
                title = stringResource(id = R.string.pw),
                value = password,
                singleLine = true,
                keyboardType = KeyboardType.Password,
                hint = stringResource(id = R.string.pw_hint),
                onValueChanged = { password ->
                    fetchPassword(password)
                }
            )
        }
    }
}
@HiltViewModel
class SignInViewModel @Inject constructor(
    private val authRepository: AuthRepository,
) : ViewModel() {
    private val _state: MutableStateFlow<SignInState> = MutableStateFlow(SignInState())
    val state: StateFlow<SignInState>
        get() = _state.asStateFlow()

    private val _sideEffect: MutableSharedFlow<SignInSideEffect> = MutableSharedFlow()
    val sideEffect: SharedFlow<SignInSideEffect>
        get() = _sideEffect.asSharedFlow()

    fun setInfo() {
        _state.value = _state.value.copy(
            id = authRepository.getId(),
            password = authRepository.getPassword(),
            nickname = authRepository.getNickname()
        )
    }

    fun fetchId(id: String) {
        _state.value = _state.value.copy(id = id)
    }

    fun fetchPw(password: String) {
        _state.value = _state.value.copy(password = password)
    }

    suspend fun signInBtnClicked() {
        viewModelScope.launch {
            authRepository.verifyUser(
                RequestSignInEntity(
                    _state.value.id,
                    _state.value.password
                )
            ).onSuccess {
                val header = it.headers()[AuthRepositoryImpl.HEADER].orEmpty()

                setUserData(header)
                _sideEffect.emit(SignInSideEffect.NavigateToMain)
            }.onFailure {
                _sideEffect.emit(
                    SignInSideEffect.SnackBar(
                        R.string.server_error
                    )
                )
            }
        }
    }

    private fun setUserData(memberId: String) {
        authRepository.setUserId(memberId)
    }

    suspend fun signUpBtnClicked() {
        _sideEffect.emit(SignInSideEffect.NavigateToSignUp)
        _sideEffect.resetReplayCache()
    }
}
data class SignInState(
    val id: String = "",
    val password: String = "",
    val nickname: String = "",
)
sealed class SignInSideEffect {
    data object NavigateToMain : SignInSideEffect()
    data object NavigateToSignUp : SignInSideEffect()
    data class SnackBar(val message: Int) : SignInSideEffect()
}

 

화면의 Data를 나타내는 부분은 State로 정의하고, 화면 내부를 제외한 변화를 SideEffect로 정의한다.

그렇게 State에 따라 화면을 나타내주고, SideEffect를 Collect하여 부수효과를 실행시킨다.

 

728x90
반응형