[THE SOPT] Android 1차 세미나 과제

한승현·2022년 4월 8일
5

SOPT 30기 Android 파트 세미나 과제입니다.
github: https://github.com/KINGSAMJO/iOS_Seunghyeon
해당 주차 브랜치(ex. seminar/1)에서 각 세미나별 과제 코드를 확인할 수 있습니다.
github를 통해 코드를 보시는 것을 추천드립니다.

목차

  • 들어가며
  • 과제 단계별 개요 & 구현 설명
  • 마치며

들어가며

SOPT의 Android 파트에서는 어떤 식으로 세미나 과제가 이루어지나요?

SOPT에서는 매주 세미나가 진행되며, 각 세미나 내용과 관련한 요구사항의 과제가 마찬가지로 매주 있습니다. 파트별로 구체적인 과제 내용은 파트별로 다르지만, 현재 30기 Android 파트에서는 매주 배운 내용을 기반으로 하여 필수과제, 성장과제, 도전과제 3가지로 난이도가 분류되어 과제가 나옵니다.

SOPT Android 파트의 1주차 과제는 어떤지, 제 코드를 기반으로 함께 공부해보는 시간을 가져보겠습니다. 저는 3기수 연속으로 SOPT에서 Android를 공부하고 있기 때문에 과제에서 요구하는 사항 이상의, 평소 공부해보고 싶었던 내용을 세미나 과제에 접목시켜 구현해 보았습니다. 과제의 요구사항 이상의 내용들은 별도의 포스트를 통해 리뷰해보는 시간을 가져보겠습니다.

과제 단계별 개요 & 구현 설명

필수과제

필수과제란 해당 주차 세미나의 내용을 기반으로 한, 모든 파트원들이 "최소한" 필수적으로 구현해야 하는 과제입니다. 그런만큼 난이도는 세미나 자료만으로도 구현할 수 있는 정도의 난이도입니다. 1주차 세미나 과제의 필수과제를 이제 함께 짚어보겠습니다.

필수과제 1.


필수과제 1은 로그인 화면(SignInActivity)를 구현하는 것입니다. 이 때, 과제의 요구사항은 다음과 같습니다.

  1. 아이디, 비밀번호 모두 입력이 되어있을 때만 로그인 버튼 누를 시 HomeActivity로 이동하게 처리한다(Intent를 사용한 startActivity)
  2. 아이디, 비밀번호 둘 중 하나라도 비어있을 경우 "아이디/비밀번호를 확인해주세요" 라는 토스트 메시지를 출력한다(Toast)
  3. 비밀번호 EditText는 입력 내용이 가려져야 한다(EditText의 inputType)
  4. 모든 EditText는 미리보기 글씨가 있어야 한다(EditText의 hint)
  5. 회원가입 버튼 클릭 시 SignUpActivity로 이동한다(Intent를 사용한 startActivity)

제가 구현한 화면을 먼저 첨부하겠습니다.


먼저, EditText 관련한 요구사항인 필수과제 1 3번, 4번부터 보겠습니다. EditText 뿐만 아니라 Android의 다양한 View들은 사용할 수 있는 여러 속성들이 있습니다. 그 중에서 EditText의 inputType이라는 속성과 hint라는 속성에 대해 아는지 점검할 수 있는 과제입니다.

<EditText
	android:id="@+id/et_user_id"
    android:layout_width="0dp"
    android:layout_height="wrap_content"
    android:hint="@string/text_user_id_hint"
    android:importantForAutofill="no"
    android:inputType="text"
    android:text="@={viewmodel.userId}"
    app:layout_constraintBottom_toTopOf="@id/tv_user_password"
	app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toBottomOf="@id/tv_user_id" />

위 코드는 제가 작성한 activity_sign_in.xml의 EditText 중 아이디를 입력하는 View입니다. 보시면 android:hint 속성에 string값을 넣은 것을 확인할 수 있습니다. Android Project의 res/values/strings.xml에 문자열을 등록하고 사용할 수 있기 때문에 저는 strings.xml에 name이 text_user_id_hintLOGIN이라는 문자열을 등록해두었고 이를 사용했습니다.

hint 속성을 사용할 경우, EditText에 text가 비어있는 상황, 즉 아무것도 입력되지 않은 상황에서 hint에 할당한 문자열이 EditText 필드에 보이게 됩니다. 그래서 hint 속성은 주로 이 EditText에 어떤 값을 입력해야 하는지에 대해 사용자에게 알려주는 역할을 합니다.

<EditText
	android:id="@+id/et_user_password"
    android:layout_width="0dp"
    android:layout_height="wrap_content"
    android:hint="@string/text_user_password_hint"
    android:importantForAutofill="no"
    android:inputType="textPassword"
    android:text="@={viewmodel.userPassword}"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toBottomOf="@id/tv_user_password" />

위 코드는 제가 작성한 activity_sign_in.xml의 EditText 중 비밀번호를 입력하는 View입니다. 보시면 android:inputType 속성에 textPassword를 넣은 것을 확인할 수 있습니다. inputType 속성의 경우, EditText의 입력 타입을 결정합니다. 대표적으로 textPassword를 사용하면, EditText의 입력 타입을 비밀번호로 간주하여 문자열을 보여주지 않고 가려줍니다. 비밀번호로 abcd를 입력한다면, textPassword로 인해 **** 처럼 보입니다.

다음으로 Intent를 사용한 Activity 이동 관련한 요구사항인 필수과제 1 1번, 5번을 보겠습니다. Android에서는 Activity 전환 시 Intent라는 것을 이용합니다. Intent는 메시징 객체입니다. Intent를 이용해 다른 앱 구성요소(ex. Activity)로부터 작업을 요청합니다.

val intent = Intent(this, HomeActivity::class.java)
startActivity(intent)

