본문 바로가기

[Android]/App UI 따라 만들기

[배달의민족2] 클론코딩 - 2. 약관 동의 화면

안녕하세요! 허접 샴푸입니다.

 

이번 편은 먼저 약관 동의 화면입니다.

 

[만들고자 하는 화면]

1. 권한 동의 화면

2. 이용약관 화면(일부 설정만)

 

[필요 사전 지식]

1. 안드로이드

2. 코틀린

3. ViewBinding

* 일단 사전 지식이 없더라도 따라 만들면서 부족한 점은, 인터넷 찾아보면서, 학습하시면 됩니다.

 

[내용]

1. ViewModel

2. MVVM, MVI 디자인 패턴

3. ViewPager

4. UI (Event + State), State Pattern

5. Flow, Observable Pattern

 

[시작]

그림, 색상 등과 관련한 리소스 관련 파일은 모두 맨 아래 나와있는 Github에서 확인할 수 있으니 따로 설명을 넣지 않았습니다.

1) 패키지 스트럭쳐 수정

- 기존 ui 패키지 하위에 intro 패키지를 만들었고, intro 패키지 안에 terms 패키지를 만들어 위와 같이 파일들을 분류하였습니다.

- 코드 및 파일이 점점 많아지는데 하나의 패키지(ui 패키지)에 모든 파일 및 코드를 넣기에는 유지보수 및 확장이 쉽지 않을 것 같아서 intro(인트로 관련 화면)를 담당하는 화면들은 intro 패키지 내부에 넣었고, 또한 약관 관련 화면들은 terms라는 패키지를 만들어 그 내부에 넣어두었습니다.

 

2) IntroTermAgreement.kt, TermsFragment.kt 제작 및 Navigation Graph

- introTermAgreement.kt : 약관 동의와 관련된 화면입니다.

- termsFragment.kt : '약관 동의' 화면에서 약관을 상세보기 위해 필요한 화면입니다. (다음 편에서 화면을 완성하도록 하겠습니다.)

 

<launchSingleTop="true">

- <action ... /> : introTermAgreementFragment에서 termsFragment로 갈 수 있는 방향 동작을 나타냅니다. (위 네비게이션 그래프에서 화살표를 화살표를 나타냅니다.)

- app:launchSingleTop="true" : 만약 사용자가 "위치 기반 서비스 약관 동의(필수)" 옆에 있는 '>' 아이콘을 빠르게 연타하게 될 경우, termsFragment 화면은 네비게이션 백스택에 여러 번 쌓이게 됩니다. 예를 들어, 사용자가 3번 빠르게 연타할 경우, 네비게이션 백스택에 termsFragment 화면이 3번 쌓이게 됩니다. 그래서 termsFragment 화면에서 뒤로 가기를 3번 수행해야 이전 화면으로 돌아갈 수 있습니다. 이를 방지하기 위해, launchSingleTop="true"로 설정하여, 만약 termsFragment가 백스택에 최상단에 쌓여있으면, 3번 연타가 일어나도, 2번 더 termsFragment 화면을 백스택에 쌓는 것이 아닌, 기존 최상단에 쌓여있는 termsFragment 화면을 재활용하게 됩니다. 여기서 재활용이란 새로운 똑같은 화면이 아닌 기존 화면을 스마트폰 화면에 띄우겠다는 의미입니다. 즉, 중복 방지라고 생각하시면 되겠습니다.

 

<IntroTermAgreementFragment.kt>

1. 클릭 리스너

- flAgreeAll : 전체 동의

- flAgreeLocation : 위치 기반 서비스 약관 동의 (필수)

- flAgreeMarketingPush : 마케팅 정보 앱 푸시 알림 수신 동의 (선택)

- ivSeeLocationTerm : 위치 기반 서비스 약관 동의 (필수) 옆에 있는 '>' 아이콘

 

