아래 내용은 패스트 캠퍼스 [35개 프로젝트로 배우는 Android 앱 개발 feat. Jetpack Compose]의 강의 내용을 기반으로 작성한 자습 기록입니다.



📚단위 변환기 앱

ℹ️앱 설명

미터(m)와 센티미터(cm) 간 단위를 변환시켜 주는 앱. 변환을 원하는 값을 입력하면, 아래 변환된 값을 노출시키는 앱.


✅구현 기능

  • 상단 부분에 변환되길 원하는 값 입력.
  • 하단 부분에 변환된 값 출력.
  • 바꾸기 이미지 버튼을 누르면 두 단위의 값을 변환. ex) cm -> m, m -> cm


✅사용되는 기능

  • UI
    • ConstraintLayout
    • EditText
  • Android
    • ViewBinding
    • onSaveInstanceState


📔UI 구현

단위 변환기 앱은 ConstraintLayout을 기반으로 구현할 것이다. 프로젝트를 생성하자마자 ConstraintLayout이 설정된 기본 화면을 볼 수 있다. ConstraintLayout에 대해서 나중에 자세히 다루어보도록 하자.

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

</androidx.constraintlayout.widget.ConstraintLayout>

UI는 단위를 입력해야 하기 때문에 입력 부분에는 EditText를 상단에 배치하였고, 하단에는 변환된 결괏값을 출력해야 하므로 TextView를 지정하였다. 그리고 각각의 View 옆에 단위를 표시하는 TextView 2개를 표시하였다. 마지막으로, 클릭하였을 때 두 단위를 바꾸는 ImageButton을 추가하였다.

📖activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <EditText
        android:id="@+id/inputEditText"
        android:layout_width="200dp"
        android:layout_height="wrap_content"
        android:gravity="end"
        android:hint="자연수를 입력해주세요."
        android:inputType="number"
        android:maxLength="7"
        android:textColor="@color/purple_700"
        android:textColorHint="@color/purple_200"
        android:textStyle="italic"
        app:layout_constraintBottom_toTopOf="@id/outputTextView"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintHorizontal_bias="0.3"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintVertical_chainStyle="packed" />

    <TextView
        android:id="@+id/outputTextView"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginTop="30dp"
        android:gravity="end"
        android:text="100"
        android:textColor="@color/black"
        android:textSize="30sp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="@id/inputEditText"
        app:layout_constraintStart_toStartOf="@id/inputEditText"
        app:layout_constraintTop_toBottomOf="@+id/inputEditText" />

    <TextView
        android:id="@+id/inputUnitTextView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="16dp"
        android:text="cm"
        android:textSize="20sp"
        app:layout_constraintBaseline_toBaselineOf="@id/inputEditText"
        app:layout_constraintStart_toEndOf="@id/inputEditText"
        app:layout_constraintTop_toTopOf="@id/inputEditText" />

    <TextView
        android:id="@+id/outputUnitTextView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="16dp"
        android:text="m"
        android:textSize="20sp"
        app:layout_constraintBaseline_toBaselineOf="@id/outputTextView"
        app:layout_constraintStart_toEndOf="@id/outputTextView" />

    <ImageButton
        android:id="@+id/swapImageButton"
        android:layout_width="50dp"
        android:layout_height="50dp"
        android:layout_marginStart="16dp"
        android:backgroundTint="#ffffffff"
        android:src="@drawable/ic_baseline_swap_vert_36"
        app:layout_constraintBottom_toBottomOf="@id/outputTextView"
        app:layout_constraintStart_toEndOf="@id/inputUnitTextView"
        app:layout_constraintTop_toTopOf="@id/inputEditText" />

</androidx.constraintlayout.widget.ConstraintLayout>

위의 코드의 결과는 다음과 같다.

ui_result

제약 조건을 설정함에 있어서 각각의 view는 필수적으로 id 값을 갖고 있어야 한다. id 값이 없다면 view들을 구분할 수 없어 배치하기가 어렵다.



📔ViewBinding

이번 프로젝트는 findViewById를 사용하지 않고 viewBinding을 이용하였다.

viewBinding은 findViewById와 비교해 다음과 같은 장점을 가진다.

📖ViewBinding의 장점

  1. Null 안정성: findViewById에서는 유효하지 않은 id를 사용했을 때 NullException이 발생하는데, viewBinding은 바인딩이 각각의 레이아웃에 1대 1로 할당되기 때문에 이런 오류가 발생할 일이 없다.
  2. Type 안정성: findViewById에서는 해당 id의 타입을 잘못 입력해서 사용했을 때 오류가 CastException이 발생하지만, viewBinding에서는 이러한 오류가 발생할 수 없다.
  3. 속도가 빠르다.


