사이드 프로젝트에서 Jetpack Compose 를 이용한 Architecture 구성기

Sehee Jeong·2022년 4월 22일
2
post-thumbnail

Jetpack Compose 적용기

구글 문서에서 확인할 수 있듯이 Compose 에서는 상태 호이스팅을 강조하고 있다. 상태는 위에서 아래로 흐르고, 이벤트는 아래에서 위로 흐르는 단방향 데이터 흐름을 가져야 한다는 것인데, 이 점을 이용해 이번 Compose 를 적용해본 프로젝트에서 간단하게 단방향 데이터 흐름을 가질 수 있는 아키텍처를 구성해보았고 기록차 남겨보려고 한다.

Project Spec

클린아키텍처 구현을 위해 멀티모듈로 구성하였고, App, Common, Presentation, Domain, Data 모듈로 진행했다.

화면을 구성하는 Presentation Layer 에서 Compose 가 적용된 곳에는 화면의 이름을 따서 네이밍을 지었고 (ex; Splash), State, SideEffect, Event는 Contract Class에 정의했다.

State, SideEffect, Event

위에서 언급한 State, SideEffect, Event 는 아래와 같다.

  1. State : 보여지고 있는 뷰의 현재 상태를 정의
  2. SideEffect : Toast 혹은 다른 화면으로 이동하는 것처럼 뷰에서 딱 한번 발생할 수 있는 단발성 이벤트
  3. Event : 유저가 뷰에 어떠한 행위를 보내는 것을 정의

프로젝트에서는 이렇게 정의내렸고, 예시로 아래와 같이 사용될 수 있다. (추후에 나오는 코드에서도 이런 흐름을 볼 수 있으며, 계속 언급될 예정이다.)

  1. 맨 처음 보여지는 뷰를 State 로 정의하며,
  2. 네트워크 통신을 통해 데이터를 받게 되면 State 로 담아 뷰를 변화시키고,
  3. 에러 메세지를 보여주어야 할때는 SideEffect 이용해 Toast 를 띄워주고,
  4. 사용자가 다른 화면으로 이동하기 위한 버튼을 누를 때는 버튼을 눌렀다는 Event 를 보내게 되며,
  5. 해당 Event 로 인해 다른 화면으로 이동하기 위한 SideEffect 가 호출된다.

약간의 비용이 드는 방식일 수 있다. 하지만 이렇게 정의해본 이유는 단방향 데이터 흐름과도 연관이 있는데, State, Event, SideEffect 를 최대한 이용하여 Composable 내부에서 스스로 뷰와 관련된 함수를 띄우는 것이 아닌 외부의 요인을 통해 띄워질 수 있도록 만들고자 하는 목적이 컸다.


Contract class

Contract class 는 화면과 관련된 상태(State), 유저 이벤트(Event), 단발성 이벤트(SideEffect)를 sealed class 로 정의한 곳이다.

아래 화면을 예시로 들어보겠다. 관리자용 출결 관리 화면은 사용자의 세션에 따른 사용자의 누적 점수를 확인할 수 있으며, 각 섹션을 클릭 했을 때 상세화면으로 이동하는 구조이다.

또한 휴일인 세션인 경우에는 A 처럼 관리 버튼이 disable 처리가 되어야한다.

AB

우리는 이 화면을 AdminMain 이라고 부르기로 했고 아래와 같이 정의했다.

class AdminMainContract {
    data class AdminMainUiState(
        val loadState: LoadState = LoadState.Idle,
        val upcomingSession: Session? = null,
        val sessions: List<Session> = emptyList()
    ) : UiState {
        enum class LoadState {
            Loading, Idle, Error
        }
    }

    sealed class AdminMainUiSideEffect : UiSideEffect {
        class NavigateToAdminTotalScore(val upcomingSessionId: Int) : AdminMainUiSideEffect()
        class NavigateToManagement(val sessionId: Int, val sessionTitle: String) : AdminMainUiSideEffect()
    }

