본문 바로가기

안드로이드/디자인패턴

Compose 프로젝트 일부 MVI로 마이그레이션

지금까지 MVI를 간단한 스톱워치로 알아봤는데, 스톱워치 기능 자체가 간단하다보니 MVI의 장점을 크게 느끼지 못했다.

 

전에 팀 프로젝트로 진행했던 Compose 프로젝트 일부를 Orbit 라이브러리를 이용하여 MVI를 적용해보고, 전과 후를 비교하여 어떤 차이가 있는지 확인해보고자 했다.

 

MVI를 적용할 부분은 SavePhotoScreen으로, 사진을 찍고 추가적인 정보(라벨, 설명 등)를 입력 후 저장하는 기능을 담당한다.

https://github.com/boostcampwm-2024/and04-Nature-Album/blob/dev/app/src/main/java/com/and04/naturealbum/ui/add/savephoto/SavePhotoScreen.kt

 

and04-Nature-Album/app/src/main/java/com/and04/naturealbum/ui/add/savephoto/SavePhotoScreen.kt at dev · boostcampwm-2024/and04-

🍀 주변의 다양한 생물을 촬영하고 식별하여 나만의 생물 도감을 만들고, 생물 지도를 친구와 함께 확인해보아요~ - boostcampwm-2024/and04-Nature-Album

github.com

 

간단하게 살펴보면 

@Composable
fun SavePhotoScreen(
    locationHandler: LocationHandler,
    location: Location?,
    model: Uri,
    onBack: () -> Unit,
    onSave: () -> Unit,
    onCancel: () -> Unit,
    onLabelSelect: () -> Unit,
    description: String = "",
    label: Label? = null,
    onNavigateToMyPage: () -> Unit,
    viewModel: SavePhotoViewModel,
) {
    ...
    
    SavePhotoScreen(
        model = model,
        location = newLocation,
        photoSaveState = photoSaveState,
        rememberDescription = rememberDescription,
        onDescriptionChange = { newDescription ->
            rememberDescription.value = newDescription
        },
        isRepresented = isRepresented,
        onRepresentedChange = { isRepresented.value = !isRepresented.value },
        onNavigateToMyPage = onNavigateToMyPage,
        onLabelSelect = onLabelSelect,
        onBack = onBack,
        savePhoto = viewModel::savePhoto,
        label = label,
    )

    if (photoSaveState.value is UiState.Success) {
        onSave()
    }
}

위와 같이 Composable 함수의 매개 변수로 전달하는 데이터 또는 함수들이 많은 것을 확인할 수 있다.

많은 만큼 특정 동작의 흐름을 파악하는 것 또한 어려움이 있었다.

 

1. Contract 작성

MVI를 적용하기에 앞서 고려해야 할 부분은 State와 Intent, Effect를 어떻게 정의하는가였다.

  • State: SavePhotoScreen에 필요한 데이터들의 집합
  • Effect: UI와는 거리가 먼 작업들
  • Intent: 사용자 액션

State

우선 Data Class로 SavePhotoState를 정의한 뒤 Screen에서 사용 중인 매개변수 중 원시 타입 또는 State, remember 등을 찾아 포함시켰다.

@Immutable
data class SavePhotoState(
    val status: UiStatus = UiStatus.Idle,
    val saveState: UiState<Unit> = UiState.Idle,
    val appState: NatureAlbumState? = null,
    val uri: Uri? = null,
    val location: Location? = null,
    val description: String = "",
    val represented: Boolean = false,
    val getLocation: @Composable ((Location?) -> Unit) -> Unit = {},
    val onBack: () -> Unit = {},
    val onSave: () -> Unit = {},
    val onCancel: () -> Unit = {},
    val onLabelSelect: () -> Unit = {},
    val onNavigateToMyPage: () -> Unit = {}
)

 

초기에는 Sealed Class로 Idle, Loading, Success로 Success로 구현하였는데, ViewModel에서 reduce 메서드를 사용할 때 copy가 되지 않아 위와 같이 Data Class 내에 status 변수를 만들어 status로 추가적인 상태를 관리하기로 했다.

 

State를 정의할 때 중요한 점은 다음과 같다.

  • data class 내의 프로퍼티가 val 로 선언되어 있는가
  • 객체의 경우 해당 객체가 Stable한가
  • 최종적으로 해당 State Data Class가 Stable한가

위의 3가지를 확인 후 unstable하다면 어노테이션을 사용하여 Stable하게 변경해주어야 한다.

그렇지 않으면 무수한 Recomposition이 발생한다.

 

Effect