- 먼저, ivSeeLocationTerm 아이콘을 클릭하면 navigate 함수를 호출하여 현재화면에서 termsFragment 화면으로 탐색하게 됩니다. 여기서 bundleOf라는 argument(인수)를 넘기게 되는데, bundle은 화면 간에 데이터를 넘기기 위해 사용되는 객체입니다. bundle은 key / value pair 형태로 작성을 하고, 저희 코드에서는 key 값이 BUNDLE_KEY_VIEWPAGER_POSITION이며 value 값은 positionLocationTerm 입니다. positionLocationTerm 값은 1이며 1인 이유는, TermsFragment 화면에서 ViewPager의 position(위치)을 0(배달의민족 이용약관)이 아닌 1(위치기반 서비스 이용약관)로 표시되어야 하기 때문입니다. 

 

- IntroTermAgreementFragment 화면은 viewModel을 사용하며, flAgreeAll, flAgreeLocation, flAgreeMarketingPush 모두 viewModel.handleIntent() 형태의 코드가 작성되어 있습니다.

- MVI 패턴을 사용하고자 해당 코드와 같이 작성을 하였습니다.

- MVI란 Model+View+Intent의 약자로서, 어떠한 이벤트인 Intent를 이용하여 해당 Intent와 관련된 동작을 수행하고, 수행 완료 되었을 때 상태를 변경하여 다시 View에게 알려주는 단방향 디자인 패턴입니다. 즉 이 예제에서는, 버튼을 클릭한 이벤트(Intent)를 이용하여 뷰모델에 새로운 상태를 요청하고, ViewModel에서는 해당 Intent(이벤트)와 관련된 동작을 수행한 후 상태를 변경하여 View에서는 해당 변경된 상태를 관찰하여 새로운 UI를 화면에 표시하는 것입니다.

- 기존 MVVM은 Model+View+ViewModel이라고 하면, MVI는 MVVM과 크게 다를 것이 없지만, MVVM과 다른 점은 MVI에서는 어떠한 이벤트에 따른 어떠한 동작이 수행되는 단방향 플로우라는 것입니다. MVVM에서는 VIewModel에 필요한 함수가 있으면, 예를 들어 해당 함수 이름이 hello()라면, View에서는 viewModel.hello() 형태로 호출을 할 수 있습니다. 이 뜻은 hello()라는 함수가 private 함수가 아닌 외부에서도 접근 가능한 함수라는 것입니다. 반대로, MVI를 사용하면 ViewModel의 함수를 호출하는 것이 아닌 이벤트 동작을 ViewModel에게 알려주어, ViewModel이 해당 동작과 관련된 함수(hello())를 직접 호출하게 하는 패턴으로 hello() 함수는 private으로 지정할 수 있게 됩니다. 그래서 외부에서 마구잡이로 호출되지 않기에 Thread Safe 하게 코드를 짤 수 있게 됩니다. 물론 그렇다고 MVVM이 Thread Safe하지 않다는 것이 아닙니다. 단순히 실수를 줄일 수 있다는 생각입니다.

 

그럼 ViewModel 코드를 보면서 더 자세히 살펴보도록 하겠습니다.

<IntroTermAgreementViewModel.kt>

- 해당 ViewModel에서 유일하게 외부에서 접근 가능한 public 함수인 handleIntent()가 존재합니다.

- View에서는 handleIntent()라는 함수만 호출이 가능하며, 즉 어떠한 이벤트인 Intent를 해당 함수의 매개변수에 인자로 넘기게 됩니다.

- 여기서 Intent는 IntroTermAgreementIntent라는 Sealed interface로 22번째 줄에 작성되어 있습니다.

- Sealed class는 생성자가 필요할 경우, Sealed interface는 생성자가 없을 경우 조금이라도 메모리를 덜 잡아먹기 위해 클래스가 아닌 인터페이스로 작성한 것입니다.

- Sealed interface 내부에 3개의 스태틱 객체가 있으며 해당 객체들은 View의 이벤트 혹은 동작을 의미합니다.

- handleIntent 함수 내부에서는 when 절을 이용하여 어떠한 Intent에 따라 어떠한 동작을 수행해야 하는지 직관적으로 나뉘어져 있습니다.

 

* 참고로 Android의 UI는 Event + State 의 조합입니다. 그래서 Event는 여기서 Intent가 되겠으며, State은 위 코드에 작성되어 있는,  StateFlow로 선언되어 있는, uiState입니다. View에서는 아래와 같이 