    sealed class AdminMainUiEvent : UiEvent {
        class OnUserScoreCardClicked(val upcomingSessionId: Int) : AdminMainUiEvent()
        class OnSessionClicked(val sessionId: Int, val sessionTitle: String) : AdminMainUiEvent()
    }
}
  1. AdminMainUiState : 화면의 UI를 구성하기 위해 필요한 데이터를 모아놓은 data class 이다. 관리자용 출결 관리 화면에서는 Loading 상태, 네트워크 에러 등으로 데이터를 불러오지 못한 Error 상태, 데이터를 성공적으로 불러온 Idle 상태로 이루어져있으며, loadState 에 따라 화면이 다르게 보여진다. Session 에는 동아리 일정에 대한 데이터가 들어있으며, 이 데이터를 이용해 휴일인지 정규세션인지 판단할 수 있다.

  2. AdminMainUiSideEffect : 단발성 이벤트를 전파하기 위한 side effect 모음이다. 우리는 Toast 혹은 다른 화면으로의 전환 이벤트를 side effect로 정의했다.

  3. AdminMainUiEvent : 유저의 행위를 담은 모음이다. 이 화면에서는 상단 카드 섹션 혹은 각 섹션을 유저가 클릭했을 때의 이벤트를 정의했다. 유저가 취하는 행동을 직관적으로 표현할 수 있는 네이밍을 선정할 수 있도록 노력했다.



BaseViewModel

abstract class BaseViewModel<S : UiState, A : UiSideEffect, E : UiEvent>(
    initialState: S,
) : ViewModel() {

    private val _uiState = MutableStateFlow<S>(initialState)
    val uiState = _uiState.asStateFlow()

    /**
     * `Channel` replicate SingleLiveEvent behavior.
     */
    private val _effect: Channel<A> = Channel()
    val effect = _effect.receiveAsFlow()

    // Get current state
    private val currentState: S
        get() = _uiState.value

    open fun setEvent(event: E) {
        dispatchEvent(event)
    }

    fun dispatchEvent(event: E) = viewModelScope.launch {
        handleEvent(event)
    }

    protected abstract suspend fun handleEvent(event: E)

    protected fun setState(reduce: S.() -> S) {
        val state = currentState.reduce()
        _uiState.value = state
    }

    protected fun setEffect(vararg builder: A) {
        for (effectValue in builder) {
            viewModelScope.launch { _effect.send(effectValue) }
        }
    }
}

정의된 State, Event, SideEffect 를 셋팅할 수 있도록 BaseViewModel 을 구성한다.

initialState 를 정의함으로써 최초 진입 시 보여지는 화면이 셋팅되고 StateFlow 를 통해 상태를 관리한다. 그리고 setState 를 통해 불변 객체 값을 넘겨주어 데이터 갱신을 통해 Recomposition 이 이루어진다. SideEffect 의 경우 Channel 로 구현하여 연속적인 단발성 이벤트가 발생할 때 버퍼에 순차적으로 담고, 먼저 발생된 이벤트를 방출할 수 있도록 구현했다.

Reference: https://proandroiddev.com/mvi-architecture-with-kotlin-flows-and-channels-d36820b2028d

// AdminViewModel

    override suspend fun handleEvent(event: AdminMainUiEvent) {
        when (event) {
            is AdminMainUiEvent.OnUserScoreCardClicked -> setEffect(
                AdminMainUiSideEffect.NavigateToAdminTotalScore(event.upcomingSessionId)
            )
            is AdminMainUiEvent.OnSessionClicked -> setEffect(
                AdminMainUiSideEffect.NavigateToManagement(event.sessionId, event.sessionTitle)
            )
        }
    }
    
    private suspend fun getSessions() {
        getSessionListUseCase()
        	.catch {
            	  setState { copy(loadState = AdminMainUiState.LoadState.Error) }
            }
            .collect { entities ->
                  setState {
                      copy(
                            loadState = AdminMainUiState.LoadState.Idle,
                            upcomingSession = upcomingSession,
                            sessions = sessions
                        )
                  }
              },
          )
    }

