본문 바로가기

안드로이드/클린 아키텍처

[안드로이드 클린 아키텍처 시리즈] UI Layer 구현 4편 (with Jetpack Compose) #11


1. 안드로이드 클린 아키텍처 UI Layer 4편

안녕하세요. 이전 포스팅에서는 Jetpack Compose를 활용한 화면 구성과 Compose Navigation, 그리고 ViewModel을 이용한 데이터 바인딩 과정에 대해 살펴보았습니다. 이번 포스팅은 클린 아키텍처 시리즈의 마지막 편으로, UI Layer에서 화면 이쁘게 꾸며보고 지금까지 포스팅한 클린 아키텍처를 정리하면서 시리즈를 마무리하겠습니다.

MovieDetailScreen 실행 모습
MovieDetailScreen 실행 모습

2. MovieDetailScreen 꾸미기

이전 포스팅에서는 서버로부터 불러온 영화에 대한 Entity의 정보를 문자열로만 출력했습니다. 이번 시간에는 해당 데이터를 StateFlow에 저장하고 MovieDetailScreen에서 compose를 활용해서 UI를 구현하겠습니다.

2-1. MovieDetailViewModel

MovieDetailViewmodel에서 UseCase로부터 데이터를 가져오고, 가져온 데이터를 StateFlow에 저장하겠습니다. 클린 아키텍처에서는 LiveData보다 StateFlow를 많이 사용합니다.

대표적인 이유는 LiveData는 안드로이드 플랫폼에 종속적이기 때문입니다. Domain Layer는 순수 Java, Kotlin 코드로 작성되어야 한다고 설명드린 적이 있습니다. 그래서 LiveData는 Domain Layer에서 사용이 불가능했기 때문에 Kotlin Flow를 사용해야만 했습니다.

하지만 Flow는 상태가 없어 ViewModel에서 사용하기 어려웠습니다. 그래서 kotlin 1.41 버전에 StateFlow가 등장했고 LiveData와 마찬가지로 value를 통해 상태 값을 저장하고 읽을 수 있습니다. 구글에서도 Kotlin Flow를 사용하도록 권장하고 있습니다.

@HiltViewModel
class MovieDetailViewModel @Inject constructor(
    private val getMovieDetail: GetMovieDetail,
    savedStateHandle: SavedStateHandle,
) : ViewModel() {
    private val movieId = mutableStateOf(savedStateHandle.get<Int>("movieId") ?: 466420)
    val movieData = MutableStateFlow<MovieEntity>(MovieEntity())

    init {
        viewModelScope.launch(Dispatchers.Main.immediate) {
            getMovieById(movieId.value).onSuccess {
                movieData.value = it
            }
        }

    }
    private suspend fun getMovieById(movieId: Int): ApiResult<MovieEntity> {
        return getMovieDetail(movieId)
    }
}

2-2. MovieDetailScreen

MovieDetailViewModel에서 관리하는 StateFlow를 활용하여, Jetpack Compose의 다양한 컴포저블 함수를 사용해서 화면을 만들었습니다.

@Composable
fun MovieDetailScreen(
    mainNavController: NavHostController,
    viewModel: MovieDetailViewModel
) {
    val state = viewModel.movieData.collectAsState().value
    MovieDetailsScreen(state, mainNavController)
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MovieDetailsScreen(
    state: MovieEntity,
    appNavController: NavHostController
) {
    Scaffold(
    	floatingActionButton = {
            FloatingActionButton(onClick = { /*TODO*/ }) {
                Image(
                    painter = painterResource(id = favoriteIcon),
                    contentDescription = null,
                    Modifier.size(24.dp),
                )
            }
        },
        topBar = {
            TopAppBar(
                title = { Text(text = "Overview") },
                navigationIcon = {
                    IconButton(onClick = { appNavController.popBackStack() }) {
                        Icon(imageVector = Icons.Default.ArrowBack, contentDescription = null)
                    }
                }
            )
        }
    ) { paddingValues ->
        Column(
            Modifier
                .fillMaxSize(1f)
                .padding(paddingValues)
        ) {
            SubcomposeAsyncImage(
                model = state.backgroundUrl,
                loading = { MovieItemPlaceholder() },
                error = { MovieItemPlaceholder() },
                contentDescription = null,
                contentScale = ContentScale.Crop,
                modifier = Modifier
                    .height(280.dp)
                    .fillMaxWidth(1f)
            )
            Text(
                text = state.title,
                style = MaterialTheme.typography.titleLarge,
                modifier = Modifier
                    .padding(16.dp, 16.dp, 16.dp, 0.dp)
                    .fillMaxWidth(1f),
            )
            Text(
                text = state.description,
                style = MaterialTheme.typography.bodyMedium,
                modifier = Modifier
                    .padding(16.dp, 8.dp)
                    .fillMaxWidth(1f),
            )
        }
    }
}

@Composable
private fun MovieItemPlaceholder() {
    Image(
        painter = painterResource(id = drawable.bg_image),
        contentDescription = "",
        contentScale = ContentScale.Crop,
    )
}

3. 안드로이드 클린 아키텍처 시리즈 정리 및 마무리

Domain Layer의 비즈니스 로직 구성, Data Layer의 데이터 관리 및 처리, UI Layer의 Screen 개발까지, 각 계층별 구현 방법과 클린 아키텍처의 원칙을 따르는 중요성 등 클린 아키텍처의 이론부터 실습까지 살펴보았습니다.

클린 아키텍처의 적용은 앱의 유지보수성, 확장성, 그리고 테스트 용이성을 높이는데 기여합니다. 각 계층을 명확히 분리함으로써, 변경에 더욱 유연하게 대응할 수 있고, 팀원 간의 협업 시에도 앱의 구조를 더욱 쉽게 이해할 수 있기 때문입니다.

최근에는 swift, react, flutter 등 모두 선언형 UI를 채택하고 있습니다. 저는 compose가 나오기 전부터 안드로이드 개발을 해서 xml기반의 UI 개발에 더 익숙하지만, 선언형 UI를 활용하는 방식이 더욱 효율적이고 생산성이 높아진다고 생각합니다.

추후에 영화 목록 화면도 구성하고 Github에 프로젝트도 공개할 예정입니다.

감사합니다.