본문 바로가기

Jetpack Compose

[Jetpack Compose] Preview와 ViewModel의 충돌

https://nemoo-dev.tistory.com/entry/Jetpack-Compose-masonry-Layout%EC%9D%84-%EB%A7%8C%EB%93%A4%EC%96%B4%EB%B3%B4%EC%9E%90feat-LazyStaggeredGrid

 

[Jetpack Compose] masonry Layout을 만들어보자(feat. LazyStaggeredGrid)

간간히 위와 같은 레이아웃의 형태를 많이 접할 수 있다. Android에선 "Lazy Staggered Grid", 혹은 "Asymmetic Grid" 등의 키워드로 접할 수 있는 위와 같은 레이아웃의 정식 명칭은 "Masonry Layout" 이라 한다.

nemoo-dev.tistory.com

 

지난 글에서 Coil(image Picker), 그리고 LazyStaggeredGrid Compose 함수를 사용하여 갤러리 앱에서 추출한 이미지를 masonry layout 화 시키는 작업을 수행해보았다.

간단하게 Main Screen(HomeScreen)의 파라미터로 ViewModel(HomeViewModel)을 받아오고, 컴포즈 내부에서 이를 사용하였다. (코드는 이전 포스팅 참조 바랍니다)

하지만, 문제가 생겼다. 

바로 "Preview"를 사용하는데 제약이 걸린다는 것이다.

Preview

기기나 에뮬레이터에 앱을 배포하지 않아도 Android 스튜디오에서 Jetpack Compose 구성요소를 미리 볼 수 있습니다. 각각 다양한 너비와 높이 제한, 글꼴 크기 조정, 테마가 있는 구성 가능한 특정 함수의 미리보기가 여러 개 있을 수 있습니다. 앱을 개발할 때 미리보기가 업데이트되므로 변경사항을 더 빠르게 검토할 수 있습니다.

 

컴포저블 내에서 ViewModel을 사용하면 Preview가 제한된다. Preview 시스템은 ViewModel에 전달된 모든 매개변수를 구성할 수 없다. 

만약, Preview에서 ViewModel을 사용한다하면 해당 Preview Composable 함수 내에 ViewModel을 인스턴스화 할 수 밖에 없고, 이 때 Android Studio는 Preview 에선 ViewModel 인스턴스화를 수행할 수 없다고 에러를 띄우게 된다.

ViewModel 인스턴스를 다른 컴포저블 함수 내부에 전달하는 것은 Preview 문제 뿐만 아니라 재사용, 테스트 측면에서의 비효율성을 불러일으킬 수도 있다. 이는 추후 조금 더 많은 코드를 짜보면 와닿을 수 있지 않을까 싶다. 공식문서에서 아래와 같은 경고 문구를 보여준다.

 

Refactoring (HomeScreen | HomeScreenContent)

ViewModel을 받지 않는 온전한 컴포저블 함수를 구성한 뒤 이를 Preview 내부에 호출하는 작업이 필요하다. 

즉, ViewModel을 받아오는 컴포저블 함수 / UI Contents를 담당하는 컴포저블 함수, 이렇게 두 컴포저블 함수로 분리하는 과정을 수행하도록 하였다.

기존 코드를 간단히 보자면 아래와 같다. (대부분을 생략하였습니다. 전체 코드가 궁금하시다면 지난 포스팅을 참조해 주세요)

@Composable
fun HomeScreen(
    homeViewModel: HomeViewModel = viewModel(),
    maxSelectionCount: Int = 1
) {
    val existingImages by homeViewModel.existingImages.collectAsState()
    val dialogShown by homeViewModel.dialogShown.collectAsState()

    val buttonText = "이미지 불러오기"
    val dialogMessage = "최대 $maxSelectionCount 개의 이미지를 불러올 수 있습니다."

    val isFirstTime = remember { mutableStateOf(false) }

    LaunchedEffect(Unit) {
        isFirstTime = true
    }

    val scrollState = rememberLazyStaggeredGridState()

    LaunchedEffect(scrollState.canScrollForward) {
        // Scroll to the bottom when new images are added
        scrollState.scrollToItem(existingImages.size - 1)
    }

    val singlePhotoPickerLauncher = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.PickVisualMedia(),
        onResult = { uri ->
            homeViewModel.addNewImages(
                listOf(
                    HomeListItem(
                        height = Random.nextInt(100, 300).dp,
                        imageUri = uri
                    )
                )
            )
        }
    )

    // ... (생략)
	
    	Column(
            Modifier.fillMaxSize(),
            verticalArrangement = Arrangement.Center,
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            if (existingImages.isEmpty()) {
                Text(
                    text = "사진을 채워주세요",
                    textAlign = TextAlign.Center,
                    fontSize = 20.sp
                )
            } else {
                LazyVerticalStaggeredGrid(
                    columns = StaggeredGridCells.Fixed(2),
                    modifier = Modifier.fillMaxSize(),
                    state = scrollState,
                    contentPadding = PaddingValues(20.dp, 20.dp, 20.dp, 100.dp),
                    horizontalArrangement = Arrangement.spacedBy(20.dp),
                    verticalItemSpacing = 20.dp,
                ) {
                    items(existingImages) { listItem ->
                        GalleryImage(item = listItem)
                    }
                }
            }
        }
}

 

