나만의 안드로이드 앱 만들기(초보자 편) - 풀 사이클 및 마무리(9)
이번 편에서는 지금까지 해왔던 UI 구성부터 데이터 연동까지 전반적으로 작업을 진행해보려고 합니다.
지금까지 진행했던 작업을 요약하면 다음과 같습니다.
- 더치 페이 글쓰기 기능 추가
- 더치 페이 글쓰기 기능 추가
- 메인 - 홈 탭 내 더치 페이 목록 추가
마무리가 필요한 작업들은 다음과 같습니다.
- 메인 - 홈 탭 내 기능 완료
- 더치 페이 정산 기능 추가
- 메인 - 저장소 탭 내 더치 페이 이력 목록 추가
기존 코드를 수정하면서 새 코드를 추가하다보니 설명이 복잡해질 우려가 있어서 아래 github 의 소스에 대한 참고 자료로 읽어주시길 바랍니다.
1. 메인 - 홈 탭 내 기능 완료
진행할 작업은 다음과 같습니다.
- 전체 금액 노출하기
- 정산 페이지로 이동 가능한 버튼 추가
1. 전체 금액 노출하기
전체 금액 노출하는 방법은 compose 에 상태를 연결하고 뷰모델에서 상태를 관리하면서 유즈케이스에서 레파지토리 및 데이터소스 를 통해 공급 받은 데이터를 상태에 주입하는 과정입니다.
뷰 하나에 데이터를 주입하기 위해서는 다음과 같은 절차를 진행합니다.
HomeScreen.kt
...
BaseScreen(
screenState = state.screenState,
body = {
// 총 가격
Box(
modifier = Modifier
.fillMaxWidth()
.height(150.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "전체 금액: ${CharFormatUtils.amount(state.totalAmount.value)}",
fontSize = 30.sp
)
}
...
HomeViewModel.kt
private fun onResume() {
setState {
copy(
totalAmount = mutableIntStateOf(dutchInfoUseCase.findDutchTotalAmount()),
list = dutchInfoUseCase.findDutchInfoList().toMutableStateList()
)
}
}
DutchInfoUseCase.kt
// 더치 페이 정산 전 전체 가격
fun findDutchTotalAmount(): Int {
return dutchInfoRepository.selectDutchTotalAmount()
}
DutchInfoRepository.kt
fun selectDutchTotalAmount(): Int
DutchInfoRepositoryImpl.kt
override fun selectDutchTotalAmount(): Int {
return dutchInfoDataSource.selectDutchTotalAmount()
}
DutchInfoDataSource.kt
fun selectDutchTotalAmount(): Int
DutchInfoDataSourceImpl.kt
override fun selectDutchTotalAmount(): Int {
return selectDutchInfoList().map { it.amount }.sumOf { it.toInt() }
}
2. 정산 페이지로 이동 가능한 버튼 추가
정산 페이지로 이동하기 위해서는 뷰에서 사용자의 버튼 클릭이라는 이벤트를 입력 받고 처리하는 과정이 필요합니다.
HomeScreen.kt
BaseScreen(
screenState = state.screenState,
body = {
// 총 가격
Box(
modifier = Modifier
.fillMaxWidth()
.height(150.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "전체 금액: ${CharFormatUtils.amount(state.totalAmount.value)}",
fontSize = 30.sp
)
}
...
Button(
onClick = { onEventSent(HomeContract.Event.HomeEndButtonClicked) },
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.padding(start = 10.dp),
colors = ButtonDefaults.buttonColors(containerColor = Gray)
) {
Box(
modifier = Modifier.height(30.dp),
contentAlignment = Alignment.Center
) {
Text(text = "정산하기", fontSize = 16.sp, fontWeight = FontWeight.Bold)
}
}
}
HomeList(state.list)
}
)
HomeContract.kt
class HomeContract {
sealed class Event : BaseViewEvent {
...
object HomeEndButtonClicked : Event()
}
...
sealed class Effect : BaseViewSideEffect {
sealed class Toast : Effect() {
object ShowListEmpty : Toast()
}
sealed class Navigation : Effect() {
...
object GoToHomeEnd : Navigation()
}
}
}
HomeViewModel.kt
override fun handleEvents(event: HomeContract.Event) {
when (event) {
...
is HomeContract.Event.HomeEndButtonClicked -> homeEndButtonClicked()
}
}
...
private fun homeEndButtonClicked() {
if (viewState.value.list.isEmpty()) {
setEffect { HomeContract.Effect.Toast.ShowListEmpty }
return
}
setEffect { HomeContract.Effect.Navigation.GoToHomeEnd }
}
HomePage.kt
fun HomePage(actions: PageMoveActions, navBackStackEntry: NavBackStackEntry) {
val coroutineScope: CoroutineScope = rememberCoroutineScope()
val context = LocalContext.current
val viewModel: HomeViewModel = viewModel(
factory = HiltViewModelFactory(context, navBackStackEntry)
)
HomeScreen(
state = viewModel.viewState.value,
effectFlow = viewModel.effect,
onEventSent = { event -> viewModel.setEvent(event) },
onNavigationRequested = { navigationEffect ->
coroutineScope.launch {
when (navigationEffect) {
...
is HomeContract.Effect.Navigation.GoToHomeEnd -> actions.gotoHomeEnd.invoke()
}
}
}
)
}
PageList.kt
sealed class PageList : SnPage {
...
object HomeEnd : SnPage {
override val description = "홈 정산 페이지"
override val route = "HomeEnd"
}
}
PageMoveActions.kt
class PageMoveActions(navController: NavController) :
Common,
Main,
Home
{
...
override val gotoHomeEnd: () -> Unit = {
navController.navigate(PageList.HomeEnd.route)
}
}
...
interface Home : BasePageMoveActions {
...
val gotoHomeEnd: () -> Unit
}
NavGraph.kt
fun NavGraph() {
val navController = rememberNavController()
val actions = remember(navController) { PageMoveActions(navController) }
NavHost(
navController,
startDestination = PageList.startDestination,
) {
...
// 홈 정산 페이지
buildTopToBottomPage(PageList.HomeEnd.route) {
HomeEndPage(actions, it)
}
}
}
2. 더치 페이 정산 기능 추가
정산 기능 추가의 경우에도 지금까지의 패턴과 동일하기 때문에 주요 로직만 소개해드리려합니다.
우선 SharedPreferencesManager 안에 다음 메소드 2가지를 추가하였습니다.
// 리스트 안에 리스트가 있는 경우 json 처리가 번거롭기 때문에 문자열을 변환하기 위함
private fun <T> encode(data: T): String {
val encodedBytes = Base64.encode(Gson().toJson(data).toByteArray(), Base64.DEFAULT)
return Uri.encode(String(encodedBytes))
}
// 리스트 안에 리스트가 있는 경우 json 처리가 번거롭기 때문에 문자열을 변환하기 위함
private inline fun <reified T> decode(data: String): T {
val decodedBytes = Base64.decode(Uri.decode(data).toByteArray(), Base64.DEFAULT)
val type = object : TypeToken<T>() {}.type
return Gson().fromJson(String(decodedBytes), type)
}
이 메소드는 다음과 같이 사용하기 위함입니다.
fun saveDutchInfo(list: ArrayList<DutchListItemVO>) {
putValueToPrivateAppPrefs(encode(list), SP_HOME_DUTCH_LIST_ITEM)
}
fun getDutchInfoList(): ArrayList<DutchListItemVO> {
val type = object : TypeToken<List<DutchListItemVO>>() {}.type
return privateAppPrefs.getString(SP_HOME_DUTCH_LIST_ITEM, "")?.let {
decode(it)
}?: arrayListOf()
}
list 안에 list 가 있는 객체를 json 화 된 문자열로 바꿔서 저장하는 로직이 있습니다.
이 경우 다시 json 파싱하는 과정에서 문제가 발생 할 수 있습니다.
그래서 문자열을 저장하기 전에 문자열을 encode, decode 하여 파싱에 문제 없게 하는 작업을 진행하였습니다.
이 기능부터 Repository 와 DataSource 의 역할이 좀 더 명확해졌습니다.
지금까지는 DataSource 에서 조회한 데이터를 Repository 가 단순히 넘겨주는 작업만 진행하였습니다.
그래서 Repository 가 굳이 있어야 하나? 이런 생각이 드셨을 수도 있습니다.
DutchInfoRepositoryImpl.kt
override fun selectDutchEndInfoList(): ArrayList<DutchEndListItemVO> {
val list = dutchInfoDataSource.selectDutchEndInfoList()
list.sortBy { it.name }
return list
}
DutchInfoDataSourceImpl.kt
override fun selectDutchEndInfoList(): ArrayList<DutchEndListItemVO> {
val list = selectDutchInfoList()
val saveMap = HashMap<String, ArrayList<DutchListItemVO>>()
val resultList = ArrayList<DutchEndListItemVO>()
// HashMap 으로 중복 제거 및 기본 틀 체우기
list.onEach { itemVO ->
itemVO.enterPersonList.onEach { itemDetailVO ->
if (saveMap[itemDetailVO.name] == null) {
saveMap[itemDetailVO.name] = arrayListOf(
DutchListItemVO(
title = itemVO.title,
amount = itemVO.amount,
enterPersonList = itemVO.enterPersonList
)
)
} else {
saveMap[itemDetailVO.name]!!.apply {
add(
DutchListItemVO(
title = itemVO.title,
amount = itemVO.amount,
enterPersonList = itemVO.enterPersonList
)
)
}
}
}
}
// 기본 틀을 가지고 정산 여부 체우기
val saveMapToList = saveMap.toList()
saveMapToList.onEach { mapData ->
var isEnd = true
mapData.second.onEach {
it.enterPersonList.onEach { personVO ->
if (mapData.first == personVO.name && !personVO.isEnd) {
isEnd = false
}
}
}
resultList.add(
DutchEndListItemVO(
name = mapData.first,
list = mapData.second,
isEnd = isEnd
)
)
}
return resultList
}
DataSource 는 특정 카테고리의 데이터 자체를 특정한 목적에 따라 생성하여 반환하는 역할을 수행합니다.
Repository 는 DataSource 가 반환해준 데이터가 손상되지 않는 선에서 정렬하거나 다른 DataSource 가 반환해준 데이터를 조합하고 반환하는 역할을 수행합니다.
이 관점으로 코드를 읽어주시면 이해가 어렵지 않으실 것으로 생각합니다.
3. 메인 - 저장소 탭 내 더치 페이 이력 목록 추가
저장소 탭 내 더치 페이 이력 목록 추가 작업의 경우에도 1, 2번 작업들을 기반으로 작업이 이루어졌고, 코드를 읽어보시는데 크게 어려움이 없으실 것으로 예상됩니다.
마무리하며
지금까지 Compose 를 이용하여 MVI 를 적용한 싱글 모듈 프로젝트를 작업해보았습니다.
설명이 부족한 부분들은 댓글을 남겨주시면 언제든지 답변드릴 예정이며, 문서도 같이 보완하도록 하겠습니다.
감사합니다.