위 코드는 SignInActivity.kt의, HomeActivity를 시작하는 코드입니다. Intent 객체를 생성하고, 그 Intent 객체를 startActivity() 메서드에 넘겨주는 방식으로 구현할 수 있습니다.

Intent 객체를 생성할 때는 첫 번째 파라미터로 Context를, 두 번째 파라미터로 Class 객체를 제공해야 합니다. Activity에서 Intent를 이용해 다른 Activity 이동하고 싶은 경우 Activity는 자신의 context를 가지고 있기 때문에 context로 this를 넣어줄 수 있습니다. 두 번째 파라미터인 Class 객체에는 이동할 Activity의 이름 뒤에 ::class.java를 붙여주면 됩니다. ::class.java가 궁금하다면 Kotlin Reflection을 검색해보시는 것을 추천드립니다.

회원가입 화면으로 이동하는 코드는 startActivity()가 아닌 다른 메서드를 사용했습니다. 이 내용은 성장과제 1. 화면 이동 + @에서 다루도록 하겠습니다.


마지막으로 Android의 Toast Message 관련한 요구사항인 필수과제 1 2번입니다. Android에서는 Toast Message라는 개념이 있습니다. Toast Message는 화면 아래에서 올라오는 메시지로, 마치 토스트 기계에서 식빵이 올라오는 모습과 비슷하다고 해서 Toast라고 불립니다.

Toast.makeText(this, "아이디/비밀번호를 확인해주세요", Toast.LENGTH_SHORT).show()

Toast Message의 사용 방법은 다음과 같습니다. Toast.makeText().show()라는 메서드를 활용하는데, makeText() 메서드 안에 3개의 파라미터를 전달해야 합니다. 첫 번째는 Context입니다. context는 위에서 설명한 것처럼 모든 Activity는 자신의 context를 가지기 때문에 this를 넣으면 됩니다. 두 번째 파라미터는 Toast Message로 표시하고 싶은 메시지 문자열입니다. 마지막으로 세 번째 파라미터는 이 Toast Message를 얼마나 띄우고싶은지에 해당하는 표시 시간입니다. Toast Class에는 사전에 정의된 LENGTH_SHORTLENGTH_LONG이라는 값이 있습니다. 짧게 보여주고 싶다면 전자를, 오래 보여주고 싶다면 후자를 넣으면 됩니다.

아이디, 비밀번호 미입력 검사는 ViewModel에서 수행하도록 구현했습니다. 따라서 아이디, 비밀번호 미입력 검사는 도전과제 2. MVVM에서 다루도록 하겠습니다.


필수과제 2.

필수과제 2는 회원가입 화면(SignUpActivity)를 구현하는 것입니다. 과제의 요구사항은 다음과 같습니다.

  1. 이름, 아이디, 비밀번호 모두 입력이 되었을 때만 회원가입 버튼을 누를 시 다시 SignUpActivity로 이동한다(finish()를 통한 Activity 종료)
  2. 이름, 아이디, 비밀번호 셋 중 하나라도 비어있을 경우 "입력되지 않은 정보가 있습니다"라는 토스트 메시지를 출력한다(Toast)
  3. 비밀번호 EditText는 입력 내용이 가려져야 한다(EditText의 inputType)
  4. 모든 EditText는 미리보기 글씨가 있어야 한다(EditText의 hint)

제가 구현한 화면을 먼저 첨부하겠습니다.


필수과제 2의 2번, 3번, 4번은 필수과제 1과 겹칩니다. 따라서 여기서는 필수과제 2 1번만 확인하겠습니다. Activity를 startActivity() 메서드를 통해 새로 시작해 이동하는 방법이 있다면, 반대로 Activity를 종료시키고 이전 화면으로 복귀하는 메서드 또한 존재합니다.

finish()

Android에서는 새로운 Activity를 시작할 때마다 기존의 Activity를 BackStack이라는 공간에 저장합니다. 그리고 현재 보여지는 Activity가 종료될 때마다 BackStack의 가장 상단에 있는 Activity를 꺼내와 다시 화면에 보여줍니다. finish() 메서드를 사용하면, 현재 Activity를 종료시키고 BackStack을 활용해 이전 화면으로 복귀합니다.

회원가입 시 이름, 아이디, 비밀번호 미입력 검사는 ViewModel에서 수행하게 구현했습니다. 따라서 이름, 아이디, 비밀번호 미입력 검사는 도전과제 2. MVVM에서 다루도록 하겠습니다.


필수과제 3.

필수과제 3은 자기소개 페이지(HomeActivity)를 구현하는 것입니다. 과제의 요구사항은 다음과 같습니다.

  1. ImageView, TextView 활용
  2. 이름, 나이, MBTI 등 자기소개 적기

제가 구현한 화면을 먼저 첨부하겠습니다.


필수과제 3의 1번을 fragment_home.xml을 보며 확인하겠습니다. 원래는 Activity만 사용해서 구현해도 되지만, 저는 별도로 추가 구현해보고 싶은 내용이 있어 Fragment로 구현하게 되었습니다.

<ImageView
	android:id="@+id/iv_profile_image"
    android:layout_width="0dp"
    android:layout_height="0dp"
    android:contentDescription="@string/description_profile_image"
	setProfileImage="@{viewmodel.userImage}"
    app:layout_constraintBottom_toTopOf="@id/tv_profile_name"
	app:layout_constraintDimensionRatio="1:1"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toTopOf="parent"
    app:layout_constraintWidth_percent="0.33" />

0dp를 적극적으로 활용한 ImageView입니다. width를 0dp로 설정하고 startend 제약조건을 parent에 건 후, app:layout_constraintWidth_percent 속성을 활용해 width를 33%로 설정했습니다.

위 ImageView에는 src를 설정하는 부분이 없습니다. 이 이유는 도전과제 1. DataBinding과 관련이 있습니다. 이 부분은 추후 도전과제 설명에서 다루도록 하겠습니다.