<IntroTermFragment.kt>

StateFlow를 Collect하여 변경된 State(상태)를 이용하여 View(UI)를 새롭게 그려줍니다.

- uiState는 초기값을 data class인, IntroTermAgreementUiState 객체를 가집니다. 이는 State Pattern을 따르며, uiState이란 property는 동작이 아닌 상태를 나타내야 합니다.

- uiState는 update이라는 함수를 지원하며, 안전하게(thread safe 하게) 상태 값을 변경할 수 있습니다.(아래 참고)

 

- 여기서 viewLifecycleOwner.lifecycleScope.launch를 사용한 이유는, Fragment는 Activity와는 다른 생명주기를 가집니다. viewLifecycleOwner을 사용하면 onCreateView <--> onDestroyView 에서만 lifecycleScope이 launch 되기 때문에 안전하게 코루틴을 동작시킬 수 있습니다.

- 내부에는 flowWithLifeCycleLifecycle.State.STARTED가 존재합니다. Flow의 경우 Android가 아닌 Kotlin Flow이므로, 만약 사용자가 앱에서 홈버튼을 클릭하여 홈화면으로 나간 경우에도 Flow를 Collect(수집)할 수 있습니다. 이는 불필요한 수행이 일어나는 것이기에 메모리 누수로 이어질 수 있습니다. 이를 막기 위해, STARTED를 사용하여 onStart()와 onStop()전까지만 Flow를 collect(수집)할 수 있도록 설정하였습니다.

 

<IntroTermAgreementViewModel.kt>

- handleIntent에서 호출하는 함수들을 살펴봅시다.

- 먼저 agreeAllTerms()의 경우 "전체 동의" 동작과 관련된 상태를 변경하는 작업의 함수입니다.

- 만약 uiState의 상태를 나타내는 IntroTermAgreementUiState 객체 내부에 있는 isLocationTermAgreed와 isMarketingPushTermAgreed 값 중에 하나라도 false이면 둘의 값을 모두 update 함수를 이용하여 안전하게 true로 변경합니다. uiState을 관찰하는 View에서는 두 변수의 값이 모두 true가 되었는지를 관찰하게 되고, 결국 Ui에 존재하는 모든 CheckBox를 활성화시키고 "시작하기" 버튼 또한 활성화합니다.

- else 문에서는 새로운 IntroTermAgreementUiState() 객체를 생성하여 상태 값을 변경합니다. IntroTermAgreementUiState 내부에 있는 isLocationTermAgreed 변수와 isMarketingPushTermAgreed 변수의 default 값은 false이므로 새로운 객체로 상태 값을 변경하면, View에서는 해당 상태를 관찰하여 (두 변수 모두 false이므로) UI에 존재하는 모든 Checkbox를 비활성화하며, "시작하기" 버튼 또한 비활성화 시킵니다.

- agreeLocationTerm()과 agreeMarketingPushTerm() 함수의 경우, !(느낌표)를 이용하여 기존 값을 반전시킵니다. 만약 true 였으면 false로, false였으면 true로 변형시킵니다. 왜냐하면 만약 사용자가 동의를 하겠다고 Checkbox를 클릭했다면, 초기값은 false 였던 값을 true로 바꾸어주고, 만약 다시 Checkbox를 클릭하면 동의하지 않겠다는 의미이기에 true를 false로 바꿔줍니다.

 

<IntroTermFragment.kt>

* 이제, Flow를 Collect(수집)하여 View가 어떻게 변하는지 살펴보겠습니다.

- cbAgreeAll : 전체 동의 CheckBox

- cbAgreeLocation : 위치 기반 서비스 약관 동의 (필수) CheckBox

- cbAgreeMarketingPush : 마케팅 정보 앱 푸시 알림 수신 동의 (선택) CheckBox

- btnStart : "시작하기" Button

 

- viewModel에서는 isLocationTermAgreed 값 혹은 isMarketingPushTermAgreed 값이 바뀌면, 해당 handleAgreementStates() 함수가 매번 호출되게 됩니다. 반대로, 값의 변화가 없을 경우 해당 함수는 호출되지 않습니다.