이제 viewBinding을 사용해보자.

아래 과정은 안드로이드 developer 공식문서의 과정을 참고하였다.


먼저 build.gradle(module) 파일에서 아래와 같은 코드를 추가한 후 sync를 한다.

📖build.gradle(module)

android {

    ...

    viewBinding {
        enabled = true
    }
}

그리고 클래스 파일과 와서 다음의 순서를 따른다.

  1. onCreate() 메서드에서 생성된 바인딩 클래스에 포함된 inflate() 메서드를 호출한다.
  2. getRoot() 메서드를 호출하거나 Kotlin 속성 구문을 사용하여 루트 뷰 참조를 가져온다.
  3. 루트 뷰를 setContentView()에 전달하여 화면상의 활성 뷰로 만든다.

📖MainActivity.kt

private lateinit var binding: ActivityMainBinding

바인딩 변수를 먼저 선언해 주었다. 뷰 바인딩을 허용했기 때문에 ActivityMainBinding와 같이 바인딩을 위한 클래스가 생성된 것을 볼 수 있다. 이때, lateinit 키워드로 지연 초기화를 해주었는데, 이 키워드를 사용할 때에는 타입이 반드시 명시되어 있어야 한다.

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)

    // binding 변수에 인스턴스 할당
    binding = ActivityMainBinding.inflate(layoutInflater)
    val view = binding.root
    setContentView(view)
}

이렇게 하면 viewBinding을 사용하기 위한 준비가 끝났다.

val outputTextView = binding.outputTextView
val outputUnitTextView = binding.outputUnitTextView
val inputEditText = binding.inputEditText
val inputUnitTextView = binding.inputUnitTextView
val swapImageButton = binding.swapImageButton

view에 접근하는 방법은 다음과 같이 binding.~을 사용해서 view에 접근할 수 있다.



📔기능 구현 - 단위 변환

먼저 inputEditText.addTextChangedListener{} 를 사용해 변환하고자 하는 값이 입력될 때마다 기능을 수행하도록 설정하고 로그를 찍어 잘 입력되었는지 확인해 보았다.

📖MainActivity.kt

inputEditText.addTextChangedListener{ text -> 
    inputNumber = text.toString().toInt()
    Log.d("inputNumber", inputNumber.toString())
}

입력값이 잘 들어가는 것을 확인할 수 있다. log



⚠️오류

입력을 계속해서 확인하던 중 오류가 발생했는데, 오류의 내용은 다음과 같았다. NumberFormatException

오류는 NumberFormatException으로 해당 내용에서는 위의

inputNumber = text.toString().toInt()

문장에서 toInt()를 통해 String을 Int 타입으로 변환하는 중 오류가 발생한 것 같다. Integer 자료형의 표현 가능 범위는 -2147483648~2147483647이다. 즉, 입력값이 Integer 자료형이 표현할 수 있는 범위를 초과했기 때문에 발생한 오류인 것이다. 이를 해결하기 위해 try ~ catch구문을 사용하여 예외를 처리해 줄 수 있지만, 더 쉬운 방법으로 해결하였다.

EditText의 속성값을 활용해서 이 오류를 해결할 수 있다. android:maxLength 속성값은 입력할 수 있는 범위의 최대 자릿수를 설정할 수 있게 해주는데, 여기서 Integer 자료형의 범위를 넘지 않도록 7로 값을 설정해 주었다. 9가 아닌 7로 설정한 이유는 나중에 이미지 버튼을 눌러 단위를 서로 바꾸어 주는 데 있어 m -> cm로 변환시에 cm 값은 100을 곱해주어야 하기 때문에 9로 설정했을 경우 또다시 Integer 자료형의 범위를 초과할 수 있기 때문이다.

activity_main.xml

<EditText
    ...
    android:maxLength="7"
    ... 
/>

값을 변경하고 난 후 실행해 보니 입력할 수 있는 범위가 9자리를 초과하지 않도록 설정된 것을 볼 수 있었다.

NumberFormatException_resolve

해당 오류를 해결하고 나니 또 다른 오류가 발생했는데, 이번에는 다른 오류가 발생했다.

⚠️오류

이번에도 오류의 내용은 NumberFormatException이었는데 오류의 내용이 다음과 같았다.

java.lang.NumberFormatException: For input string: ""

입력한 뒤 지우다가 빈 값이 되었을 때 발생하는 오류였는데, 이 과정에서 숫자로 변환할 수 없기 때문에 발생한 듯하다. 이를 위해 값을 입력하는 과정에서 null 체크를 하는 코드를 추가하였다.

