#1 개요
이전 게시글에서 채팅 UI 도입을 위해 가상 테이블 DayMealView를 만들었다. 백엔드 작업이 끝났으므로, Day 테이블 및 Meal 테이블을 참조하던 프론트가 이제는 DayMealView 테이블을 참조하도록 만들어야 한다.
#2 코드 - 백엔드 깨알 변경 사항
#2-1 DAO에서 getAllDayMeals(limit: Int) 선언
...
@Dao
interface MainDAO {
...
@Query("SELECT * FROM DayMealView LIMIT :limit")
suspend fun getAllDayMeals(limit: Int): List<DayMealView>
...
}
존재하는 모든 레코드를 가져오는 getAllDayMeals()의 메소드 오버로딩이다. getAllDayMeals() 바로 아래에 선언한다.
#2-2 Repository에서도 선언
...
class MainRepository(private val dao: MainDAO) {
...
suspend fun getAllDayMeals(limit: Int): List<DayMealView> {
return dao.getAllDayMeals(limit)
}
...
}
DAO에서 선언한 함수를 참조하는 함수다.
#3 코드 - View에서 표시할 State 변경
#3-1 NutrientScreenState
// package com.example.nutri_capture_new.nutrient
import androidx.compose.runtime.snapshots.SnapshotStateList
import com.example.nutri_capture_new.db.DayMealView
data class NutrientScreenState(
val dayMeals: SnapshotStateList<DayMealView>
)
가상 테이블 DayMealView의 각 아이템이 담긴 리스트만을 선언한다.
#3-2 ViewModel의 내부 프로퍼티
...
class NutrientViewModel(private val repository: MainRepository) : ViewModel() {
// (1) 화면 표시용 State
private val _nutrientScreenState = mutableStateOf(
NutrientScreenState(
dayMeals = SnapshotStateList()
)
)
val nutrientScreenState: State<NutrientScreenState>
get() = _nutrientScreenState
...
}
#3-1 변경 사항에 맞춰 ViewModel를 수정한다.
#3-3 InitializeState 이벤트 변경
...
class NutrientViewModel(private val repository: MainRepository) : ViewModel() {
...
// (4) View로부터 받은 이벤트 처리
fun onEvent(event: NutrientViewModelEvent) {
when (event) {
is NutrientViewModelEvent.InitializeState -> {
viewModelScope.launch {
_nutrientScreenState.value.dayMeals.apply {
clear()
addAll(repository.getAllDayMeals(10))
}
}
_isInitialized.value = true
}
...
}
}
// (5) DayMealView 작동 확인용 로그
...
}
ViewModel.onEvent()의 분기 중 하나인 NutrientViewModelEvent.InitializeState를 변경한다.
#3-4 LoadMoreItemsAfterLastDate 이벤트 이름 및 동작 변경
...
class NutrientViewModel(private val repository: MainRepository) : ViewModel() {
...
// (4) View로부터 받은 이벤트 처리
fun onEvent(event: NutrientViewModelEvent) {
when (event) {
...
is NutrientViewModelEvent.LoadMoreItemsAfterLastDayMeal -> {
viewModelScope.launch {
val lastDayMeal = _nutrientScreenState.value.dayMeals.last()
_nutrientScreenState.value.dayMeals.addAll(
repository.getNextDayMealsAfter(lastDayMeal, 10)
)
}
}
...
}
}
// (5) DayMealView 작동 확인용 로그
...
}
LoadMoreItemsAfterLastDate의 이름을 LoadMoreItemsAfterLastDayMeal로 바꾼다 (당연하겠지만 변수명 클릭 후 Shift + F6 눌러서 프로젝트 내의 모든 LoadMoreItemsAfterLastDate를 일괄적으로 변경한다). 또, 이벤트 내용도 위와 같이 변경한다.
#3-5 InsertMeal 이벤트 변경
...
class NutrientViewModel(private val repository: MainRepository) : ViewModel() {
...
// (4) View로부터 받은 이벤트 처리
fun onEvent(event: NutrientViewModelEvent) {
when (event) {
...
is NutrientViewModelEvent.InsertMeal -> {
viewModelScope.launch {
var insertedMealId = -1L
insertedMealId = repository.insertMeal(event.meal, event.date)
if (insertedMealId != -1L) {
val insertedDayMeal = repository.getDayMeal(insertedMealId)
val index =
findIndexToInsert(_nutrientScreenState.value.dayMeals, insertedDayMeal)
_nutrientScreenState.value.dayMeals.add(index, insertedDayMeal)
}
}
}
...
}
}
// (5) DayMealView 작동 확인용 로그
...
}
private fun findIndexToInsert(list: SnapshotStateList<DayMealView>, newItem: DayMealView): Int {
for(i: Int in list.indices) {
if(newItem < list[i]) {
return i
}
}
// 삽입할 위치가 없다면 맨 끝(list.size)에 삽입
return list.size
}
원래 if (insertedMealId != -0L) { ... } 였다. 삽입 실패 시 반환되는 값은 0이 아니라 -1므로 위와 같이 수정했다.
findIndexToInsert는 굉장히 단순한 삽입 알고리즘이다. 나중에 리스트의 길이가 길어지면 많은 자원을 먹게될 것이다. 지금은 넘어가겠지만 추후 이진 탐색을 응용한 방식으로 리팩토링한다.
또, DayMealView 간 대소비교를 하고 있는데 원래라면 불가능한 연산이다. 이를 가능케하기 위해서 DayMealView를 아래와 같이 업데이트한다.
// package com.example.nutri_capture_new.db
...
@DatabaseView("""
...
""")
data class DayMealView(
...
) {
operator fun compareTo(otherDayMealView: DayMealView): Int {
return compareValuesBy(this, otherDayMealView,
{ it.date }, // (1차) date에 대해 오름차순으로 비교 (date가 큰 쪽이 비교 우위)
{ it.time }, // (2차) time에 대해 오름차순으로 비교 (time이 큰 쪽이 비교 우위)
{ it.mealId } // (3차) mealId에 대해 오름차순으로 비교 (mealId가 큰 쪽이 비교 우위)
) * -1 // 부호를 반전시키면, 내림차순으로 비교한 것과 동일한 결과가 나옴
}
}
#3-6 DeleteMeal 이벤트 변경
...
class NutrientViewModel(private val repository: MainRepository) : ViewModel() {
...
// (4) View로부터 받은 이벤트 처리
fun onEvent(event: NutrientViewModelEvent) {
when (event) {
...
is NutrientViewModelEvent.DeleteMeal -> {
viewModelScope.launch {
var deletedRowCount = 0
deletedRowCount = repository.deleteMeal(event.meal)
if (deletedRowCount == 1) {
val deletedMealId = event.meal.mealId
_nutrientScreenState.value.dayMeals.removeIf { it.mealId == deletedMealId }
}
}
}
}
}
// (5) DayMealView 작동 확인용 로그
...
}
...
#3-7 미사용 이벤트 삭제
// package com.example.nutri_capture_new.nutrient
import com.example.nutri_capture_new.db.Meal
import java.time.LocalDate
sealed class NutrientViewModelEvent {
data object InitializeState : NutrientViewModelEvent()
data object LoadMoreItemsAfterLastDayMeal : NutrientViewModelEvent()
data class InsertMeal(val meal: Meal, val date: LocalDate) : NutrientViewModelEvent()
data class DeleteMeal(val meal: Meal, val date: LocalDate) : NutrientViewModelEvent()
}
LoadMoreItemsBeforeFirstDate와 GetMealsByDate 분기를 onEvent() 분기문에서 삭제한다. 그리고, NutrientViewModelEvent에서도 제거한다.
#4 코드 - View (NutrientScreen)
#4-1 INSERT를 수행하는 임시 함수
...
@Composable
fun NutrientScreen(
...
) {
LaunchedEffect(key1 = true) {
...
}
LaunchedEffect(key1 = true) {
repeat(5) {
viewModel.onEvent(
NutrientViewModelEvent.InsertMeal(
meal = Meal(
time = LocalTime.now(),
name = "test",
nutritionInfo = NutritionInfo()
),
date = LocalDate.now()
)
)
}
}
LaunchedEffect(key1 = viewModel.isInitialized.value) {
...
}
LazyColumn(
...
) {
...
}
}
#4-3에서, 원래는 존재했던 Meal 추가 버튼이 없어지므로 이렇게라도 구현한다. 앱이 실행되면 1초 간격으로 Meal이 하나씩 INSERT될 것이다. Meal 추가 버튼을 다시 만드는 것과 동시에 삭제할 함수다.
#4-2 역방향 무한 스크롤 삭제
...
@Composable
fun NutrientScreen(
...
) {
LaunchedEffect(key1 = true) {
...
}
LaunchedEffect(key1 = true) {
...
}
LaunchedEffect(key1 = viewModel.isInitialized.value) {
if (viewModel.isInitialized.value) {
// 무한 스크롤
snapshotFlow { listState.layoutInfo.visibleItemsInfo }.collect { visibleItemsInfo ->
val totalMaxIndex = listState.layoutInfo.totalItemsCount - 1
val firstVisibleItemIndex = listState.firstVisibleItemIndex
val visibleItemCount = visibleItemsInfo.size
if (totalMaxIndex <= firstVisibleItemIndex + visibleItemCount) {
viewModel.onEvent(NutrientViewModelEvent.LoadMoreItemsAfterLastDayMeal)
}
}
}
}
LazyColumn(
...
) {
...
}
}
채팅 UI는 양방향 무한 스크롤이 없다. 원래 있던 역방향 무한 스크롤 코드를 제거하고 순방향 무한 스크롤 코드만 남긴다. 역방향 무한 스크롤이 혹시 나중에라도 필요하면 다시 구현하겠다.
#4-3 LazyColumn 변경
...
@Composable
fun NutrientScreen(
...
) {
LaunchedEffect(key1 = true) {
...
}
LaunchedEffect(key1 = true) {
...
}
LaunchedEffect(key1 = viewModel.isInitialized.value) {
...
}
LazyColumn(
...
reverseLayout = true
) {
val dayMeals = viewModel.nutrientScreenState.value.dayMeals
itemsIndexed(dayMeals) { index, dayMeal ->
Card(
modifier = Modifier
.fillMaxWidth()
.padding(
start = 8.dp,
top = if (index == dayMeals.lastIndex) 8.dp else 0.dp,
end = 8.dp,
bottom = 8.dp
),
elevation = CardDefaults.cardElevation(8.dp)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(8.dp)
) {
Text(
text = DateFormatter.formatDateForNutrientScreen(dayMeal.date) + " " + dayMeal.time,
modifier = Modifier.fillMaxWidth(),
fontSize = 15.sp,
textAlign = TextAlign.End
)
Text(
text = "mealId: ${dayMeal.mealId}, index: $index",
modifier = Modifier.fillMaxWidth(),
fontSize = 30.sp,
textAlign = TextAlign.Center
)
}
}
}
}
}
LazyColumn의 reverseLayout 프로퍼티에 true를 대입한다. 이제 LazyColumn의 아이템의 순서는 뒤집힐 것이고, 순방향 무한스크롤의 방향도 (위 → 아래)에서 (아래 → 위)로 뒤집힐 것이다.
#5 요약
가상 테이블 DayMealView를 참조하도록 ViewModel 및 View를 변경했다.
#6 완성된 앱
#6-1 문제점
앱을 실행시키면 바로 튕긴다. 실행이 안되는 버전을 Commit으로 등록하는 게 약간 껄끄럽다. 하지만, 본 게시글이 과도하게 길어지기에 이 에러의 해결 과정은 다음 게시글에서 다루겠다.
#6-2 이 게시글 시점의 Commit
#6-3 본 프로젝트의 가장 최신 Commit
'App 개발 일지 > Nutri Capture' 카테고리의 다른 글
Nutri Capture 백엔드 - Room 무결성 보완 (2) | 2024.11.15 |
---|---|
Nutri Capture 백엔드 - Room 가상 테이블의 레코드를 필요한 만큼만 가져오기 (0) | 2024.11.12 |
Nutri Capture 백엔드 - Room 가상 테이블 선언 (0) | 2024.11.10 |
Nutri Capture 방향성 - 채팅 UI (1) | 2024.11.08 |
Nutri Capture 백엔드 - View에서 INSERT 트리거 (0) | 2024.10.23 |