- cbAgreeAll 체크박스는 위치 기반 서비스 약관에 동의를 하였고, 마케팅 정보 앱 푸시 알림 수신에 동의를 한 경우 isChecked는 true가 되어 체크된 표시로 화면에 표시되게 됩니다.

- cbAgreeLocation 및 cbAgreeMarketingPush 체크박스의 경우, 대응되는 isLocationTermAgreed 및  isMarketingPushTermAgreed 값이 true면 체크된 상태, false이면 체크 해제된 상태로 표시되게 됩니다.

- btnStart의 경우 사용자가 "전체 동의"를 하지 않아도 "필수"인 위치 기반 서비스 약관에 동의를 하였다면 활성화가 되고 그렇지 않으면 비활성화가 됩니다.

 

* ViewModel을 사용한 이유?

- Fragment 간의 전환을 중요하게 봐야 합니다. Fragment는 다른 Fragment로 전환되는 경우 onDestroyView가 호출되어 Ui에 표시되던 상태들을 모두 잃게 됩니다. 그래서 Fragment의 생명주기 보다 긴 ViewModel에 해당 상태 값들을 항상 저장해주어야 합니다.

- 만약 ViewModel을 사용하지 않아 상태 값을 저장하지 않는 경우, 위 '약관 동의 화면'에서 '약관 상세보기' 화면으로 탐색해서 다시 '약관 동의 화면'으로 돌아올 경우, 기존에 동의 했던 CheckBox 상태들을 모두 잃게 됩니다.

- 이 때문에, viewModel에 어떤 항목에 동의를 했는지 기록을 하고, 해당 UI 상태를 Fragment에서(다른 화면을 갔다가 돌아와도) 관찰하여 기존의 상태값을 화면에 표시해야 합니다.

- ViewModel은 대응되는 Fragment가 네비게이션 백스택에서 제거될 때 같이 소멸되게 됩니다. 만약 A Fragment에서 B Fragment로 탐색할 경우 네비게이션 백스택에는 A Fragment가 존재하고 그 위에 B Fragment가 쌓이게 됩니다. 결국 A Fragment에서  B Fragment로 전환할 경우, onDestroyView 함수가 호출되지만 아직 네비게이션 백스택에는 존재하기 때문에 관련 ViewModel은 소멸되지 않습니다. A Fragment에서 B Fragment로 탐색하였다가 다시 A Fragment로 돌아오고 A Fragment에서 뒤로가기를 눌러 네비게이션 백스택에서 pop(파괴)이 된다면 관련 ViewModel 또한 소멸되게 됩니다.

 

[마무리]

- UI 적인 부분보다는 디자인 패턴 및 UI 상태 및 Observable 패턴에 집중하여 코드를 살펴보았습니다.

- 다소 어려운, 이해하기 쉽지 않은 내용들이었지만, 이런 것들이 있구나~ 정도로 읽으시면 되겠습니다.

- 앱 개발이라고 하면, 단순히 UI 코드를 짜고, 동작과 관련된 코드를 대충대충 짜면 된다고 생각하겠지만, 이면에는 엄청나게 복잡한 로직들이 있습니다. 쉬워 보인다고 해서 앱 개발을 시작하게 되면 안돼요! 

- Fragment 백스택 및 화면 전환할 때의 어떤 생명주기 함수가 호출되는 지도 알아보면 좋습니다.

 

[다음 목표]

- 약관 동의 상세 화면을 제작하고 코드를 살펴보도록 하겠습니다.

- 약관 동의 화면에는 서버로부터 약관 내용을 받아와서 화면에 띄워야 합니다. 저희가 배달의 민족 API를 호출할 수 없기에, 가상으로 data를 만들어 data layer에 넣고, 해당 data layer에 접근할 수 있도록 usecase를 만드는 아키텍처 패턴과 관련된 코드를 만들어보도록 하겠습니다.

 

 

[Github] 코드를 다운로드하여서 실행해보시기 바랍니다.

https://github.com/DJDrama/BaeminPractice2/tree/2_termsScreen

 

GitHub - DJDrama/BaeminPractice2

Contribute to DJDrama/BaeminPractice2 development by creating an account on GitHub.

github.com