이제 위의 코드를 ViewModel을 가지는 함수와 그렇지 않은 함수로 나누어 보자. 해당 작업을 수행하기 위해선 먼저 컴포저블 함수 내에 ViewModel에서 정의한 상태및 메서드 필드를 사용하는 부분을 확인할 필요가 있다. 

현재 ViewModel은 아래와 같이 정의되어있고, 컴포저블 함수 내부에선 이를 호출해 사용하고 있다.

class HomeViewModel : ViewModel() {
    private val _existingImages = MutableStateFlow<List<HomeListItem>>(emptyList())
    val existingImages: StateFlow<List<HomeListItem>> = _existingImages.asStateFlow()

    private val _dialogShown = MutableStateFlow<Boolean>(false)
    val dialogShown: StateFlow<Boolean> = _dialogShown.asStateFlow()
    fun addNewImages(newImages: List<HomeListItem>) {
        _existingImages.value += newImages
    }

    fun showDialog() {
        _dialogShown.value = true
    }

    fun dismissDialog() {
        _dialogShown.value = false
    }
}

 

이에 따라, 먼저 기존의 HomeScreen 컴포저블 함수를 아래와 같이 정의할 수 있다.

HomeScreen Compose (변경)

@Composable
fun HomeScreen(
    homeViewModel: HomeViewModel = viewModel(),
) {
    val existingImages by homeViewModel.existingImages.collectAsState()
    val dialogShown by homeViewModel.dialogShown.collectAsState()

    val addNewImages: (List<HomeListItem>) -> Unit = { newImages ->
        homeViewModel.addNewImages(newImages)
    }
    val showDialog: () -> Unit = {
        homeViewModel.showDialog()
    }
    val dismissDialog: () -> Unit = {
        homeViewModel.dismissDialog()
    }

    HomeScreenContent(
        existingImages = existingImages,
        dialogShown = dialogShown,
        addNewImages = addNewImages,
        showDialog = showDialog,
        dismissDialog = dismissDialog
    )
}

이곳에서만 ViewModel의 접근을 허용한다. 그리고 해당 컴포저블 함수만이 외부 (NavController와 같은) 호출에 사용될 것이다. (public)

두 번째로는 Homescreen 내부 필드로 정의한 상태 및 메서드들을 인자로 받는 HomeScreenContent를 정의해 준다.

 

HomeScreenContent (분리)

// 매개변수 정의
@Composable
private fun HomeScreenContent(
    existingImages: List<HomeListItem>,
    dialogShown: Boolean,
    addNewImages: (List<HomeListItem>) -> Unit,
    showDialog: () -> Unit,
    dismissDialog: () -> Unit,
    maxSelectionCount: Int = 10,
) {

    val buttonText = "이미지 불러오기"
    val dialogMessage = "최대 $maxSelectionCount 개의 이미지를 불러올 수 있습니다."

    val isFirstTime = remember { mutableStateOf(false) }

    LaunchedEffect(Unit) {
        isFirstTime.value = true
    }

    val scrollState = rememberLazyStaggeredGridState()

    LaunchedEffect(scrollState.canScrollForward) {
        // Scroll to the bottom when new images are added
        scrollState.scrollToItem(existingImages.size - 1)
    }

    val singlePhotoPickerLauncher = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.PickVisualMedia(),
        onResult = { uri ->
            addNewImages(  // 직접적으로 바로 메서드 호출
                listOf(
                    HomeListItem(
                        height = Random.nextInt(100, 300).dp,
                        imageUri = uri
                    )
                )
            )
        }
    )

    val multiplePhotoPickerLauncher = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.PickMultipleVisualMedia(
            maxItems = if (maxSelectionCount > 1) maxSelectionCount else 2
        ),
        onResult = { uris ->
            addNewImages(   
                uris.map { uri ->
                    HomeListItem(
                        height = Random.nextInt(100, 300).dp,
                        imageUri = uri
                    )
                }
            )
        }
    )

    // ~ 생략

    // show dialog
    if (dialogShown) {
        AlertDialog(
            onDismissRequest = { dismissDialog() }, // 직접적으로 바로 메서드 호출
            title = { Text(text = "알림")},
            text = { Text(dialogMessage) },
            confirmButton = {
                Button(onClick = {
                    dismissDialog()  
                    isFirstTime.value = false
                    launchPhotoPicker()
                }) {
                    Text(text = "확인")
                }
            }
        )
    }
	
    // ~ 생략
}

 

HomeScreenContent란 컴포즈 함수를 별도로 분리하고, 필요한 필드 및 메서드를 매개변수를 통해 받아옴으로써 ViewModel과의 Decoupling을 이뤄낼 수 있게 되었다. 

이러한 컴포즈 분리를 통해 뷰모델 종속적이지 않은 레이아웃 구현부를 뽑아낼 수 있게 되었고, 우린 이를 Preview에 호출할 수 있게 된다.

 

Preview

 

Preview를 사용하기 위해 수정된 코드이지만, ViewModel과 Compose를 어떻게 잘 구성해야 좋을 지에 대해서 고민하기에도 좋은 소스였다.