<TextView
	android:id="@+id/tv_profile_name"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_marginTop="5dp"
    android:text="@{`이름: ` + viewmodel.user.userName}"
    android:textColor="@color/black"
    android:textSize="20sp"
    app:layout_constraintBottom_toTopOf="@id/tv_profile_age"
	app:layout_constraintEnd_toEndOf="@id/iv_profile_image"
	app:layout_constraintStart_toStartOf="@id/iv_profile_image"
	app:layout_constraintTop_toBottomOf="@id/iv_profile_image" />

별 다를 것 없는 TextView입니다. 이름을 표시하는 TextView입니다.

위 TextView에는 text를 설정하는 부분이 @{}로 감싸져 있습니다. 이 또한 도전과제 2. DataBinding과 관련이 있습니다. 이 부분 역시 추후 도전과제 설명에서 다루도록 하겠습니다.


성장과제

성장과제란 해당 주차의 세미나 내용을 기반으로 하지만 한 걸음 더 성장하기 위해 고민하며 공부한 후 구현하는 과제입니다. 필수과제보다는 조금 더 어렵지만 조금만 검색하고 공부해보면 구현할 수 있는 과제이기 때문에 개발자로서 성장하기 위해 구현해 보시는 것을 추천드립니다.

성장과제 1.

성장과제 1은 단순히 startActivity()로 화면을 이동하는 것을 넘어, 특정 결과를 위해 새 화면으로 이동하고, 이동한 화면에서 작업을 한 뒤, 복귀할 때 이동한 화면에서의 작업 결과를 기존 화면에서 활용하는 것입니다. 과제의 요구사항은 다음과 같습니다.

  1. 회원가입(SignUpActivity)이 성공한다면 이전 로그인 화면으로 돌아온다
  2. 이 때 회원가입 화면에서 입력한 아이디와 비밀번호가 로그인 화면의 아이디, 비밀번호 입력칸에 채워져 있어야 한다(registerForActivityResult)

제가 구현한 화면을 먼저 첨부하겠습니다.

먼저, 성장과제 1을 이해하기 위해서는 콜백(Callback)이라는 개념에 대한 이해가 필요합니다.

콜백이란?

콜백의 사전적 정의는 이렇습니다.

콜백이란,
1. 다른 함수의 인자로써 이용되는 함수
2. 어떤 이벤트에 의해 호출되는 함수

이렇게 보면 무슨 말인가 싶습니다. 최대한 이해하기 쉽게, 일상 속의 예제로 한 번 들어보겠습니다.

승민이는 보현이와 팀 프로젝트를 합니다. 승민이는 자료조사와 발표를, 보현이는 발표자료 제작을 맡았습니다.
승민이는 자료를 조사하고 보현이한테 자료를 보내줬습니다. 자료를 보내며 승민이는 말합니다.

네가 PPT를 다 만들면 그 때 내가 그 PPT를 가지고 발표를 준비할테니 PPT 다 만들면 알려줘(보내줘)

그러면 시간이 흐른 뒤 보현이가 말합니다.

내가 PPT 다 만들어서 메일로 보냈어, 이제 발표 준비해줘

보현이의 말을 들은 승민이는 이제 발표를 준비합니다.

이해가 가시나요? 일상 속의 모습을 코드로 비유해보겠습니다.

A 함수와 B 함수는 엮여 있습니다. A가 a, c 작업을 수행하고, B는 b 작업을 수행합니다.
A 함수는 a 작업을 마친 뒤 B 함수에게 a 작업의 결과를 전달하고 콜백함수(c)를 등록합니다.

A: B 너가 a를 가지고 b 작업을 다 수행하면 내가 b 작업의 결과를 가지고 c 작업을 할게. 그러니 B 너의 작업이 다 끝나면 알려줘(콜백해줘)

그러면 B 함수가 b 작업을 마친 뒤 A 함수에게 알립니다(콜백).

B: 나 b 작업 끝나서 너한테 알려주는거야(콜백), 너 c 작업 해

B 함수가 콜백하면 A 함수는 콜백함수를 수행합니다.

registerForActivityResult란?

register: 등록하다
ActivityResult:Activity의 Result, 즉 결과

registerForActivityResult() 메서드를 사용하면, 해당 Activity의 Result를 활용하는 콜백을 등록할 수 있습니다. 위에서는 함수로 예시를 들었지만 Activity간에도 콜백을 등록할 수 있습니다.

세미나 과제를 위의 예시에 적용해볼까요? 그렇다면 이런 상황이라고 할 수 있습니다.

SignInActivity: 얘, SignUpActivity야. 너 회원가입 성공하면 그 때의 ID랑 비밀번호를 나한테 알려줄래? 너가 알려주면 내가 그 ID랑 비밀번호를 내 EditText 필드에 채워넣을게!

SignUpActivity: 내가 방금 한승현이라는 사람을 회원가입 성공시켰어(콜백), 얘의 ID는 qwer이고 비밀번호는 1234야!

SignInActivity: 오 그래? 그러면 내 EditText 필드에 너가 알려준 qwer, 1234를 채울게!

개념을 확실하게 잡았으니 이제 코드를 이해해봅시다.

private val activityResultLauncher =
	registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
		if (it.resultCode == RESULT_OK) {
        	val userId = it.data?.getStringExtra("userId") ?: ""
            val userPassword = it.data?.getStringExtra("userPassword") ?: ""
            binding.etUserId.setText(userId)
            binding.etUserPassword.setText(userPassword)
		}
	}

