App 개발 일지/Nutri Capture

Nutri Capture 프론트엔드 - NutrientScreen 구조 잡기

interfacer_han 2024. 10. 4. 17:20

#1 깨알 변경 사항

#1-1 "NutrientInputScreen"을 "NutrientScreen"으로 변경

가독성을 위한 이름 변경이다.
 

#1-2 "nutrient" 패키지 생성

먼저, "NutrientScreen"를 "nutrient" 패키지에 넣는다. 또, 본 게시글에서 새로 만들 모든 파일은 해당 패키지에 들어간다
 

#2 개요

#2-1 객체 지향적 UI 설계

[Android] Jetpack Compose - 객체 지향적 UI 레이어 설계

#1 개요 UI 레이어  |  Android Developers이 페이지는 Cloud Translation API를 통해 번역되었습니다. UI 레이어 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. UI의 역

kenel.tistory.com

위 게시글에 기반하여 코드를 짰다.
 

#2-2 만들 파일

1. (View의 역할) nutrient.NutrientScreen
2. (Event의 역할) nutrient.NutrientScreenEvent
3. (State의 역할) nutrient.NutrientScreenState
4. (ViewModel의 역할) nutrient.NutrientViewModel
5. (Event의 역할) nutrient.NutrientViewModelEvent
6. (데이터 클래스) nutrient.NutritionInfo

1. NutrientScreen
기존에 있던 파일이다. View의 역할을 수행한다.
 
