안드로이드/앱개발(Android)

(코틀린 kotlin) 계산기 앱

김염인 2022. 1. 21. 00:26

목차

  1. 인트로 (완성앱 & 구현 기능 소개)
  2. 계산기 UI 그리기 (1)
  3. 계산기 UI 그리기 (2)
  4. 계산기로 계산하기 (1)
  5. 계산기로 계산하기 (2)
  6. 계산 기록 저장하기 (1)
  7. 계산 기록 저장하기 (2)
  8. 아웃트로 (정리)

결과화면

이 챕터를 통해 배우는 것

  • Layout 을 그리는 법
    • TableLayout 사용하기
    • ConstraintLayout 사용하기
    • LayoutInflater 사용하기
  • Thread 사용하기
    • 타 Thread 만들어서 사용하기
    • runOnUiThread 사용하기
  • Room 사용하기

Kotlin 문법

확장 함수 사용하기

data class 사용하기

계산기

계산기 기능 수행

계산 기록 저장하기

계산 기록 삭제하기

단 시간관계 상 정수형으로 한정하고, 몇 가지 기능을 배제하고, 연산자는 1회만 사용할 수 있음

 

앱 구현전 이해 해야하는 사항 

Room 이란 무엇일까 ? 

Room을 말하기 전에, 이 Room이 포함된 안드로이드 아키텍쳐 (Android Architecture Components)라는 것이 있다. 안드로이드 아키텍쳐는 앱을 견고하고, 실험 가능하고, 유지보수성이 뛰어나도록 만들어주는 라이브러리 모음이다. 이 중의 하나가 Room

Room은 SQLite의 추상 레이어를 제공하여 SQLite의 객체를 매핑하는 역할을 한다. 쉽게 말하면 SQLite의 기능을 모두 사용할 수 있고, DB로의 접근을 편하게 도와주는 라이브러리 이다.

왜 SQLite를 직접 쓰지 않고 굳이 Room을 쓰는지에 대해선 Android Developers Page SQLite 문서에 ‘Caution’ 마크와 함께 다음과 같이 설명이 되어있다.

  • There is no compile-time verification of raw SQL queries. As your data graph changes, you need to update the affected SQL queries manually. This process can be time consuming and error prone.
  • (원본 SQL은 컴파일 시간이 확실하지 않다. SQL 데이터에 변화가 생기면 수동으로 업데이트 해 주어야 한다. 이 과정에서 시간이 많이 소요되며 오류가 생길 수 있다.)
  • You need to use lots of boilerplate code to convert between SQL queries and data objects.
  • (SQL 쿼리와 데이터 객체를 변환하기 위해서는 많은 상용구 코드(boilerplate code)를 작성해야 한다.)

즉 Room을 사용하면 컴파일 시간을 체크할 수 있으며, 무의미한 boilerplate 코드의 반복을 줄여줄 수 있다. 

Room이 Architecture Components에 포함되는 만큼, Architecture Components 다른 구성 요소인 LiveData나 ViewModel 등과 함께 사용하면 아주 간편하게 데이터베이스를 관리하고 UI를 갱신할 수 있다. 하지만 여기서는 기존 형식의 프로젝트에 SQLite 대신 Room으로 데이터를 저장하는 데에 의의를 두어 예제를 만들었다. LiveData나 ViewModel에 대해서는 더 공부해서 나중에 포스팅을 올릴 생각이다.

 

Room Components 룸 구성 요소

Room에는 3가지 구성 요소가 있다.

  1. Entity - Database 안에 있는 테이블을 Java나 Kotlin 클래스로 나타낸 것이다. 데이터 모델 클래스라고 볼 수 있다.
  2. DAO - Database Access Object, 데이터베이스에 접근해서 실질적으로 insert, delete 등을 수행하는 메소드를 포함한다.
  3. Database - database holder를 포함하며, 앱에 영구 저장되는 데이터와 기본 연결을 위한 주 액세스 지점이다. RoomDatabase를 extend 하는 추상 클래스여야 하며, 테이블과 버전을 정의하는 곳이다.

Android Developers Page 참조

빌드 환경 설정

build.gradle

plugins {
    ...
    id 'kotlin-kapt'
}