하나하나 분석해보겠습니다.

  1. registerForActivityResult: ActivityResult에 대한 콜백을 등록하고 Launcher를 생성.
  2. ActivityResultContracts: ActivityResult에 대한 Contract(계약) 여러 개(s)가 포함된 클래스(ActivityResultContracts)입니다. ActivityResultContract 클래스는 Result를 생성하는 데 필요한 입력 유형과 Result의 출력 유형을 정의함.
  3. StartActivityForResult: 새 Activity를 열어주되, 기존의 startActivity와 달리 데이터를 쌍방향으로 교류함을 의미.
  4. resultCode: 새 Activity는 작업을 수행한 후 Result, 즉 결과가 성공 후 종료로 나온 결과인지 혹은 실패 후 종료로 나온 결과인지에 대한 값을 RESULT_OK 또는 RESULT_CANCELED 로 반환함. 그러면 결과를 받은 Activity, 즉 콜백을 받은 Activity는 resultCodeRESULT_OK인 경우에는 성공한 것으로 간주해 성공 시에는 무엇을 수행할 것인지, RESULT_CANCELED인 경우에는 실패한 것으로 간주해 실패 시에는 무엇을 수행할 것인지 결정할 수 있음.

위의 코드에서는 콜백이 등록된 런처를 생성만 했고, 이를 activityResultLancher라는 변수에 할당했습니다. 그렇다면 이 런처를 가지고 어떻게 SignUpActivity를 실행시키고 결과를 받을 수 있을까요?

binding.btnSignUp.setOnClickListener {
	val intent = Intent(this, SignUpActivity::class.java)
    activityResultLauncher.launch(intent)
}

SIGNUP 버튼을 누르면 SignUpActivity로 이동할 수 있는 Intent를 생성합니다. 그리고 생성한 Intent 객체를 아까 생성한 런처 activityResultLauncherlaunch() 메서드에 인자로 실어서 실행시킵니다.

그 결과로, SignUpActivity가 실행됩니다. 단순 startActivity()와 다른 점은, startActivity()는 그저 새 Activity를 시작하는 것뿐이었다면, 이제는 새 Activity로부터 콜백을 받을 수 있게 되었습니다!

Result(결과) 전달하기

위 과정을 통해 SignUpActivity가 실행되었습니다. 그렇다면 회원가입 후 아이디와 비밀번호를 다시 SignInActivity에게 돌려주는 코드도 작성해야 합니다. 이 과정은 IntentsetResult()를 사용합니다.

회원가입 시 이름, 아이디, 비밀번호 미입력 검사는 ViewModel에서 수행합니다. 검사 로직에 대해서는 별도로 언급하지 않습니다. 해당 내용이 궁금하신 분들은 추후 도전과제 2. MVVM을 확인해주시면 감사하겠습니다.

val intent = Intent(this, SignInActivity::class.java)
intent.putExtra("userId", binding.etUserId.text.toString())
intent.putExtra("userPassword", binding.etUserPassword.text.toString())
setResult(RESULT_OK, intent)
if (!isFinishing) {
	finish()
}

SignInActivity에 대한 Intent 객체를 생성하고, putExtra() 메서드를 사용해 전달할 데이터를 담습니다(put). putExtra()는 인자 2개를 받는 메서드입니다. 첫 번째 인자로 전달할 값의 name을, 두 번째 인자로 전달할 값을 넣어줍니다. 우리는 아이디와 비밀번호를 전달해야 하므로, 아이디와 비밀번호를 putExtra를 통해 Intent 객체에 담아줍니다.

그 후, 이 결과(Result)가 성공한 케이스(RESULT_OK)인지, 취소된(실패된) 케이스인지(RESULT_CANCELED) 설정해야 합니다(set). 그래서 setResult() 메서드를 통해 첫 번째 인자로 resultCode를, 두 번째 인자로 Intent 객체를 넣습니다. setResult() 메서드가 수행되는 순간, SignInActivity에서는 콜백함수가 수행됩니다.

하지만 SignInActivity가 콜백함수를 수행하면 뭐합니까, 회원가입을 했으면 회원가입 화면을 종료시켜야 합니다. 그래서 finish() 메서드를 통해 SignUpActivity를 종료시키고 로그인 화면으로 돌아갑니다.

콜백함수 분석

콜백함수는 registerForActivityResult()의 중괄호 부분입니다. 해당 콜백함수만 살펴보도록 하겠습니다. (왜 중괄호 안에 쓰는지 궁금하신 분은 Android 람다를 검색해보시는 것을 추천드립니다)

if (it.resultCode == RESULT_OK) {
	val userId = it.data?.getStringExtra("userId") ?: ""
    val userPassword = it.data?.getStringExtra("userPassword") ?: ""
    binding.etUserId.setText(userId)
    binding.etUserPassword.setText(userPassword)
}

resultCodeRESULT_OK라면, 즉 정상적으로 결과를 반환했다는 콜백을 받으면 우리는 Result에 해당하는 it에서 보낸 결과를 꺼내서 사용해야 합니다. it.data 에는 SignUpActivity가 Intent가 들어있습니다. 우리는 Intent에서 이번엔 Extra를 가져와 보겠습니다.
getExtra() 메서드는 putExtra() 메서드와 달리, 타입을 지정해줘야 합니다. String Extra를 가져오려면 getStringExtra()를, Int Extra를 가져오려면 getIntExtra()를 사용하면 됩니다. getExtra() 메서드의 인자로는 putExtra() 시 입력한 name을 전달해주면 됩니다.

이렇게 SignUpActivity로부터 결과(Result)를 전달받았으니, 이제 화면의 EditText의 필드에 그 값을 넣습니다. EditText의 경우 setText() 메서드를 이용해 String을 text로 set 할 수 있습니다.

성장과제 2.

성장과제 2의 요구사항은 다음과 같습니다.

  1. 화면에 표시될 내용이 많아서 내용이 화면 밖으로 넘어가는 경우를 대비해 스크롤이 가능하게끔 화면을 변경한다(ScrollView)
  2. ImageView의 가로세로 비율을 1:1로 만든다(constraintDimensionRatio)

제가 구현한 화면을 먼저 첨부하겠습니다.