SavePhotoScreen에서의 Effect는 화면 이동밖에 없기 때문에 화면 이동과 관련 된 Effect로 정의하였다.

sealed interface SavePhotoEffect {
    sealed interface Navigation : SavePhotoEffect {
        data object Back : Navigation
        data object Cancel : Navigation
        data object Save : Navigation
        data object LabelSelect : Navigation
        data object MyPage : Navigation
    }
}

 

Intent

Intent로는 사용자와 상호작용 할 수 있는 Button들과 TextField 입력, ToggleButtone 클릭들을 정의하였다.

sealed interface SavePhotoIntent {
    data object BackButtonClicked : SavePhotoIntent
    data object CancelButtonClicked : SavePhotoIntent
    data object SaveButtonClicked : SavePhotoIntent
    data object LabelSelectClicked : SavePhotoIntent
    data object MyPageButtonClicked : SavePhotoIntent
    data class DescriptionInput(val text: String) : SavePhotoIntent
    data object RepresentedToggleClicked : SavePhotoIntent
}

 

2. ViewModel

@HiltViewModel
class SavePhotoViewModel @Inject constructor(
    ...
): ContainerHost<SavePhotoState, SavePhotoEffect>, ViewModel(){
    
    override val container: Container<SavePhotoState, SavePhotoEffect> =
        container(SavePhotoState())

    ....
    
    fun onIntent(intent: SavePhotoIntent) = intent {
        when (intent) {
            is SavePhotoIntent.DescriptionInput -> {
                reduce {
                    state.copy(description = intent.text)
                }
            }

            is SavePhotoIntent.RepresentedToggleClicked -> {
                reduce {
                    state.copy(represented = !state.represented)
                }
            }

            is SavePhotoIntent.SaveButtonClicked -> {
                postSideEffect(SavePhotoEffect.Navigation.Save)
            }

            is SavePhotoIntent.BackButtonClicked -> {
                postSideEffect(SavePhotoEffect.Navigation.Back)
            }

            is SavePhotoIntent.LabelSelectClicked -> {
                postSideEffect(SavePhotoEffect.Navigation.LabelSelect)
            }

            is SavePhotoIntent.CancelButtonClicked -> {
                postSideEffect(SavePhotoEffect.Navigation.Cancel)
            }

            is SavePhotoIntent.MyPageButtonClicked -> {
                postSideEffect(SavePhotoEffect.Navigation.MyPage)
            }
        }
    }

    fun changeState(state: SavePhotoState) {
        intent {
            reduce { state }
        }
    }
    
    ...
}

 

Orbit의 장점 중 하나가 쉽게 마이그레이션이 가능하다는 점인 것 같다.

ViewModel에 ContainerHost를 상속받고 container를 초기화 한 뒤 onIntent()만 작성하면 된다.

기능이 추가되어도 Intent와 Effect에 추가만 하면 되기 때문에 확장에도 자유로워진다.

 

3. View

Navigation

fun NavGraphBuilder.saveAlbumNavGraph(
    state: NatureAlbumState,
    navigator: NatureAlbumNavigator,
    takePictureLauncher: ManagedActivityResultLauncher<Intent, ActivityResult>
) {
    composable(NavigateDestination.SavePhoto.route) { backStackEntry ->
        val savePhotoBackStackEntry = remember(backStackEntry) {
            navigator.getNavBackStackEntry(NavigateDestination.SavePhoto.route)
        }
        val viewModel: SavePhotoViewModel =
            hiltViewModel(viewModelStoreOwner = savePhotoBackStackEntry)
        val savePhotoState by viewModel.collectAsState()

        SavePhotoScreen(
            state = { savePhotoState },
            initState = { savePhotoState.init(state, navigator, takePictureLauncher) },
            viewModel = viewModel
        )
    }
}

원래는 Screen 내에서 ViewModel 생성 후 State를 구독하여 사용했었는데, 이번에는 ViewModel을 Navigation Router 부분에서 생성해주고 State를 구독하여 인자로 넘겨주는 방법을 사용하였다. (Screen의 코드가 더욱 깔끔해짐)

 

SavePhotoScreen