kotlin kapt 플러그인을 추가해줍니다.

dependencies {
    ...
    kapt "androidx.room:room-compiler:버전정보"
    testImplementation "androidx.room:room-testing:버전정보"
    implementation "androidx.room:room-runtime:버전정보"
}

room 관련 라이브러리를 추가해줍니다.

 

Entity 생성

package com.example.aoppart2chaptor4.model

import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey

@Entity
data class History(
    @PrimaryKey val uid : Int?,
    @ColumnInfo(name = "expression") val expression : String?,
    @ColumnInfo(name = "result") val result : String?
)

컬럼 정보를 저장할 테이블 객체를 만들어줍니다.

Dao 생성

package com.example.aoppart2chaptor4.dao

import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Query
import com.example.aoppart2chaptor4.model.History

// DAO는 인터페이스 또는 추상 클래스일 수 있습니다. 추상 클래스라면 RoomDatabase를 유일한 매개변수로
// 사용하는 생성자를 선택적으로 가질 수 있습니다. Room은 컴파일 시간에 각 DAO 구현을 생성합니다.

@Dao
interface HistoryDao{

    @Query(value = "SELECT * From history")
    fun getAll() : List<History>

    @Insert
    fun insertHistory(history: History)

    @Query(value = "DELETE From history")
    fun deleteAll()

    @Delete
    fun delete(history: History)

    // @Query(value = "SELECT * From history WHERE :result LIKE result LIMIT 1")
    // fun findByResult(result: String) : History
}

데이터베이스에 접근 가능한 쿼리를 제공해주는 Dao를 만들어줍니다.

Database 생성

package com.example.aoppart2chaptor4

import androidx.room.Database
import androidx.room.RoomDatabase
import com.example.aoppart2chaptor4.dao.HistoryDao
import com.example.aoppart2chaptor4.model.History

@Database(entities = [History::class], version = 1)
abstract class AppDataBase : RoomDatabase(){
    abstract fun historyDao(): HistoryDao
}

데이터베이스를 엑세스 할 수 있으며, Entity와 버전 정보를 관리해줄 Database를 만들어줍니다.

Room 적용

lateinit var db :AppDataBase

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)

    db = Room.databaseBuilder(
        applicationContext,
        AppDataBase::class.java,
        "historyDB"
    ).build()
}

이렇게 Database Room 구조를 완성시키고 작업을 진행하면 된다.

  • Layout 을 그리는 법
    • TableLayout 사용하기
    • ConstraintLayout 사용하기
    • LayoutInflater 사용하기

ㅁ TableLayout 사용하기

 

KeypadLayout이라는 TableLayout을 만들어주고 ShrinkColums를 이용하여 이블 가로 길이에 맞게 모든 뷰들이 들어오게 해준다.

키보드는 테이블 레이아웃을 이용해주는 게 좋다.

<TableRow android:layout_weight="1">

테이블레이아웃의 layout_weight = 1로 설정해주어 높이를 모두 맞춰 준다.

그리고 테이블레이아웃 안에 버튼을 Table Row형태로 만들어준다.

        <TableRow android:layout_weight="1">

            <androidx.appcompat.widget.AppCompatButton
                android:id="@+id/clearButton"
                android:layout_width="wrap_content"
                android:layout_height="match_parent"
                android:layout_margin="7dp"
                android:background="@drawable/button_background"
                android:onClick="clearButtonClicked"
                android:stateListAnimator="@null"
                android:text="C"
                android:textSize="24dp" />

            <androidx.appcompat.widget.AppCompatButton
                android:layout_width="wrap_content"
                android:layout_height="match_parent"
                android:layout_margin="7dp"
                android:background="@drawable/button_background"
                android:clickable="false"
                android:enabled="false"
                android:stateListAnimator="@null"
                android:text="()"
                android:textColor="@color/green"
                android:textSize="24dp" />