2. NutrientScreenEvent
ViewModel에서 View에게 요청하는 이벤트다. 해당 이벤트의 인스턴스는 ViewModel에 Flow로 정의하며, 해당 Flow를 View에서 collect()하여 이벤트를 처리한다 (참조: 이 게시글의 #4).
 
3. NutrientScreenState
View가 보유할 정보를 정의한다. 인스턴스는 ViewModel에 선언된다.
 
4. NutrientViewModel
View를 보조하는 ViewModel이다.
 
5. NutrientViewModelEvent
View에서 ViewModel에게 요청하는 이벤트다. 인스턴스는 그때 그때 만들어 쓴다. 왜냐하면 NutrientScreenEvent처럼 collect()로 처리하는 방식이 아니기 때문이다. View에서 ViewModel의 이벤트 처리 함수를 직접 호출하며 NutrientViewModelEvent를 전달할 것이다.
 
6. NutritionInfo
이 게시글의 #2-4에서 말했던 '개인화된 체크리스트'를 저장할 곳이다. 여기서 세부 사항을 구현하지는 않는다 (틀만 잡음).
 

#2 코드 - NutrientViewModel

#2-1 NutrientViewModel.kt 생성

// package com.example.nutri_capture_new.nutrient

import androidx.lifecycle.ViewModel

class NutrientViewModel : ViewModel() {
    
}

 

#2-2 Jetpack Compose용 ViewModel 라이브러리 다운로드 - 모듈 수준 build.gradle

plugins {
    ...
}

android {
    ...
}

dependencies {

    ...

    // ViewModel (Compose)
    implementation(libs.androidx.lifecycle.viewmodel.compose)
}

이제 #2-1에서 만든 ViewModel을 사용해야할텐데, 본 게시글처럼 Jetpack Compose 프로젝트의 경우라면 ViewModel을 사용하기 위해서 라이브러리 하나가 요구된다. 혹시 프로젝트에 libs.versions 파일이 없다면 implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.6")라고 입력하면 된다.
 

#2-3 Jetpack Compose용 ViewModel 라이브러리 다운로드 - libs.versions

[versions]
...

[libraries]
...
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" }
...

[plugins]
...

 

#2-4 View에 ViewModel 주입 (생성자 주입)

// package com.example.nutri_capture_new.nutrient

...
import androidx.lifecycle.viewmodel.compose.viewModel

@Composable
fun NutrientScreen(
    scope: CoroutineScope,
    snackbarHostState: SnackbarHostState,
    viewModel: NutrientViewModel = viewModel<NutrientViewModel>()
) {
    ...
}

NutrientScreen에서 생성자 인수에 ViewModel를 주입한다.
 

#3 코드 - NutrientScreenState

#3-1 NutrientScreenState.kt 생성

// package com.example.nutri_capture_new.nutrient

import java.time.LocalDate
import java.time.LocalTime

data class NutrientScreenState(
    val dailyMeals: List<DailyMeal>
)

data class DailyMeal(
    val date: LocalDate,
    val meals: List<Meal>
)

data class Meal(
    val time: LocalTime,
    val name: String,
    val nutritionInfo: NutritionInfo,
)

val dailyMeals: List<DailyMeal>
View의 LazyColumn에서 표시할 아이템들의 리스트다.
 
data class DailyMeal
하루에 먹은 식사나 간식들을 보유하는 클래스다.
 
data class Meal
식사나 간식을 먹은 시각, 이름, 영양 정보를 보유한다. NutritionInfo 클래스는 아래에 있다.
 

#3-2 NutritionInfo.kt 생성

// package com.example.nutri_capture_new.nutrient

data class NutritionInfo(
    val mealSize: Int
)

세부 구현은 나중에 한다. 우선은 얼마나 먹었는지(과식 정도)를 표현하는 변수 하나만 덩그러니 넣어두었다.
 

#3-3 ViewModel에 NutrientScreenState를 담는 State 변수 선언

...

class NutrientViewModel : ViewModel() {
    // (1) 화면 표시용 State
    private val _nutrientScreenState = mutableStateOf(
        NutrientScreenState(
            dailyMeals = emptyList()
        )
    )
    val nutrientScreenState: State<NutrientScreenState>
        get() = _nutrientScreenState
}

NutrientScreenState는 이름에 "State"가 들어가지만 진짜 State는 아니다. NutrientScreenState 클래스의 인스턴스를 mutableStateOf( ... )에 넣음으로써 진짜 State로 만든다.
 

#4 코드 - NutrientScreenEvent

#4-1 NutrientScreenEvent.kt 생성

// package com.example.nutri_capture_new.nutrient

sealed class NutrientScreenEvent {
    data class ShowSnackbar(val message: String) : NutrientScreenEvent()
}

스낵바를 표시하는 이벤트 하나를 자식 이벤트로 정의했다. 다른 이벤트가 필요하다면 나중에 추가하겠다.
 

#4-2 ViewModel에서 이벤트를 저장하는 Flow 인스턴스 생성

...

class NutrientViewModel : ViewModel() {
    // (1) 화면 표시용 State
    ...

    // (2) View에서 받아 처리할 이벤트
    private val _nutrientScreenEventFlow = MutableSharedFlow<NutrientScreenEvent>()
    val nutrientScreenEventFlow: SharedFlow<NutrientScreenEvent>
        get() = _nutrientScreenEventFlow.asSharedFlow()
}

ViewModel에 이벤트를 담는 SharedFlow를 선언한다.
 

#4-3 View에서 이벤트를 저장하는 Flow 인스턴스 collect()

...

@Composable
fun NutrientScreen(
    scope: CoroutineScope,
    snackbarHostState: SnackbarHostState,
    viewModel: NutrientViewModel = viewModel<NutrientViewModel>()
) {
    LaunchedEffect(key1 = true) {
        // ViewModel로부터 받은 이벤트 처리
        viewModel.nutrientScreenEventFlow.collectLatest { event ->
            when (event) {
                is NutrientScreenEvent.ShowSnackbar -> {
                    scope.launch {
                        snackbarHostState.showSnackbar(
                            message = event.message,
                            duration = SnackbarDuration.Short
                        )
                    }
                }
            }
        }
    }
}

ViewModel의 SharedFlow를 collect()하고 해당 Flow 수신 시의 동작을 정의한다.
 

#5 코드 - NutrientViewModelEvent

#5-1 NutrientViewModelEvent.kt 생성

// package com.example.nutri_capture_new.nutrient

sealed class NutrientViewModelEvent {
    data object InitializeState : NutrientViewModelEvent()
}

State를 초기화하는 이벤트 하나를 자식 이벤트로 정의했다. 다른 이벤트가 필요하다면 나중에 추가한다.
 

#5-2 ViewModel에서 이벤트를 받아 처리하는 함수 onEvent() 선언

...

class NutrientViewModel : ViewModel() {
    // (1) 화면 표시용 State
    ...

    // (2) View에서 받아 처리할 이벤트
    ...

    // (3) View로부터 받은 이벤트 처리
    fun onEvent(event: NutrientViewModelEvent) {
        when (event) {
            is NutrientViewModelEvent.InitializeState -> {
                _nutrientScreenState.value = _nutrientScreenState.value.copy(
                    dailyMeals = listOf(
                        DailyMeal(
                            date = LocalDate.of(2011, 11, 11),
                            meals = emptyList()
                        ),
                        DailyMeal(
                            date = LocalDate.of(2011, 11, 12),
                            meals = emptyList()
                        ),
                        DailyMeal(
                            date = LocalDate.of(2011, 11, 13),
                            meals = emptyList()
                        ),
                        DailyMeal(
                            date = LocalDate.of(2011, 11, 14),
                            meals = emptyList()
                        ),
                        DailyMeal(
                            date = LocalDate.of(2011, 11, 15),
                            meals = emptyList()
                        )
                    )
                )
            }
        }
    }
}

더미용으로, 2011년 11월 11일부터 15일까지의 DailMeal 인스턴스 5개를 초기화하게끔 두겠다.
 

#5-3 View에서 State를 초기화하는 이벤트 ViewModel에 보내기

...
@Composable
fun NutrientScreen(
    scope: CoroutineScope,
    snackbarHostState: SnackbarHostState,
    viewModel: NutrientViewModel = viewModel<NutrientViewModel>()
) {
    LaunchedEffect(key1 = true) {
        // State 초기화
        viewModel.onEvent(NutrientViewModelEvent.InitializeState)

        // ViewModel로부터 받은 이벤트 처리
        ...
    }
}

'State 초기화' 코드는 'ViewModel로부터 받은 이벤트 처리' 보다 LaunchedEfffect { ... } 내에서 먼저 실행되어야 한다. 그렇지 않으면 'State 초기화' 코드가 실행되지 않는다. 아마 collectLastest()가 suspend 키워드가 붙는 비동기 코드라서 발생하는 문제로 보인다.
 

#5-4 간단한 LazyColumn 선언

...

@Composable
fun NutrientScreen(
    scope: CoroutineScope,
    snackbarHostState: SnackbarHostState,
    viewModel: NutrientViewModel = viewModel<NutrientViewModel>()
) {
    LaunchedEffect(key1 = true) {
        ...
    }

    LazyColumn(
        modifier = Modifier
            .fillMaxSize()
            .background(Color.DarkGray)
    ) {
        val dailyMeals = viewModel.nutrientScreenState.value.dailyMeals
        items(dailyMeals) { dailyMeal ->
            Text(
                text = dailyMeal.date.toString(),
                color = Color.White,
                fontSize = 40.sp
            )
        }
    }
}

짙은 회색 배경 LazyColumn에 하얀 글씨 item이 나오는 간단한 코드다. #5-2에서 만들어두었던 더미 DailyMeal 인스턴스 5개가 보여야 할 것이다. #7-1에 해당 화면 스크린샷이 있다.
 

#6 요약

NutrientScreen 구현을 위한 큰 틀을 객체 지향적인 방식으로 잡아두었다.
 

#7 완성된 앱

#7-1 스크린샷

 

#7-2 이 게시글 시점의 Commit

GitHub - Kanmanemone/nutri-capture-new

Contribute to Kanmanemone/nutri-capture-new development by creating an account on GitHub.

github.com

 

#7-3 본 프로젝트의 가장 최신 Commit

GitHub - Kanmanemone/nutri-capture-new

Contribute to Kanmanemone/nutri-capture-new development by creating an account on GitHub.

github.com