성장과제 2의 1번은 ScrollView를 사용해 화면을 스크롤 가능하게 만들어보는 과제입니다. ScrollView를 사용하면 ScrollView 안의 View들이 차지하는 영역이 ScrollView가 차지하는 영역보다 더 클 때, 스크롤을 통해 더 표시할 수 있습니다. Android 공식문서의 ScrollView를 한 번 보겠습니다.

이 공식문서에는 가장 중요한 사실 하나가 들어있습니다.

ScrollView may have only one direct child placed within it ScrollView는 그 안에 하나의 직계 자식(View)만 가질 수 있다.

이것도 말로만 하니까 잘 와닿지 않습니다. 우리는 ScrollView 안에 ImageView와 여러 TextView를 넣어야 하는데, 하나의 직계 자식만 가질 수 있다니. 이 문제를 어떻게 해결해야 할까요?

<ScrollView
	android:id="@+id/sv_profile"
    android:layout_width="match_parent"
    android:layout_height="0dp"
    android:layout_marginBottom="20dp"
    app:layout_constraintBottom_toTopOf="@id/btn_edit"
    app:layout_constraintTop_toTopOf="parent">

	<androidx.constraintlayout.widget.ConstraintLayout
    	android:id="@+id/layout_profile"
    	android:layout_width="match_parent"
        android:layout_height="wrap_content">
		
      	<!-- ImageView와 TextView들-->
      
	</androidx.constraintlayout.widget.ConstraintLayout>

</ScrollView>

fragment_home.xml의 일부입니다. ScrollView 안에 하나의 직계 자식인 ConstraintLayout만 들어 있습니다. 우리가 ScrollView 안에 넣어야 할 ImageView와 TextView들은 ConstraintLayout 안에 들어있습니다.

공식문서에 적힌 단 하나의 직계 자식만 가진다는 조건을 만족시켰습니다. ScrollView의 직계 자식은 ConstraintLayout 뿐입니다. 즉, 하위 View들을 모두 포함하는 ViewGroup을 만들고, ScrollView가 그 ViewGroup을 하나의 직계 자식으로 삼도록 구현하면 됩니다.

성장 과제 2의 2번은 ImageView의 width(가로), height(세로) 비율을 1:1로 설정하라는 요구사항입니다. 이 요구사항은 constraintDimensionRatio 속성을 사용해 충족시킬 수 있습니다.

<ImageView
	android:id="@+id/iv_profile_image"
    setProfileImage="@{viewmodel.userImage}"
    android:layout_width="0dp"
    android:layout_height="0dp"
    android:contentDescription="@string/description_profile_image"
	android:scaleType="centerCrop"
    app:layout_constraintBottom_toTopOf="@id/tv_profile_name"
	app:layout_constraintDimensionRatio="1:1"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toTopOf="parent"
    app:layout_constraintWidth_percent="0.33" />

constraintDimensionRatio 속성을 사용하면 widthheight 값을 비율에 맞게 설정할 수 있습니다. 이 속성을 사용하려면, 조건이 있습니다.

적어도 한 면(가로 또는 세로)은 크기가 정해져야 하며,
그 정해진 크기에 맞춰 비율로 크기를 정할 나머지 면은 0dp로 설정되어야 한다.

제가 짠 ImageView에서는 위 조건을 충족할까요? 충족합니다.

  1. width는 0dp로 설정되어 있으나, layout_constraintWidth_percent="0.33"에 의해 가로 폭의 33%만큼 width가 고정됩니다.
  2. 고정된 width에 맞춰, 1:1의 비율로 height가 자동으로 계산됩니다.

그런데, 만약 width도 0dp고 height도 0dp이며 둘 다 화면에 꽉 제약이 걸려있다면, constraintDimensionRatio로 1:1을 지정하면 어떻게 될까요?

이렇게, 짧은 쪽을 기준으로 1:1 비율을 맞춥니다!

도전과제

도전과제는 세미나에서 따로 학습하지는 않지만 혼자, 또 파트원들과 함께 따로 공부하며 도전해볼 수 있는 난이도의 과제입니다. 필수과제와 성장과제 내용을 이해한 후 도전과제에도 도전해보시면 더욱 좋을 것 같습니다.

도전과제 1.

과제의 요구사항은 ViewBinding이 아닌 DataBinding으로 구현하는 것입니다.

findViewById? ViewBinding? DataBinding?

findViewById란 뭘까요? ViewBinding이란 뭘까요? DataBinding이란 뭘까요? 1차 세미나에 대해서는 ViewBinding에 대해 다룹니다. 그렇지만 한 번 다시 짚고 넘어가보겠습니다.

findViewById

ViewBinding 이전에는 findViewById라는 메서드를 이용해 View에 접근했습니다. 예를 들면 이렇습니다. id가 "button_login"이라는 Button이 존재하고, 이 Button에 대한 ClickEvent를 구현한다면 아래와 같이 구현할 수 있습니다.

val button = findViewById<Button>(R.id.buttonLogin)
button.setOnClickListener {
	// 클릭 시 수행할 코드
}

하지만 이 방법에는 단점이 있습니다.

  1. 느리다.
  2. null을 return할 수 있어 NullPointerException으로 인해 앱이 비정상 종료될 수 있다.
  3. findViewById에서 명시한 View의 Type과, 실제로 id로 참조하려고 하는 View의 Type이 다를 수 있다.

ViewBinding

위의 이유로 1차 세미나에서는 findViewById가 아닌 ViewBinding을 배웠습니다. Android 공식문서는 ViewBinding이 뭐라고 설명했을까요?

ViewBinding은 View와 상호작용하는 코드를 더 쉽게 작성해주는 기능입니다. ViewBinding을 사용하도록 설정하면, 각각의 layout 파일(xml)에 대한 Binding 클래스가 자동으로 생성되며, 이 Binding 클래스의 인스턴스를 사용하여 해당 layout(xml) 안에 있는 id를 가진 모든 View에 대한 직접 참조가 가능해집니다.