<!--            쓰지 않는 기능이므로 enable, clickable = false로 지정 해준다.-->

            <androidx.appcompat.widget.AppCompatButton
                android:id="@+id/buttonPercent"
                android:layout_width="wrap_content"
                android:layout_height="match_parent"
                android:layout_margin="7dp"
                android:background="@drawable/button_background"
                android:onClick="buttonClicked"
                android:stateListAnimator="@null"
                android:text="%"
                android:textColor="@color/green"
                android:textSize="24dp" />

            <androidx.appcompat.widget.AppCompatButton
                android:id="@+id/buttonDivide"
                android:layout_width="wrap_content"
                android:layout_height="match_parent"
                android:layout_margin="7dp"
                android:background="@drawable/button_background"
                android:onClick="buttonClicked"
                android:stateListAnimator="@null"
                android:text="÷"
                android:textColor="@color/green"
                android:textSize="24dp" />


        </TableRow>

이렇게 테이블 레이아웃의키보드 형태를 4줄을 만들어 키보드를 보여주는 화면을 완성시킨다.

 

ㅁ LayoutInflater 사용하기

LayoutInflater.from()

이 방법은 가장 자주 사용하는 방법으로, LayoutInflater에 static으로 정의되어있는 LayoutInflater.from을 통해 LayoutInflater를 만드는 방법입니다. 내부적으로 context#getSystemService를 호출 하고 있으며, 같은 context에서는 같은 객체를 리턴하기 때문에 굳이 멤버 변수로 선언해 놓지 않고 필요할 때마다 호출해서 사용해도 괜찮습니다.

fun historyButtonClicked(v: View) {
    historyLayout.isVisible = true
    historyLinearLayout.removeAllViews() // FrameLayout에 포함된 모든 자식(Children) 뷰 제거.

    Thread( { // db작업시는 새로운 쓰레드를 이용!
        db.historyDao().getAll().reversed().forEach {
            runOnUiThread {
                var historyView = LayoutInflater.from(this).inflate(R.layout.history_row, null, false)
                historyView.findViewById<TextView>(R.id.expressionTextView).text = it.expression
                historyView.findViewById<TextView>(R.id.resultTextView).text = " = ${it.result}"

                historyLinearLayout.addView(historyView)
            }
        }
    }).start()
    // Todo 모든 기록 가져오가
    // Todo 뷰 모든 기록 할당하기
}

 

MainActivity 코드 정리

package com.example.aoppart2chaptor4

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.text.Spannable
import android.text.SpannableStringBuilder
import android.text.style.ForegroundColorSpan
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.widget.LinearLayout
import android.widget.TextView
import android.widget.Toast
import androidx.core.view.isVisible
import androidx.room.Room
import com.example.aoppart2chaptor4.model.History
import org.w3c.dom.Text
import java.lang.NumberFormatException

class MainActivity : AppCompatActivity() {