inputEditText.addTextChangedListener { text ->
    inputNumber = if (text.isNullOrEmpty()) {
        0
    } else {
        text.toString().toInt()
    }
    Log.d("inputNumber", inputNumber.toString())
}

이후 다시 실행을 해보니 오류가 해결된 것을 볼 수 있었다.


다시 돌아와서, 변환된 결괏값을 보여주기 위해 다음과 같이 outputNumber라는 변수를 생성하였고, 여기에 변환된 값을 넣어 outputTextView에 값을 띄우도록 했다.

var outputNumber = inputNumber.times(0.01)
outputTextView.text = outputNumber.toString()

그리고 cm -> m로의 변환인지, 아니면 그 반대 경우인지를 구분하기 위해 cmToM라는 변수를 생성하였고 기본값으로 true를 지정해 주었다.

var cmToM = true

이후 단위 변환 ImageButton이 눌렸을 때마다 두 단위가 서로 바뀌기 때문에 Boolean으로 설정된 cmToM의 값을 not() 키워드를 사용하여 변경되도록 해주었고, 이때 각각의 단위에 맞게 보이는 값들 또한 변경될 수 있도록 해주었다. 또한 앞서 작성했던 outputNumber 변수는 계산식이 중복이 되기 때문에 지운 뒤, 변환이 된 뒤에도 변환된 값으로 입력을 계속해서 할 수 있도록 해당 구문에 변환이 구현되는 코드를 추가해 주었다.


inputEditText.addTextChangedListener { text ->
    inputNumber = if (text.isNullOrEmpty()) {
        0
    } else {
        text.toString().toInt()
    }
    
    if(cmToM) {
        outputTextView.text = inputNumber.times(0.01).toString()
    } else {
        outputTextView.text = inputNumber.times(100).toString()
    }
}

swapImageButton.setOnClickListener {
    cmToM = cmToM.not()
    if (cmToM) {
        inputUnitTextView.text = "cm"
        outputUnitTextView.text = "m"
        outputTextView.text = inputNumber.times(0.01).toString()
    } else {
        inputUnitTextView.text = "m"
        outputUnitTextView.text = "cm"
        outputTextView.text = inputNumber.times(100).toString()
    }
}

각각 잘 구현된 것을 확인할 수 있다.
result_cmtom result_mtocm


📔기능 구현 - 화면전환 시 데이터 유지

마지막으로 화면 전환 시에도 값이 유지될 수 있도록 해보자. 저번과 마찬가지로 onSaveInstanceState()를 활용한다. 다만 이번에는 데이터를 복원할 때 onCreate()에서 저장된 Bundle 값을 활용해서 가져와 복원했던 것과는 달리, onRestoreInstanceState()를 활용해서 복원해 보도록 한다.

onRestoreInstanceState()onStart() 메서드 다음에 호출되며, 복원할 저장 상태가 있을 경우에 해당 메서드를 호출한다. 따라서 저번처럼 Bundle이 null인지 확인할 필요가 없다.

Android Developer 공식문서 참고

먼저 onSaveInstanceState()를 사용하여 저장할 값을 지정해 주었다. 여기서는 cmToM값이 유지가 되지 않기 때문에 저장시켜 주었다.

override fun onSaveInstanceState(outState: Bundle) {
    outState.putBoolean("cmToM", cmToM)
    super.onSaveInstanceState(outState)
}

이후 onRestoreInstanceState()를 사용해 저장된 값을 불러왔다. cmToM값 이외에도 해당 값에 영향을 받는 inputUnitTextViewoutputTextView값 또한 저장된 값에 따라 설정될 수 있도록 적용시켜 주었다.

override fun onRestoreInstanceState(savedInstanceState: Bundle) {
    cmToM = savedInstanceState.getBoolean("cmToM")
    binding.inputUnitTextView.text = if(cmToM) "cm" else "m"
    binding.outputUnitTextView.text = if(cmToM) "m" else "cm"
    super.onRestoreInstanceState(savedInstanceState)
}

위 코드들을 추가하고 실행시켜보니 값이 잘 유지되는 것을 볼 수 있었다.

result_vertical

result_horizontal


📔전체 코드

https://github.com/Becomeproo/unit-transducers/tree/master



📝마무리

이번 과정을 진행하며 다시 한번 Activity Lifecycle은 확실히 잡고 가야 한다는 생각을 했고 조금 더 레퍼런스들을 자주, 더 많이 들여야 보아야겠다는 생각을 하게 되었다. 이제는 정말 부지런해질 필요가 있음을 느낀다.