원래 말로만 설명을 들으면 어려운 법입니다. 위의 글 중 꼭 알아야 하는 내용은 다음 2가지입니다.

  1. ViewBinding을 사용하면 각각의 layout 파일에 대한 Binding 클래스가 자동으로 생성된다.
  2. 이렇게 자동으로 생성된 Binding 클래스의 인스턴스를 사용해 해당 layout 파일 안에 있는 id를 가진 모든 View에 접근할 수 있다.

ViewBinding을 사용하려면 아래 순서를 따라야 합니다. MainActivity에서 ViewBinding을 사용하는 상황으로 예시를 들겠습니다.

먼저, build.gradle(Module: ~.app)에서 ViewBinding을 사용하겠다고 선언해야 합니다. android { } 중괄호 안에 buildFeature { } 내부에 viewBinding true라고 선언합니다.

android {
	....
    buildFeatures {
    	viewBinding true
    }
    ....
}

다음으로, MainActivity.kt에서 Binding 클래스의 인스턴스를 만들어야 합니다. MainActivity에 대한 Binding 클래스의 이름은 ActivityMainBinding입니다.

Binding 클래스의 이름은 아래 규칙을 따릅니다.
xml 파일의 이름은 snake_case로 작성됩니다. 이를 CamelCase로 바꾼 후, 뒤에 Binding을 붙인 게 Binding 클래스명입니다.
ex - activity_main.xml의 Binding 클래스 이름 = ActivityMainBinding

class MainActivity: AppCompatActivity() {
	....
    private lateinit var binding: ActivityMainBinding
    ....
    
    override fun onCreate(savedInstanceState: Bundle?) {
    	super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)
        ....
	}   
    ...
}

마지막으로, id를 가진 View에 접근할 때 binding 이라는 Binding 클래스의 인스턴스를 사용해 접근합니다.

private fun login() {
	binding.btnSignIn.setOnClickListener {
    	....
	}
}

위의 과정을 통해 ViewBinding을 사용하여 id를 가진 모든 View에 접근할 수 있습니다. findViewById 대비 ViewBinding의 장점은 뭘까요? 이에 대해 Android 공식문서는 다음과 같이 말합니다.

  1. Null-Safe
  2. Type-Safe

첫째로, Null-Safe하다는 특징이 있습니다. ViewBinding은 View에 대한 직접 참조를 생성하기 때문에, 유효하지 않은 ID를 참조하려다 null pointer exception이 발생할 위험이 없습니다. 유효하지 않은 ID를 참조하려고 시도하면 컴파일 에러가 발생하기 때문입니다.
두 번째로, Type-Safe하다는 특징이 있습니다. findViewById를 사용할 때는, 해당 id의 View Type도 같이 알려줘야 했습니다. 만약 findViewById<TextView>(R.id.buttonLogin)라는 코드를 작성했는데, buttonLogin이라는 id를 가진 View가 TextView가 아닌 Button일 경우 class cast exception이 발생하게 됩니다. 하지만 ViewBinding은 각 Binding 클래스의 필드가 xml 파일에서 참조하는 View와 일치하기 때문에 Type-Safe합니다.

DataBinding

그렇다면 DataBinding은 뭘까요? DataBinding에 대한 Android 공식문서를 살펴보겠습니다.

DataBinding은 programmatically한 방식보다 declarative한 형식을 사용해 레이아웃의 UI 구성요소와 앱의 데이터 스소를 결합할 수 있게 해주는 support 라이브러리입니다.

이렇게만 보면 또 어렵습니다. Android 공식문서에 적힌 예시를 함께 보겠습니다.

sample_text라는 id를 가진 TextView의 text를 viewModel이라는 인스턴스의 userName이라는 필드의 값으로 할당하고 싶은 상황이라고 가정하겠습니다. findViewById를 사용하는 경우에는 Kotlin 코드로 이렇게 작성해야 했습니다.

findViewById<TextView>(R.id.sample_text).apply {
	text = viewModel.userName
}

DataBinding을 사용하는 경우 Kotlin 코드가 아닌, xml 코드로 아래와 같이 작성할 수 있습니다.

<TextView
	...
    android:text="@{viewmodel.userName}"
    ... />

그리고 참고사항으로는 이렇게 적혀있습니다.

많은 경우, DataBinding이 제공하는 이점들을 ViewBinding은 더 간단하고 우수한 성능으로 제공할 수 있다. findViewById를 대체하기 위해 주로 DataBinding을 사용하는 경우 그 대신 ViewBinding을 사용하는 것을 고려해 봐라.

이 내용에 대해서는 DataBinding 마지막 부분에서 다뤄보겠습니다. 우리는 이제 직접 DataBinding을 사용해보며 이해해보겠습니다. SignInActivity에서 DataBinding을 사용하는 상황으로 예시를 들겠습니다.

먼저, build.gradle(Module: ~.app)에서 DataBinding을 사용하겠다고 선언해야 합니다. android { } 중괄호 안에 buildFeatures { } 내부에 dataBinding true라고 선언합니다.

android {
	....
    buildFeatures {
    	dataBinding true
    }
    ....
}

DataBinding 레이아웃 파일은 layout이라는 루트 태그로 시작하고, data 태그 및 나머지 view 태그들로 구성됩니다. 말로만 보면 어려우니 아래 예시를 보겠습니다.

<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools">

    <data>

        <variable
            name="viewmodel"
            type="co.kr.sopt_seminar_30th.presentation.viewmodel.SignInViewModel" />
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:padding="20dp"
        tools:context=".presentation.ui.auth.SignInActivity">

      	....
      
	</androidx.constraintlayout.widget.ConstraintLayout>

</layout>