ViewModel 에서는 화면을 구성하기 위한 UseCase 를 불러와 State 에 정의된 데이터를 copy() 를 이용해 값을 변경하며, 이벤트가 발생할 때 원하는 로직을 handleEvent 에 정의한다.


Composable Function

@Composable
fun AdminMain(
    viewModel: AdminMainViewModel = hiltViewModel(),
    navigateToAdminTotalScore: (Int) -> Unit,
    navigateToManagement: (Int, String) -> Unit
) {
        
        val uiState by viewModel.uiState.collectAsState()
        ...
		...
        when (uiState.loadState) {
            AdminMainUiState.LoadState.Loading -> YDSProgressBar()
            AdminMainUiState.LoadState.Idle -> AdminMainScreen(
                uiState = uiState,
                onUserScoreCardClicked = {
                    viewModel.setEvent(
                        AdminMainUiEvent.OnUserScoreCardClicked(
                            uiState.upcomingSession?.sessionId
                                ?: AttendanceList.DEFAULT_UPCOMING_SESSION_ID
                        )
                    )
                },
                onSessionClicked = { sessionId, sessionTitle ->
                    viewModel.setEvent(
                        AdminMainUiEvent.OnSessionClicked(sessionId, sessionTitle)
                    )
                }
            )
            AdminMainUiState.LoadState.Error -> YDSEmptyScreen()
        }

ViewModel 내 StateFlow 정의된 uiState 를 선언한다. uiState 를 옵저빙하고 있기 때문에 처음에는 LoadState 가 Loading 상태 이므로 인 ProgressBar 가 등장한다. 그리고 네트워크 동신을 통해 받은 데이터를 통해 Idle 혹은 Error 상태로 변경되면, 그에 맞는 Screen을 보여주는 형태이다.

또한 어떠한 이벤트가 발생했을 때(ex: onUserScoreCardClicked), setEvent 를 통해 유저의 행위를 전달하고, 전달된 이벤트는 ViewModel 의 handleEvent 를 통해 SideEffect 혹은 State를 변화시킴으로써, 외부 요인을 통해 다시 Recomposition 이 발생하거나 다른 화면으로 이동할 수 있게 된다.


End

이번에 사이드 프로젝트를 진행하면서 처음으로 Jetpack Compose 를 이용해 앱을 구현해보았다. 처음에는 Compose에 대해 잘 몰랐으며, 사이드 프로젝트 기한은 이미 정해져 있었던터라 Compose 로 진행하다가는 개발 속도가 너무 더뎌질지는 않을까(혹은 제대로 사용하지 못하면 어쩌지)하는 불안감이 많이 존재했었다. 하지만 두려워서 시작조차 안하는 내 모습이 싫어서 팀원들 동의하에 무작정 개발 스펙에 Compose를 넣었고, 이를 시작으로 다같이 Compose 스터디를 진행했었다. 결론적으로는 다들 맨땅에 헤딩하면서 배웠던지라 기억에도 잘 남았고, 사이드 프로젝트로 적용하는데 많은 도움을 받을 수 있었다. 이번 기회에 Compose 를 1% 라도(^^..) 알게 되어서 좋다.

하지만 반대로 아쉬운 부분도 많이 존재했는데, Compose 의 장점인 뷰의 재사용성을 고려하면서 개발해보려고 노력했지만 아직 Compose가 미숙해서인지 생각보다 쉽지는 않았다. 🤔oOO(아 내가 만든 뷰는 다른 화면에 가져다 쓰지 못할것 같아.. 재사용 성이 1도 없잖아? 하며 한탄했던 과거의 내 자신)
또한 UI Testing 을 진행한다 가정해본다면, ViewModel의 의존성이 없는 구조가 더 깔끔한 형태라고 생각한데, 현재 구조에서는 그렇지 못한 점이 아쉬웠다. 이 부분들은 나중에 사이드 프로젝트 혹은 현업에서 적용할 때 더 많이 고민하면서 설계해봐야겠다:)

profile
android developer @bucketplace

0개의 댓글