    lateinit var db :AppDataBase

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        db = Room.databaseBuilder(
            applicationContext,
            AppDataBase::class.java,
            "historyDB"
        ).build()
    }

    private var isOperator = false
    private var hasOperator = false

    private val expressionTextView: TextView by lazy {
        findViewById<TextView>(R.id.expressionTextView)
    }

    private val historyLayout: View by lazy {
        findViewById<View>(R.id.historyLayout)
    }

    private val historyLinearLayout: LinearLayout by lazy {
        findViewById<LinearLayout>(R.id.historyLinearLayout)
    }

    private val resultTextView: TextView by lazy {
        findViewById<TextView>(R.id.resultTextView)
    }



    fun buttonClicked(v: View) {
        when (v.id) {
            R.id.button0 -> numberButtonClicked("0")
            R.id.button1 -> numberButtonClicked("1")
            R.id.button2 -> numberButtonClicked("2")
            R.id.button3 -> numberButtonClicked("3")
            R.id.button4 -> numberButtonClicked("4")
            R.id.button5 -> numberButtonClicked("5")
            R.id.button6 -> numberButtonClicked("6")
            R.id.button7 -> numberButtonClicked("7")
            R.id.button8 -> numberButtonClicked("8")
            R.id.button9 -> numberButtonClicked("9")
            R.id.buttonPlus -> operatorButtonClicked("+")
            R.id.buttonDivide -> operatorButtonClicked("/")
            R.id.buttonMulti -> operatorButtonClicked("*")
            R.id.buttonMinus -> operatorButtonClicked("-")
            R.id.buttonPercent -> operatorButtonClicked("%")
        }
    }

    private fun numberButtonClicked(number: String) { // 0 ~ 9 까지 숫자를 클릭했을 때 !!

        if (isOperator) {
            expressionTextView.append(" ")
        }

        isOperator = false

        val expressionText = expressionTextView.text.split(" ")
        // val str = "123.456,789"
        // val arr = str.split(".", ",") 구분자를 등록 해서 나누어 배열로 저장해줌!
        // println(arr) -> ["123", "456", "789"]

        if (expressionText.isNotEmpty() && expressionText.last().length >= 15) { // 길이가 15를 초과하는 마지막 문자 가 있으면
            Toast.makeText(this, "15자리만 사용할 수 있습니다.", Toast.LENGTH_SHORT).show()
            return
        //            2. 코틀린 first, last 함수 예제
        //            fun firstLast(){
        //                val names = listOf("Duke", "Leonardo", "Sara", "James", "Mino")
        //
        ////first 함수 : 첫번째 인자 리턴
        //                println(names.first())
        //
        ////문자열 길이 4를 초과하는 인자 첫번째 인자 리턴
        //                println(names.first{name -> name.length > 4})
        //
        ////last 함수 : 마지막 인자 리턴
        //                println(names.last())
        //
        ////문자열 길이 4를 초과하는 인자 마지막 인자 리턴
        //                println(names.last{name -> name.length > 4})
        //
        ////firstOrNull 함수 : 첫번째 인자반환하며, 없을 경우에 Null 리턴
        //                println(names.firstOrNull{ name -> name.length > 10})
        //
        ////lastOrNull 함수 : 마지막 인자를 반환하며, 없을 경우에 Null 리턴
        //                println(names.lastOrNull{ name -> name.length > 10})
        //            }

        } else if (expressionText.last().isEmpty() && number == "0") {
            Toast.makeText(this, "0은 제일 앞에 올 수 없습니다.", Toast.LENGTH_SHORT).show()
            return
        }

        expressionTextView.append(number)
        resultTextView.text = calcuExpression()
    }

    private fun operatorButtonClicked(operator: String) { // Operator 버튼을 눌렀을 경우 !
        if (expressionTextView.text.isEmpty()) { // 이미 비어 있는 Text이면 아무것도 반응 안해줌 !
            return
        }

        when {
            isOperator -> {
                val text = expressionTextView.text.toString()
                expressionTextView.text = text.dropLast(1) + operator
                // text의 마지막 char dropLast로 drop해주고 기존 operator로 교체 해줌,
            }
            hasOperator -> {
                Toast.makeText(this, "연산자는 한번만 사용 가능합니다..", Toast.LENGTH_SHORT).show()
                return
            }
            else -> {
                expressionTextView.append(" $operator")
            }
        }
        val ssb = SpannableStringBuilder(expressionTextView.text) // 특정 글자의 색만 바꿔주기 위한 Span !

        ssb.setSpan(
            ForegroundColorSpan(getColor(R.color.green)),
            expressionTextView.text.length - 1,
            expressionTextView.text.length,
            Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
        // Spannable.SPAN_EXCLUSIVE_INCLUSIVE 플래그를 사용하여 삽입된 텍스트를 포함하고
        // Spannable.SPAN_EXCLUSIVE_EXCLUSIVE를 사용하여 삽입된 텍스트를 제외합니다.

        expressionTextView.text = ssb

        isOperator = true
        hasOperator = true
    }

    fun clearButtonClicked(v: View) { // C 버튼 다 지워준다.
        expressionTextView.text = ""
        resultTextView.text = ""

        isOperator = false
        hasOperator = false
    }

    fun resultButtonClicked(v: View) { // 결과 버튼 '='
        val expressionTexts = expressionTextView.text.split(" ")
        Log.d("MainActivity", "${expressionTextView.text}")
        if (expressionTextView.text.isEmpty() || expressionTexts.size == 1){
            return
        }
        if (expressionTexts.size != 3 && hasOperator){
            Toast.makeText(this, "아직 완성되지않은 수식입니다.", Toast.LENGTH_SHORT).show()
            return
        }
        else if(expressionTexts[0].isNumber().not() || expressionTexts[2].isNumber().not()){
            Toast.makeText(this, "오류가 발생했습니다.", Toast.LENGTH_SHORT).show()
            return
        }

        val expressionText = expressionTextView.text.toString()
        val resultText = calcuExpression()

        resultTextView.text = ""
        expressionTextView.text = resultText

        isOperator = false
        hasOperator = false



        Thread( {
            db.historyDao().insertHistory(History(uid = null,expressionText, resultText))
        }).start() //DB 관련 업무는 새로운 쓰레드에서 !
    }

    private fun calcuExpression() : String{ // 간단함 ! 배열 크기가 3이면 계산을 해준다, [숫자, (operator), 숫자] 형식일때 !! 계
        val expressionTexts = expressionTextView.text.split(" ")
        Log.d("MainActivity", "${expressionTexts}")

        if (hasOperator.not() || expressionTexts.size != 3){
            return ""
        }
        else if(expressionTexts[0].isNumber().not() || expressionTexts[2].isNumber().not()){
            return ""
        }

        val exp1 = expressionTexts[0].toBigInteger() // Integet로 변환
        val exp2 = expressionTexts[2].toBigInteger()
        val op = expressionTexts[1]

        return when(op){
            "+" -> (exp1 + exp2).toString()
                "-" -> (exp1 - exp2).toString()
                "/" -> (exp1 / exp2).toString()
                "*" -> (exp1 * exp2).toString()
                "%" -> (exp1 % exp2).toString()
                else -> ""
        } // return에 when형식으로 써줄 수 도 있다 !!

    }

    fun historyButtonClicked(v: View) {
        historyLayout.isVisible = true
        historyLinearLayout.removeAllViews() // FrameLayout에 포함된 모든 자식(Children) 뷰 제거.

        Thread( { // db작업시는 새로운 쓰레드를 이용!
            db.historyDao().getAll().reversed().forEach {
                runOnUiThread {
                    var historyView = LayoutInflater.from(this).inflate(R.layout.history_row, null, false)
                    historyView.findViewById<TextView>(R.id.expressionTextView).text = it.expression
                    historyView.findViewById<TextView>(R.id.resultTextView).text = " = ${it.result}"

                    historyLinearLayout.addView(historyView)
                }
            }
        }).start()
        // Todo 모든 기록 가져오가
        // Todo 뷰 모든 기록 할당하기
    }

    fun closeHistoryButtonClicked(v: View) {
        historyLayout.isVisible = false
    }

    fun clearHistoryButtonClicked(v: View) {
        historyLinearLayout.removeAllViews()

        Thread({
            db.historyDao().deleteAll()
        }).start()

        // TODO DB에서 모든 기록 삭제
    }



}