@Composable
fun SavePhotoScreen(
    state: () -> SavePhotoState,
    initState: () -> SavePhotoState,
    viewModel: SavePhotoViewModel,
) {
    val context = LocalContext.current

    viewModel.collectSideEffect { effect ->
        when (effect) {
            is SavePhotoEffect.Navigation.Save -> {
                val time = LocalDateTime.now(ZoneId.of("UTC"))
                val fileName = "${System.currentTimeMillis()}.jpg"
                val fileUri = ImageConvert.makeFileToUri(state().uri.toString(), fileName)
                val label = state().appState?.selectedLabel?.value

                viewModel.savePhoto(
                    uri = fileUri,
                    fileName = fileName,
                    label = label!!,
                    location = state().location!!,
                    description = state().description,
                    isRepresented = state().represented,
                    time = time
                )

                insertFirebaseService(
                    context = context,
                    uri = fileUri,
                    fileName = fileName,
                    label = label,
                    location = state().location!!,
                    description = state().description,
                    time = time
                )
            }

            is SavePhotoEffect.Navigation.Back -> state().onBack()

            is SavePhotoEffect.Navigation.Cancel -> state().onCancel()

            is SavePhotoEffect.Navigation.MyPage -> state().onNavigateToMyPage()

            is SavePhotoEffect.Navigation.LabelSelect -> state().onLabelSelect()
        }
    }

    SavePhotoScreen(
        state = { state() },
        initState = { initState() },
        changeState = viewModel::changeState,
        onIntent = viewModel::onIntent,
    )
}

@Composable
fun SavePhotoScreen(
    state: () -> SavePhotoState,
    initState: () -> SavePhotoState,
    changeState: (SavePhotoState) -> Unit,
    onIntent: (SavePhotoIntent) -> Unit,
) {
    ...
    Description(
        description = { state().description },
        modifier = Modifier.weight(1f),
        onValueChange = { newDescription ->
            onIntent(
                SavePhotoIntent.DescriptionInput(
                    newDescription
                )
            )
        }
    )
    
    ...
    
    IconTextButton(
        modifier = Modifier.weight(1f),
        imageVector = Icons.Default.Close,
        stringRes = R.string.save_photo_screen_cancel,
        onClick = { onIntent(SavePhotoIntent.CancelButtonClicked) })

    IconTextButton(
        enabled = (state().appState?.selectedLabel?.value != null) && (state().saveState != UiState.Loading),
        modifier = Modifier.weight(1f),
        imageVector = Icons.Outlined.Create,
        stringRes = R.string.save_photo_screen_save,
        onClick = { onIntent(SavePhotoIntent.SaveButtonClicked) }
    )
    ...
}

수 많았던 매개 변수들이 확연히 줄어든 것을 볼 수 있다.

상태로 관리하기 때문에 State와 ViewModel, 사용자의 모든 액션 이벤트의 경우 Intent로 넘겨주기 때문에 onIntent 하나로 모든 것이 해결되었다.

또한 모든 SideEffect는 when문으로 최상단에서 모두 관리하기 때문에 가독성과 유지보수가 굉장히 편해졌고, 흐름을 파악하는 것 또한 쉬워졌다.

 

https://github.com/boostcampwm-2024/and04-Nature-Album/blob/refactoring/mvi/app/src/main/java/com/and04/naturealbum/ui/add/savephoto/SavePhotoScreen.kt

 

and04-Nature-Album/app/src/main/java/com/and04/naturealbum/ui/add/savephoto/SavePhotoScreen.kt at refactoring/mvi · boostcampwm

🍀 주변의 다양한 생물을 촬영하고 식별하여 나만의 생물 도감을 만들고, 생물 지도를 친구와 함께 확인해보아요~ - boostcampwm-2024/and04-Nature-Album

github.com

 

 

MVI를 모르고 Compose를 처음 접했을 때 어떤 아키텍처를 적용해야 할 지 고민이 많았다.

MVVM을 적용하기엔 Databinding이 필요 없기 때문에 맞지 않았고, MVC....?도 거리가 멀기 때문에 안드로이드 권장 아키텍처로 Data > Domain(선택) > UI 구조로 분리하여 사용하는데 초점을 두었다.

MVI를 공부하고 직접 적용해보니 상태를 기반으로 UI를 구성 및 관리하는 Compose와 매우 궁합이 잘 맞았다.

 

MVI를 적용함으로써 다음과 같은 이점을 얻었다.

  1. 코드의 가독성 증가
  2. 유지보수성 증가
  3. SideEffect를 한 번에 관리
  4. 확장에 자유로워짐

다만 간단한 구성과 기능에는 불필요하게 코드만 많아질 수 있기 때문에 적합한 곳에서 사용하면 좋을 것 같다.

'안드로이드 > 디자인패턴' 카테고리의 다른 글

MVI 라이브러리 - Orbit with Compose  (0) 2025.07.02
MVI 패턴  (0) 2025.06.30
Data Layer  (0) 2025.03.09
SOILD 원칙  (0) 2025.03.08
SingleTon  (0) 2025.03.08