뭔가 달라진 게 보이시나요? ViewBinding을 사용할 때의 루트 태그는 ConstraintLayout이었습니다. 하지만 DataBinding을 사용할 때는 루트 태그가 layout으로 바뀐 것을 확인할 수 있습니다. 또, 추가로 data라는 태그 안에 variable이라는 태그가 있는 것을 알 수 있습니다.

  1. 루트 태그는 layout로 한다.
  2. datavariablename이라는 이름의 변수를 활용해서 표현식을 작성한다.
  3. type에는 해당 변수의 클래스 경로를 넣는다.

DataBinding은 단방향 데이터 결합과 양방향 데이터 결합이 있습니다. 단방향 데이터 결합은 DataBinding 변수를 read만 할 수 있습니다. 양방향 데이터 결합은 DataBinding 변수를 read하고 write할 수 있습니다.

단방향 데이터 바인딩: @{} 구문을 사용합니다.
양방향 데이터 바인딩: @={} 구문을 사용합니다.

저의 경우에는 activity_sign_in.xml의 아이디, 비밀번호를 입력하는 EditText의 text 속성을 SignInViewModeluserIduserPassword라는 변수에 양방향 데이터 바인딩을 시켜줬습니다. 아래 xml 코드는 activity_sign_in.xmlEditText 부분입니다.

<EditText
	android:id="@+id/et_user_id"
    android:layout_width="0dp"
    android:layout_height="wrap_content"
    android:hint="@string/text_user_id_hint"
    android:importantForAutofill="no"
    android:inputType="text"
    android:text="@={viewmodel.userId}"
    app:layout_constraintBottom_toTopOf="@id/tv_user_password"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toBottomOf="@id/tv_user_id" />
<EditText
	android:id="@+id/et_user_password"
    android:layout_width="0dp"
    android:layout_height="wrap_content"
    android:hint="@string/text_user_password_hint"
    android:importantForAutofill="no"
    android:inputType="textPassword"
    android:text="@={viewmodel.userPassword}"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toBottomOf="@id/tv_user_password" />

위 두 EditText를 보시면, android:text 속성에 각각 viewmodel.userIdviewmodel.userPassword를 양방향 데이터 바인딩으로 결합시켜 준 것을 확인할 수 있습니다.

그러면 화면에는 어떻게 표시될까요? 처음 화면에 표시될 때는, viewmodel에서 userIduserPassword 값을 읽어(read) EditText 필드에 값을 보여줍니다. 하지만 사용자가 EditText에 직접 값을 입력할 경우, viewmodeluserIduserPassword의 값이 사용자가 입력한 값으로 변경(write)됩니다.

하지만 여전히 뭔가 의문스러운 점이 있습니다. 우리는 xml 코드에 viewmodel이라는 친구가 어떤 친구인지 알려준 적이 없습니다. 어떤 클래스 타입인지만 알려줬을 뿐, 어떤 인스턴스가 들어가는지 알려준 적은 없는데, 어떻게 userIduserPassword에 접근하는지, 이상하지 않으신가요? 해답은 SignInActivity.kt 안에 있습니다.

class SignInActivity: AppCompatActivity() {
	private lateinit var binding: ActivitySignInBinding
    private val viewModel by viewModels<SignInViewModel>()
    
    ....
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // 1. DataBindingUtil 클래스를 사용하는 방법
        binding = DataBindingUtil.setContentView(this, R.layout.activity_sign_in)
        // 2. ActivitySignInBinding 클래스를 사용하는 방법
        binding = ActivitySignInBinding.inflate(layoutInflater)
        
        // 레이아웃의 variable 할당
        binding.viewmodel = signInViewModel
	}
    ....
}

DataBindingUtil 클래스나 ActivitySignInBinding 클래스를 사용하여 binding 객체를 생성한 후, binding 객체의 변수 viewmodelSignInActivitysignInViewModel을 할당해 주었습니다. 이제 의문이 해결되셨나요? 어떤 variable인지는 Activity에서 binding 객체를 초기화해준 후 할당해주기 때문에, signInViewModel 인스턴스의 userIduserPassword에 접근하게 됩니다.

DataBindingUtil vs Activity~Binding
무슨 차이일까요? 저도 잘 모릅니다. 공부한 뒤 또 새로운 포스트로 찾아뵙겠습니다.

그럼 ViewBinding 대비 DataBinding의 장점은 뭘까요? 이에 대해 Android 공식문서는 이렇게 말하고 있습니다.

Observable, 그러니까 관찰할 수 있는 데이터 객체와 함께 사용하면, 데이터 변경 시 UI를 업데이트하는 것을 신경쓰지 않아도 된다고 합니다. Observable 객체와 DataBinding을 함께 사용할 경우, 데이터가 변경되면 UI가 자동으로 업데이트된다는 장점이 있습니다. 이 내용은 LiveData라는 내용과 함께 공부하면 좋을 것 같다고 생각합니다.

도전과제 2.

도전과제 2는 MVVM 아키텍처 패턴을 알아보고, 실제로 적용해보는 과제입니다. MVVM이란 뭘까요?

MVVM이란?

Android 아키텍처에 대해 A부터 Z까지 제가 모두 다루기엔 너무 양이 많을 뿐 아니라, 다룰 능력도 아직은 부족합니다. 하지만 MVVM으로 과제를 구현해보라는 요구사항이 있기 때문에, 이를 위해서는 일단 MVVM이라는 게 어떤 개념인지는 최소한 알아야 구현할 수 있습니다.