fun String.isNumber() : Boolean{ // 확장 함수를 구현!
    return try{
        this.toBigInteger()
        true
    }catch (e: NumberFormatException){
        false
    }
}

 

요약

  • XML레이아웃 파일에서 뷰를 생성할때는 LayoutInflater를 이용해야 한다.
  • 내장 DB 사용 할 시, Dao, Data Class, appDataBase Class(버전 관리 엑세스)를 만들어 주는게 좋다.
  • 내장함수를 직접 구현 가능 하다.
    fun String.isNumber() : Boolean{ // 확장 함수를 구현!
        return try{
            this.toBigInteger()
            true
        }catch (e: NumberFormatException){
            false
        }
    }
  • DB 작업시에는 새로운 Thread를 이용하여 작업해주어야 한다. 그리고 runonUiThread를 이용해준다.
  • SpannbleStringBuilder를 이용하면 특정 글자의 색만 바꿔 줄 수 있다.
    val ssb = SpannableStringBuilder(expressionTextView.text) // 특정 글자의 색만 바꿔주기 위한 Span !
    
    ssb.setSpan(
        ForegroundColorSpan(getColor(R.color.green)),
        expressionTextView.text.length - 1,
        expressionTextView.text.length,
        Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
    // Spannable.SPAN_EXCLUSIVE_INCLUSIVE 플래그를 사용하여 삽입된 텍스트를 포함하고
    // Spannable.SPAN_EXCLUSIVE_EXCLUSIVE를 사용하여 삽입된 텍스트를 제외합니다.
    ​