나만의 안드로이드 앱 만들기(초보자 편) - common (6-3)
이번 편에서는 지난 편에서 설명드린 구조 중 common 부분에 대해 설명드리겠습니다.
지난 편을 못보신 분들은 아래 링크를 참고 부탁드립니다.
common 에서는 공통적으로 사용될 부분들을 미리 정의하는 작업을 진행합니다.
현재는 크게 base 와 compose 2가지로 구분하였습니다.
base 에서는 프로젝트 구성의 기본이 될 요소들의 집합이고, compose 는 UI 구성에서 공통적으로 사용될 요소들의 집합입니다.
추후 공통으로 사용될 버튼이나 텍스트 등의 컴포넌트들도 compose 내에 정의될 예정입니다.
전체 코드가 궁금하신 분들은 위 링크를 참고 부탁드립니다.
base 에서 가장 중요한 부분은 BaseScreen 와 SdV1ViewModel 입니다.
compose 에서는 Scaffold 라는 개념이 있습니다.
Scaffold 에 대해 간단하게 요약하면 UI 의 기본 틀이라고 생각해주시면 되겠습니다.
앱을 구성할때는 웹 페이지를 구성할때(header, body, footer)와 같이 topbar, bottomBar 등 기본적으로 요소들이 들어갈 구조가 필요합니다.
이에 대해 쉽게 구현 할 수 있도록 구글이 미리 만들어논 컴포넌트라고 생각하시면 됩니다.
간단한 예제나 개인 프로젝트의 경우에는 저도 종종 사용하는 편이지만, 커스터마이징을 하기에는 어려운 부분이 많아서 저는 직접 만들어 쓰는 편입니다.
저는 Scaffold 를 BaseScreen 라는 이름으로 만들고 다음과 같이 정의 하였습니다.
@Composable
fun BaseScreen(
screenState: MutableState<SdV1ScreenStateEnum>,
topBar: @Composable () -> Unit = {},
body: @Composable () -> Unit = {},
footer: @Composable () -> Unit = {},
loading: @Composable (() -> Unit)? = null,
isLoadingBackground: Boolean = false,
isBackgroundFocusClear: Boolean = true,
failure: @Composable (() -> Unit)? = null,
popup: @Composable (popup: BasePopup) -> Unit = {},
popupShowRule: MutableList<BasePopupVo> = arrayListOf(),
bottomSheet: @Composable () -> Unit = {},
) {
Box {
Column(
Modifier.fillMaxSize().background(WHITE)
) {
Column {
topBar.invoke()
}
when (screenState.value) {
SdV1ScreenStateEnum.FAILURE -> {
Box(
modifier = Modifier.fillMaxWidth().weight(1f),
contentAlignment = Alignment.Center
) {
if(failure != null) {
failure.invoke()
} else {
NetworkError{}
}
}
}
SdV1ScreenStateEnum.LOADING,
SdV1ScreenStateEnum.SUCCESS
-> {
Box(
modifier = Modifier.fillMaxWidth().weight(1f),
contentAlignment = Alignment.Center
) {
Column {
var modifier = Modifier
.fillMaxWidth()
.weight(1f)
if (isBackgroundFocusClear) {
modifier = modifier.addFocusCleaner(LocalFocusManager.current)
}
Column(
modifier = modifier
) {
body.invoke()
}
footer.invoke()
}
if (screenState.value == SdV1ScreenStateEnum.LOADING) {
var modifier = Modifier.fillMaxSize()
if(isLoadingBackground) {
modifier = modifier.background(WHITE)
}
Box(
modifier = modifier,
contentAlignment = Alignment.Center,
) {
if(loading != null) {
loading.invoke()
} else {
CircularProgressIndicator(
color = Purple40
)
}
}
}
}
}
}
}
// 팝업
BasePopup(
popup = popup,
popupShowRule = popupShowRule
)
// 바텀 시트
bottomSheet.invoke()
}
}
Scaffold 의 구조와 비슷한 편이며, 화면의 상태를 갖고 있습니다.
screenState: MutableState<SdV1ScreenStateEnum>, // 화면 상태
topBar: @Composable () -> Unit = {}, // 탑바
body: @Composable () -> Unit = {}, // 본문
footer: @Composable () -> Unit = {}, // 바텀바
loading: @Composable (() -> Unit)? = null, // 로딩 시 노출할 프로그레스
isLoadingBackground: Boolean = false, // 프로그레스의 백그라운드를 보이게 할지?
isBackgroundFocusClear: Boolean = true, // 백그라운드를 터치했을때 포커스를 초기화 할지?
failure: @Composable (() -> Unit)? = null, // 화면 상태가 실패일 경우 보여줄 화면
popup: @Composable (popup: BasePopup) -> Unit = {}, // 팝업 노출 목록
popupShowRule: MutableList<BasePopupVo> = arrayListOf(), // 팝업 노출 조건 정의
bottomSheet: @Composable () -> Unit = {}, // 바텀 시트
매개 변수에 대해서 간단하게 주석을 달아 놓았으나, 처음부터 바로 이해하기시는 어려울 것이라고 생각합니다.
우선 내부 구조를 보시면
- Box
ㄴ Column
ㄴ BasePopup
ㄴ bottomSheet
로 되어 있습니다.
Box 는 기존 xml 방식에서 FrameLayout 으로 위에 계속 덮어 씌우는 레이아웃이라고 생각하시면 좋을 것 같습니다.
Column 안에는 우리가 실제로 보여주고자 하는 레이아웃이 있으며, 팝업이나 바텀시트는 우리가 보여주고자 하는 레이아웃 위에 노출되어야 하기 때문에 위와 같은 형태를 취하고 있습니다.
Column 내부에는 탑바, 바디, 푸터 3가지로 구성되어 있습니다.
지금은 아 그냥 이런 구성으로 되어 있구나? 정도로만 인지하고 넘어가도록 합니다.
추후 직접 개발을 진행하시면서 아? 그래서 이런 구조로 되어 있구나? 정도로 이해가 되실 것이라고 생각합니다.
화면 상태는 다음과 같으며, SdBaseInterface.kt 파일 안에 정의 되어 있습니다.
enum class SdV1ScreenStateEnum{
SUCCESS, // 의도한 화면 노출
LOADING, // 화면 로딩 중
FAILURE // 실패한 화면 노출
}
이를 기반으로 앞으로 화면 추가 시 BaseScreen 를 통해 구성해볼 예정입니다.
SdV1ViewModel 은 뷰모델이 상속 받을 기초 구조입니다.
abstract class SdV1ViewModel<Event: BaseViewEvent, UiState: BaseViewState, Effect: BaseViewSideEffect> : BaseViewModel<Event, UiState, Effect>() {
override fun handleEvents(event: Event) {}
fun onCreate(
initialData: suspend () -> Unit,
initialHandleUiMonitoring: () -> Unit
) {
viewModelScope.launch {
// 데이터 추가
val job1 = async(Dispatchers.Main) {
initialData.invoke()
}
// ui 모니터링
val job2 = async(Dispatchers.Main) {
initialHandleUiMonitoring.invoke()
}
val deferredList = listOf(job1, job2)
deferredList.awaitAll()
}
}
private suspend fun initialData() = suspendCoroutine { continuation ->
continuation.resume(AsyncDoneStatus.COMPLETE)
}
private suspend fun initialHandleUiMonitoring() = suspendCoroutine { continuation ->
continuation.resume(AsyncDoneStatus.COMPLETE)
}
override fun handleUiMonitoring() {}
enum class AsyncDoneStatus(val value: Int) {
COMPLETE(1),
FAIL(2)
}
}
compose 에서는 기존 안드로이드와 다르게 생명 주기가 별도로 있진 않습니다.
compose 는 기본적으로 선언형 프로그래밍으로 상태 변화가 발생했을때 UI 를 갱신하는 구조이며, 이 부분은 별도 포스팅에서 다시 설명 드리도록 하겠습니다.
생명주기는 저는 양날의 칼이라고 생각합니다.
잘쓰면 너무 좋은데 그 반대라면 무척 걸리적거리는 부분이기도 합니다.
저는 기존 안드로이드에서 onCreate 와 onResume, onDestroy 등을 가장 많이 사용했고, 이 부분을 이번 viewmodel 에서도 구현하였습니다.
이 코드들도 지금 이해를 하시기 보다도 추후 개발 과정에서 어떻게 사용되는지 한번 살펴보시고 다시 이 포스팅으로 돌아와서 다시 보시는 것을 추천 드립니다.
설명 드린 기본 구조들은 처음부터 명확하게 이해하고 넘어가실 필요는 없습니다.
제가 추천해드리는 방법은
1. 일단 복붙 후에 써보기
2. 숙달하기
3. 이해하기
4. 현 프로젝트에 맞게 커스텀하기
라고 생각합니다.
어자피 숙달되지 않으면 이 코드가 왜 이렇게 되어 있는지, 좋은 코드가 맞는지 등에 대해 판단하기 어렵기 때문입니다.
여기까지 common 에 대해 설명드렸고 다음 편에서는 core 로 찾아 뵙겠습니다.
감사합니다.