MVVM이란, 기존의 MVC 패턴의 God Class의 단점들을 보완하기 위해 나타난 대안 중 하나입니다. MVVM이라는 단어는 Model, View, ViewModel에서 각 어절의 첫 문자를 따서 만들어졌습니다.

  1. View: Activity, Fragment가 View의 역할을 합니다. 사용자의 Action을 받습니다(ex - EditText에 텍스트 입력, Button 클릭 등). View는, ViewModel에게 View를 그리는 데 필요한 데이터를 요청하고 ViewModel을 통해 필요한 데이터를 제공받습니다.
  2. ViewModel: View가 요청한 데이터를 Model에게 요청합니다. Model에게서 제공받은 데이터를 View에게 제공합니다.
  3. Model: ViewModel이 요청한 데이터를 ViewModel에게 제공합니다. Room 같은 DB 사용 혹은 Retrofit 등을 사용한 API 호출을 통해 적합한 데이터를 제공합니다.

MVVM 패턴은 MVC 패턴에 비해 이러한 장점이 있습니다.

  1. ViewViewModel의 데이터를 관찰(Observe)하고 있습니다. 따라서 UI 업데이트가 간편해집니다.
  2. View가 직접 Model에 접근하는 대신, ViewModel을 통해 데이터를 얻기 때문에 Memory Leak의 위험이 줄어듭니다. 직접 Model에 접근하지 않아 View의 수명 주기에 의존하지 않기 때문입니다.
  3. 기능별 모듈화가 잘 되어있다면, 유지보수하기에 매우 용이합니다. DB 교체 같은 작업이 편리하고, ViewModel은 재사용이 가능합니다.

Android, 그러니까 Google 측에서는 Android Architecture Component, 즉 AAC라고 하는 것을 제공합니다. 조금 더 편하라고 아키텍처 구성요소에 관한 라이브러리를 제공하는데, 여기에는 ViewModel(ViewModel), LiveData(Observable Data Object), Room(Local Database) 등이 포함됩니다. 이 포스트에서 각각에 대한 자세한 설명이나 사용법을 하나하나 상세히 적기에는 너무 글이 길어져, 추후 다른 포스트에서 다뤄보도록 하겠습니다.

저의 경우에는 MVVM 패턴으로 이렇게 구현했습니다.

  1. ViewViewModel에게 데이터를 요청하고, ViewModel의 데이터를 관찰해 변경사항이 있을 시 UI를 자동으로 업데이트합니다.
  2. ViewModel에서 필요로 하는 데이터는 Repository를 통해 Model, 즉 로컬 데이터베이스인 Room에 요청해서 받아옵니다.
  3. RepositoryViewModelModel 사이에 위치하며, 사용자 동작에 따라 필요한 데이터를 로컬 데이터베이스나 외부 서버에서 가져오는 역할을 수행합니다. Repository의 존재로 인해 ViewModel은 직접 데이터를 관리할 필요가 없습니다.
  4. 이 경우, ViewModel을 직접적으로 참조하지 않습니다. 대신 중간에 있는 ViewModel을 통해 필요한 데이터를 제공받습니다. ViewModel 또한 Model에 직접 접근하지 않고 Repository를 통해 필요한 데이터를 제공받습니다.

이만큼 읽으셨다면, 아마 이런 생각이 드실 수도 있습니다.

이거 너무 복잡해졌는데... 좋은 거 맞아?

MVVM의 단점이기도 합니다. 구조가 복잡하고, 배우기에 진입장벽이 분명하게 존재합니다. 하지만 저는 사용하는지를 아는게 가장 중요하다고 생각합니다. 제가 생각하고 느낀 MVVM 패턴을 사용하는 이유는 이렇습니다.

관심사를 분리시킨다. View는 UI를 업데이트하는데 신경을 기울이고, ViewModel은 View가 필요로 하는 데이터를 적재적소에 제공할 수 있도록 준비하고, Model은 앱 전체에서 사용할 데이터를 전반적으로 관리하는 것에 온 신경을 기울이자.
한 마디로, 의존성을 줄이자.

마치며

과제 회고

3기수째 세미나 과제를 하면서 돌아보니 매 기수마다 제 코드는 조금씩 다릅니다. 28기 과제에서는 필수과제 하나에도 허덕이며 끙끙댔고, 29기 과제에서는 잘 이해하지도 못한 채로 일단 마구 적용하려고 했습니다. 이번 30기에서는 과제를 수행하며 최대한 이해하고, 남에게 설명할 수 있을 정도로 공부했습니다. 사실 이 글도 제 공부를 정리함과 동시에, 이 글만 보고도 YB 파트원들이 조금이나마 더 쉽게 이해했으면 좋겠다는 생각으로 최대한 쉬운 말들로 풀어서 쓰려고 노력했습니다. 그 과정에서 저 또한 더 잘 이해하고 한 번 더 공부하는 시간을 가졌습니다.

28기, 첫 기수의 제 코드는 정말 엉망이었고 29기, 두 번째 기수 때의 코드 또한 마음에 들지 않습니다. 과거의 코드가 마음에 들지 않는다는 것은 제가 그만큼 조금이나마 성장했다는 뜻인가 생각이 들곤 합니다. 그 성장은 저 혼자서 이룬 것이 아닙니다. 다양한 주변 동료 개발자들을 통해 배우고 얼굴 모르는 수많은 블로거들의 도움을 받았을 것입니다.

좋은 개발자란 무엇일까요? 코드를 잘 짜는 사람? 남들과 활발하게 의견을 공유하고 교류하는 사람? 저는 아직은 잘 모르겠습니다. 하지만 전자와 후자 양쪽 모두에 해당되면 좋은 게 아닐까요? 저 또한 아직은 많이 부족한 개발자지만 다양한 분들과 함께 성장하고 싶다는 생각을 항상 합니다.

이 글을 읽으신 여러분들이 어떤 경로를 통해 읽게 되셨는지는 모르겠지만, 함께 성장할 수 있었으면 좋겠습니다. 긴 글 읽어주셔서 감사합니다.

profile
영차영차

2개의 댓글

comment-user-thumbnail
2022년 4월 15일

정말 열심히 정리하시네요

1개의 답글