앱 프로젝트 - 12 - 1 (도서 리뷰 앱) - RecyclerView, ViewBinding, Retrofit (API 데이터 쉽게 가져오기) + gson-converter, Glide( 이미지 Url 로드해서 사용 ), Android Room, Open API 사용해보기 + Postman, KeyListener() 주의할 점, Data Class 직렬화 - Parcelize (Intent에 데이터클래스 담아서 넘기기),앱 전역에서 사용할 상수 설정

하이루·2022년 2월 9일
0

소개


레이아웃 소개


알아야 할 내용

앱 전역에서 사용할 상수 설정

API-Key와 같이 앱 전역에서 사용할만한 상수는
따로 res 폴더의 values폴더내에 새로운 xml 파일을 만들어 관리하는 것이 좋다.

ViewBinding

일반적으로 우리는 View를 가져올 때

val textView: TextView = findViewById(R.id.textView1)

이런 식으로 해당 View레이아웃의 주소값을 통해 가져온다.

그런데 안드로이드 스튜디오에서는 ViewBinding이라는 기능을 통해서
바로 가져올 수도 있다.

ViewBinding으로 가져오는 방식 ㅡ, ViewBinding클래스가 이름지어지는 원칙

ViewBinding을 사용할 것이라고 build.gradle에 명시해 주었다면,

안드로이드 스튜디오는 Resouce의 Layout폴더에 있는 xml파일들에 대해서
ViewBinding를 상속한 클래스를 자동으로 만들어준다.

이때 클래스의 이름은 대략 아래의 예시와 같은 방식으로
해당하는 xml 파일의 이름을 따와서 지어진다.

  • 예를 들어)

    • activity_main.xml파일에 대한 ViewBinding클래스는
      ActivityMainBinding이라는 이름으로 자동으로 정의된다.

    • 만약에 xml파일의 이름이 activity_main_layout.xml이었다면
      해당 xml파일에 대한 ViewBinding클래스는
      ActivityMainLayoutBinding이라는 이름으로 자동 정의되었을 것이다.

이제 이렇게 정의된 ViewBinding클래스를 통해 해당 xml파일에 접근할 수 있으며,
이를 사용하여 기존의 findViewById를 사용하여 View를 가져와 주었던 것을 대신하는 것이다.

ViewBinding사용하기

  1. 앱수준의 build.gradle에 ViewBinding 사용 설정하기

    ViewBinding을 사용하기 위해서는 앱수준의 build.gradle에서
    ViewBinding에 대한 설정을 true로 바꿔줘야 한다.

        viewBinding{
        enabled = true
    }
    
    • 이렇게 설정을 해주면,
      안드로이드 스튜디오에서 layout폴더에 있는 xml파일들(레이아웃파일)에 대해
      ViewBinding 클래스를 상속한 클래스를 자동을 만들어줌

    • 이렇게 자동으로 만든 클래스의 이름은 해당 xml 파일의 이름을 따와서 정해짐
      --> 이름에 대한 자세한 설명은 위에 "ViewBinding클래스가 이름지어지는 원칙"부분에 정리해놓음

  2. ViewBinding을 사용하여 View를 kotlin파일에 binding하여 사용

  • 예시 1) activity_main.xml을 ViewBinding을 통해 MainActivity.kt에 세팅

    
    ......
    
    class MainActivity : AppCompatActivity() {
    
     private lateinit var binding: ActivityMainBinding
    
         override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)
    
         binding = ActivityMainBinding.inflate(layoutInflater)
    
         setContentView(binding.root)
         
         ......
         
         }
    }     
    • activity_main.xml이므로
      ActivitiyMainBinding이라는 이름의 ViewBinding 클래스가 생성되어 있음

      private lateinit var binding: ActivityMainBinding
      
      • 즉, ViewBinding은 ViewBinding에서 사용할 새로운 레이아웃 객체를 생성하여 그것을 제어하는 식으로 이루어진다.
        이에 대한 자세한 내용은
        예제 1 추가설명 ( ViewBinding을 액티비티에 적용하기 )에 해놓았다.
    • Activity( MainAcitvity.kt )에는 이미 LayoutInflater가 내장되어 있음,
      ViewBinding의 inflate()메소드에 인자로 이런 LayoutInflater를 넣어서
      ViewBinding에 세팅함
      --> Layout을 ViewBinding에 불러오는 과정이므로, LayoutInflater가 필요

      binding = ActivityMainBinding.inflate(layoutInflater)
      // ViewBinding에 LayoutInflater를 세팅
    • ViewBinding의 root멤버변수에는
      해당 ViewBinding이 Bind하고 있는 View의 주소값이 들어있음
      --> ViewBinding의 root멤버변수의 값을 setContentView에 넣어서 레이아웃을 세팅

      setContentView(binding.root)
      
  • 예시 1의 내용에 대해 추가 설명 ( ViewBinding을 액티비티에 적용하기 )

    ......
    override fun onCreate(savedInstanceState: Bundle?) {
    	super.onCreate(savedInstanceState)
    
    	binding = ActivityMainBinding.inflate(layoutInflater)
    	setContentView(binding.root)
       
       ......
    • ViewBinding은 기존의 레이아웃을 똑같이 복제한 레이아웃 객체를 생성하여 그것을 제어하는 식으로 이루어진다.

      따라서 기존과 같이 R 파일에 있는 (앱 실행시 미리 하나 생성해 놓은) 레이아웃 객체를 가져와서 사용하는 것이 아니라,

      ViewBinding이 복제하여 생성한 레이아웃 객체를 가져와서 액티비티에 세팅해야 한다.

           	setContentView(binding.root)
    • 일반적으로 setContentView()는 onCreate()가 호출되자마자 호출하지만,
      ViewBinding을 사용할 때에 한해서는 binding을 할당한 이후에
      binding.root를 인자로 사용하여 호출하였다.

      이유는 다음과 같다.

      • setContentView()의 파라미터에 binding한 레이아웃을 할당하기 위해서

        --> 즉, binding을 통해 불러온 layout을 액티비티에 할당하기 위해서 위와 같이 하는 것이다.

    • 만약에 이렇게 하지 않고 기존의 방식대로 R.layout.activity_main을 사용한다면,

      ......
      override fun onCreate(savedInstanceState: Bundle?) {
      	super.onCreate(savedInstanceState)
      
      	binding = ActivityMainBinding.inflate(layoutInflater)
      	setContentView(R.layout.activity_main)
         
         ......
      • ViewBinding을 통해 접근한 레이아웃과
        R.layout.activity_main으로 불러온 레이아웃
        두가지가 각각 생성되어, 서로 호환되지 않게 된다.

        ( 즉, 내용은 같지만 각각 다른 레이아웃 객체가 생성됨 )

      • 다시말해 위와 같이 하게되면,
        setContentView()를 통해 액티비티에 설정된 레이아웃은
        R.layout.activity_main을 통해 불러온 레이아웃 객체이므로

        ViewBinding을 통해 레이아웃을 제어하려고 해도,
        ViewBinding이 생성한 레이아웃 객체에 영향을 미칠 뿐,

        실제 액티비티에 설정된 R.layout.activity_main으로 불러온 레이아웃 객체의 내용에는 영향을 미칠 수 없게 된다.

      • 반면에 binding.root를 사용하여 ViewBinding이 생성한 레이아웃 객체를 setContentView()에 할당한 경우,

        액티비티에 설정된 레이아웃 객체와 ViewBinding의 레이아웃 객체가 동일하므로 ViewBinding을 통해 액티비티의 레이아웃을 제어할 수 있게 되는 것이다.

  • 예시 2) ViewBinding이 Bind하고 있는 View에 컴포넌트에 접근

    • edit_activity.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/searchEditText"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />
      
      </androidx.constraintlayout.widget.ConstraintLayout>
    • MainActivity.kt예시

       class MainActivity : AppCompatActivity() {
      
        private lateinit var binding: EditActivityBinding
      
            override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            
            binding = ActivityMainBinding.inflate(layoutInflater)
            
            setContentView(binding.root)
            
            binding.searchEditText.text ="dd"
            
            ......
            
            }
       }   
      • edit_activity.xml( res의 Layout폴더내의 xml파일 )에 대한 ViewBinding은
        EditActivityBinding로 정의된다.

        private lateinit var binding: EditActivityBinding
      • ViewBinding을 통해 해당 View(여기서는 Layout)의 내부에 정의된
        컴포넌트에 접근할 수 있다.
        ( 해당 컴포넌트의 id를 통해 )

        • edit_activity.xml

           <EditText
            android:id="@+id/searchEditText"
            
            ......
          

          MainActivity.kt

          binding.searchEditText.text ="dd"

RecyclerView

  • View를 재활용하는 것으로 Scroll을 구현하는 View

  • ScrollView와 다른 방식으로 Scroll을 구현한다.

  • 일반적으로 반복되는 형식( 레이아웃 형태 )의 View에 대한 Scroll을 구현할때는
    RecyclerView를 쓰는 것이 효율적이다.

ScrollView의 단점?

ScrollView의 경우,
보여주고자 하는 View들을 ScrollView가 모두 올려놓고 있어야한다.

예를 들어)
2만개의 View를 ScrollView로 본다고 하면,
일단 ScrollView에 그 2만개의 View를 모두 올리고 나서 화면 스크롤을 통해 봐야한다.
즉, 기기는 아직 화면에 보여지지 않은,
혹은 사용자가 ScrollView를 다 넘기지도 않아서 보여지지 않을 View까지도 일단 모두 올려야 한다는 것이다.

즉, 보여지지 않은 밑에까지 모두 그려져 있어야 한다는 단점이 있음

RecyclerView는 ?

RecyclerView는
먼저 보여질 View의 Format( 레이아웃 형태 )을 미리 정해 놓고,
이 Format대로 화면에 직접적으로 보여질 View를 몇 개 만든다.
( 일반적으로 5개 정도 만든다고 알고 있다. )

그 다음에 사용자가 화면을 넘길 때마다
화면을 벗어난 View는 날리고 ( 날린다고 표현했지만 View를 없애는 것은 아니다. ),
화면에 새로 들어갈 View에는 데이터만 바꿔넣어서 보여주는 식으로 진행된다.

중요한 것은 스크롤을 통해 화면을 벗어난 View가
새로 들어갈 View의 대기열에 들어가서
데이터만 변경되어 들어간다는 것이다.
( 마치 순환하듯이 )

  • RecyclerView 안에 있는 모든 View가 동일 Format을 가지고 있으므로,
    이렇게 View를 재활용하면서 데이터만 바꿔넣는 방식이 가능하다.

    --> 이런 View의 재활용이라는 특징이 RecyclerView의 이름에 드러나있다.

RecyclerView가 적합하지 않은 경우

위에 설명을 읽어보면 알겠지만,
View의 Format( 레이아웃의 형태 )이 일정하지 않을 경우에는
ScrollView를 쓰는 것이 현명하다.

ViewHolder란?

RecyclerView는 미리 View를 몇개 만들어 놓는 방식을 사용한다고 했는데,
이렇게 미리 만들어 놓는 View를 ViewHolder라고 한다.

RecyclerView 사용하기

RecyclerView를 사용하려면 먼저

  1. RecyclerView를 통해 보여줄 View의 레이아웃
  2. Adapter

을 구성해야 한다.

이후

  1. Adapter를 RecyclerView에 세팅하고,
  2. RecyclerView가 보여줄 데이터 리스트를 Adapter에 삽입하면,

RecyclerView가 완성된다.

1. RecyclerView를 통해 보여줄 View의 레이아웃 구성

일반적인 View와 같이 구성하면 된다.

  • 단 Activity의 전체 레이아웃을 구성하는 것과는 다르게,
    다른 레이아웃의 일부로 들어가게 될 View의 레이아웃을 구성하는 것이므로
    최상위 레이아웃의 width와 height를 고려할 필요가 있다.

    --> 아래의 예시의 경우,
    레이아웃의 크기를 컴포넌트의 크기에 맞추기 위해서
    height를 wrap_content로 맞추었다.

예시)

<?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"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:padding="16dp">

    <ImageView
        android:id="@+id/coverImageView"
        android:layout_width="100dp"
        android:layout_height="100dp"
        android:background="@drawable/background_gray_stroke_radius_16"
        android:padding="8dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <TextView
        android:id="@+id/titleTextView"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginStart="12dp"
        android:ellipsize="end"
        android:lines="1"
        android:text="안드로이드 마스터하기안드로이드 마스터하기안드로이드 마스터하기안드로이드 마스터하기안드로이드 마스터하기"
        android:textColor="@color/black"
        android:textSize="16sp"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toEndOf="@+id/coverImageView"
        app:layout_constraintTop_toTopOf="parent" />

    <TextView
        android:id="@+id/descriptionTextView"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:layout_marginTop="12dp"
        android:ellipsize="end"
        android:maxLines="3"
        android:textSize="12sp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="@+id/titleTextView"
        app:layout_constraintTop_toBottomOf="@+id/titleTextView" />


</androidx.constraintlayout.widget.ConstraintLayout>

2. Adapter

예시


package com.example.aop_part3_chapter12.adapter

import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.example.aop_part3_chapter12.databinding.ItemBookBinding
import com.example.aop_part3_chapter12.model.Book

// RecyclerView에 있는 ListAdapter를 상속해야함
class BookAdapter : ListAdapter<Book, BookAdapter.BookItemViewHolder>(diffUtil) {


    // RecyclerView는 미리 View를 몇개 만들어 놓는 방식을 사용한다고 했는데,
    // 이렇게 미리 만들어 놓는 View를 ViewHolder라고 한다.
    inner class BookItemViewHolder(private val binding: ItemBookBinding) :
        RecyclerView.ViewHolder(binding.root) {

        fun bind(bookModel: Book) {

            // LayoutInflater가 아닌 ViewBinding을 통해 레이아웃에 접근하는 방식을 사용한 것임
            binding.titleTextView.text = bookModel.title
            binding.descriptionTextView.text = bookModel.description

            Glide
                .with(binding.coverImageView.context)
                .load(bookModel.coverSmallUrl)
                .into(binding.coverImageView)

        }

    }


    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BookItemViewHolder {
        // TODO 이미 만들어진 ViewHolder가 없을 경우에 새로 생성하는 메소드

        // ItemBookBinding( item_book.xml에 대한 ViewBinding )을 통해 해당 레이아웃을 Inflate하여 ViewHolder에 인자로 전달
        // ( 이후 ViewHolder는 이 레이아웃의 주소값을 ViewHolder 자신이 상속한 RecyclerView.ViewHolder()에 인자로 전달하는 것으로 자신의 레이아웃을 그린다. )
        return BookItemViewHolder(
            ItemBookBinding.inflate(
                LayoutInflater.from(parent.context),
                parent,
                false
            )
        )
    }

    override fun onBindViewHolder(holder: BookItemViewHolder, position: Int) {
        // TODO 실제로 이 ViewHolder가 View에 그려지게 되었을 때, 데이터를 그려주게 되는( 데이터를 Bind하게 되는 ) 메소드

        // ListAdapter에 세팅되어 있는 데이터들의 List는 currentList 변수에 저장되어 있음
        holder.bind(currentList[position])
    }


    // diffUtil이란 RecyclerView에서 실재로 View의 position이 변경이 되었을 때( 사용자가 화면을 넘겨서 ),
    // 새로운 값을 할당할지 말지를 결정하는 기준이 있는데,( 예를 들어 , 같은 값이 할당되어 있을 경우 똑같은 값을 굳이 할당해줄필요는 없다. )
    // 그것을 결정이나 판단해주는 것이 diffUtil이라고 한다.
    companion object {
        val diffUtil = object : DiffUtil.ItemCallback<Book>() {

            override fun areItemsTheSame(oldItem: Book, newItem: Book): Boolean {
                // TODO oldItem과 newItem이 실제로 Item이 같은지 판단

                return oldItem == newItem
            }

            override fun areContentsTheSame(oldItem: Book, newItem: Book): Boolean {
                // TODO 안에 있는 Contents가 같은지 다른지 판단

                // id의 값이 같은지 다른지를 보고 Contents가 같은지 판단할 것임 ( 이것은 Contents의 구성에 따라 무엇을 기준으로 할지 선택하면 됨 )
                return oldItem.id == newItem.id
            }
        }
    }

}
  • 이 부분에서 ListAdapter를 상속받으며, diffUtil을 설정해주고 있다.

    class BookAdapter : ListAdapter<Book, BookAdapter.BookItemViewHolder>(diffUtil) {
    • 상속받는 ListAdapter은 두개의 제네릭타입을 설정해야하는데,

      • 첫번째 제네릭타입으로 들어올 데이터의 타입을 설정해준다.
        --> 위의 예시에서는 데이터 클래스로 정의한 Book 객체로 데이터를 다룰 것이며,
        Adapter가 Book에 대한 List로 데이터를 받을 것이기 때문에
        데이터 타입은 Book이다.

      • 두번째 제네릭 타입으로 ViewHolder클래스를 설정해준다.
        --> 해당 Adapter의 내부에 RecyclerView의 ViewHolder클래스를 상속받는
        Inner Class를 생성할 것인데, 그 클래스를 넣어주면 된다.

    • 해당 Adapter의 내부에 RecyclerView의 장점 중 하나인
      DiffUtil을 구현할 것인데, 그 구현한 DiffUtil을 Adapter에 세팅하고 있음

  • 이 부분에서 ViewHolder를 Inner Class로 생성하고 있다.

       inner class BookItemViewHolder(private val binding: ItemBookBinding) :
           RecyclerView.ViewHolder(binding.root) {
    
           fun bind(bookModel: Book) {
    
               // LayoutInflater가 아닌 ViewBinding을 통해 레이아웃에 접근하는 방식을 사용한 것임
               binding.titleTextView.text = bookModel.title
               binding.descriptionTextView.text = bookModel.description
    
               Glide
                   .with(binding.coverImageView.context)
                   .load(bookModel.coverSmallUrl)
                   .into(binding.coverImageView)
    
           }
    
       }
    
    • 해당 클래스는 RecyclerView의 핵심적인 역할을 하는 ViewHolder를 구현한다.

    • ViewHolder 클래스의 역할은 다음과 같다

      • 레이아웃을 받아 View를 생성 ( 5개 정도의 View -> ViewHolder )
      • 데이터를 받아 생성한 View의 레이아웃에 적용
    • 해당 클래스의 파라미터로
      ViewHolder를 통해 생성할 View의 레이아웃을 받아온다.
      ( 위의 코드에서는 ViewBinding을 통해 받아온다. )

    • 해당 클래스는 RecyclerView에 대한 Adapter이므로,
      RecyclerView 내부의 ViewHolder를 상속받는다.

      그리고 이때 RecyclerView.ViewHolder()의 인자로,
      위에 말했던
      ViewHolder를 통해 생성할 View의 레이아웃에 대한 주소값을 넣어준다.
      ( 위의 코드에서는 ViewBinding을 통해 받아온다. )

      --> 이렇게 RecyclerView.ViewHolder()의 인자로 설정한 레이아웃이
      ViewHolder를 통해 생성할 View의 레이아웃이 된다.

    • 가져온 레이아웃에 데이터를 입히기 위한 bind() 메소드를 정의하고 있다.

      • bind() 메소드는 파라미터로 데이터를 받아
        데이터의 내용을 레이아웃에 적용한다.
  • 이 부분에서 ListAdapter가 구현해야하는 메소드중 하나인 onCreateViewHolder()를 구현하고 있음

       override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BookItemViewHolder {
          // TODO 이미 만들어진 ViewHolder가 없을 경우에 새로 생성하는 메소드
    
          // ItemBookBinding( item_book.xml에 대한 ViewBinding )을 통해 해당 레이아웃을 Inflate하여 ViewHolder에 인자로 전달
          // ( 이후 ViewHolder는 이 레이아웃의 주소값을 ViewHolder 자신이 상속한 RecyclerView.ViewHolder()에 인자로 전달하는 것으로 자신의 레이아웃을 그린다. )
          return BookItemViewHolder(
              ItemBookBinding.inflate(
                  LayoutInflater.from(parent.context),
                  parent,
                  false
              )
          )
      }
    • onCreateViewHolder() 메소드는 내가 구현한 ViewHolder클래스를 생성하기 위한 메소드이다.

    • onCreateViewHolder() 메소드는
      이미 만들어진 ViewHolder가 있을 경우 호출되지 않으며,
      이미 만들어진 ViewHolder가 없을 경우에 새로 생성하는 메소드이다.

    • onCreateViewHolder()는 내가 구현한 ViewHolder클래스를 생성하여 반환하여야 한다.

      • 이때 해당 ViewHolder의 파라미터로
        ViewHolder에서 사용될 Layout을 LayoutInflater를 통해 가져와줘야 한다.
        ( 위의 코드에서는 ViewBinding을 통해 inflater()메소드를 이용하여 가져왔다. )
  • 이 부분에서 ListAdapter가 구현해야 되는 메소드중 하나인 onBindViewHolder()를 구현하고 있다.

       override fun onBindViewHolder(holder: BookItemViewHolder, position: Int) {
           // TODO 실제로 이 ViewHolder가 View에 그려지게 되었을 때,
           // 데이터를 그려주게 되는( 데이터를 Bind하게 되는 ) 메소드
    
           // ListAdapter에 세팅되어 있는 데이터들의 List는 currentList 변수에 저장되어 있음
           holder.bind(currentList[position])
       }
  • onBindViewHolder() 메소드는
    사용자가 RecyclerView를 스크롤하여
    메인으로 나오는 ViewHolder가 변경되었을 경우 호출되며ㅡ,

    그에따라 변경된 각 번호에 맞춰서 ViewHolder에
    해당 번호에 해당하는 데이터를
    Adapter에 세팅된 데이터 리스트에서 가져와서 넣어주기 위한 메소드이다.

    • onBindViewHolder()는 이를 수행하기 위해
      스크롤을 통해 RecyclerView에서 보여줘야할 데이터 번호가 변경되었을 때 호출된다.
  • onBindViewHolder() 메소드는

    • 첫번째 파라미터로 위의 onCreateViewHolder()메소드에서 반환하였던
      내가 만든 ViewHolder를 가져온다.

    • 두번째 파라미터로 변경된 데이터 번호를 가져온다.

    • onBindViewHolder() 메소드는
      첫번째 파라미터로 가져온 ViewHolder의 bind()메소드를 사용하여
      사용자가 스크롤했을 경우에 ViewHolder의 데이터를 바꿔준다.

    • 이때 bind()의 파라미터로는 데이터 리스트에서
      onBindViewHolder() 메소드의 두번째 파라미터로 들어온 번호에 해당하는 데이터를 찾아서 넣는다.

      • Adapter에 세팅한 데이터 리스트는 currentList 키워드를 통해 접근할 수 있다.
  • 이 부분에서 DiffUtil을 구현하였다.

      companion object {
          val diffUtil = object : DiffUtil.ItemCallback<Book>() {
    
              override fun areItemsTheSame(oldItem: Book, newItem: Book): Boolean {
                  // TODO oldItem과 newItem이 실제로 Item이 같은지 판단
    
                  return oldItem == newItem
              }
    
              override fun areContentsTheSame(oldItem: Book, newItem: Book): Boolean {
                  // TODO 안에 있는 Contents가 같은지 다른지 판단
    
                  // id의 값이 같은지 다른지를 보고 Contents가 같은지 판단할 것임 ( 이것은 Contents의 구성에 따라 무엇을 기준으로 할지 선택하면 됨 )
                  return oldItem.id == newItem.id
              }
          }
      }

    DiffUtil이란?

    DiffUtil이란
    RecyclerView에서 실제로 View의 position이 변경이 되었을 때( 사용자가 화면을 넘겨서 ),
    새로운 값을 할당할지 말지를 결정하는 기준이 있는데,
    ( 예를 들어 , 같은 값이 할당되어 있을 경우 똑같은 값을 굳이 할당해 줄 필요는 없다. )

    그것을 결정이나 판단해주는 것이 DiffUtil이라고 한다.

    • 이 부분에서 DiffUtil을 무명클래스로 구현하고 있음

            val diffUtil = object : DiffUtil.ItemCallback<Book>() {
      • 이때 ItemCallback의 제네릭타입은 DiffUtil을 통해
        할당 여부를 판단하기 위한 객체타입이 들어가야 함
    • 이 부분에서 DiffUtil이 구현해야하는 메소드인 areItemsTheSame()메소드를 구현하고 있다.

               override fun areItemsTheSame(oldItem: Book, newItem: Book): Boolean {
                  // TODO oldItem과 newItem이 실제로 Item이 같은지 판단
      
                  return oldItem == newItem
              }
      • 이 메소드에서는
        현재 View에 있는 데이터와
        View에 덮어 씌울려고 하는 데이터가 같은 지 여부에 따라
        View에 데이터를 덮어씌울지 아니면 그대로 사용해도 괜찮을 지 판단한다.
        ( 이렇게 비교하는 코드를 구현하여야 한다. )

      • 최종적으로 데이터가 같으므로 변경할 필요가 없다면 True를
        데이터가 달라서 변경할 필요가 있다면 False를 반환시키면 된다.

    • 이 부분에서 DiffUtil이 구현해야하는 메소드인 areContentsTheSame()메소드를 구현하고 있다.

               override fun areContentsTheSame(oldItem: Book, newItem: Book): Boolean {
                 // TODO 안에 있는 Contents가 같은지 다른지 판단
      
                 // id의 값이 같은지 다른지를 보고 Contents가 같은지 판단할 것임
                 // ( 이것은 Contents의 구성에 따라 무엇을 기준으로 할지 선택하면 됨 )
                 return oldItem.id == newItem.id
             }
      • 이 메소드에서는 현재 View에 있는 데이터의 특정 Contents와
        Viewdp 덮에 씌우려고 하는 데이터의 특정 Contents가ㅓ 같은지 여부에 따라
        Viewdp 데이터를 덮어씌울지 아니면 그대로 사용해도 괜찮을 지 판단한다.
        ( 이렇게 비교하는 코드를 구현하여야 한다. )

      • 최종적으로 데이터가 같으므로 변경할 필요가 없다면 True를
        데이터가 달라서 변경할 필요가 있다면 False를 반환시키면 된다.

3. Adapter를 RecyclerView에 세팅

<?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.recyclerview.widget.RecyclerView
        android:id="@+id/bookRecyclerView"
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/searchEditText" />


</androidx.constraintlayout.widget.ConstraintLayout>
  • 해당 RecyclerView에 다음과 같은 속성을 추가해주면,
    xml파일에서도 해당 RecyclerView가 어떤 식으로 보일지 미리 볼 수 있다.

    tools:listitem="res폴더의 layout폴더에 만들어놓은 RecyclerView에 설정할 레이아웃"
    예시)
    tools:listitem="@layout/item_book"

    private val bookRecyclerView: RecyclerView by lazy {
        findViewById(R.id.bookRecyclerView)
    }
    
   private lateinit var adapter: BookAdapter
    ......



        // 만든 Adapter를 가져옴
        adapter = BookAdapter()

        // 해당 RecyclerView가 실제로 어떻게 그려질 것인지에 대한 부분이 LayoutManager이다ㅡ, 
        // 이번에는 Linear하게 그려지길 원하기 때문에 LinearLayoutManager를 세팅했다.
        bookRecyclerView.layoutManager = LinearLayoutManager(this)

        // ViewBinding을 통해 레이아웃에 접근, RecyclerView에 Adapter를 세팅하고 있음
        bookRecyclerView.adapter = adapter
  • RecyclerView에 LayoutManager를 세팅하고 있음
    --> LinearLayoutManager() 메소드는 자기 자신을 반환함 + 파라미터로 Context를 받음
    --> 또한 LinearLayoutManager에 따로 별다른 파라미터를 주지 않았기 때문에
    RecyclerView의 기본 방향인 Vertical Orientation으로 설정

  • RecyclerView에 Adapter를 세팅하고 있음

4. RecyclerView를 통해 보여줄 데이터 리스트를 Adapter에 삽입

[ 위의 3번에서 이어지는 내용임 ]

val dataList = [ ~~~ Adapter에 설정된 데이터 타입의 데이터들이 모인 리스트 ]

     adapter.submitList(dataList?.orEmpty())
     
    
  • RecyclerView의 Adapter에 submitList()메소드를 통해서 리스트를 넣으면
    그것이 Adapter의 리스트로 새롭게 세팅된다.
  • adapter의 submitList() 메소드를 통해 데이터리스트를 Adapter에 세팅할 수 있으며,
    submotList()로 Adapter에 세팅된 데이터리스트를 변경하는 것 만으로
    해당 데이터 리스트의 내용이 RecyclerView에 갱신된다.

RecyclerView 응용 1 -> RecyclerView의 컴포넌트에 액티비티의 리스너 설정하기

  • 즉, RecyclerView에서 버튼과 같은 컴포넌트를 클릭할 경우,
    액티비티 차원의 리스너가 실행될 수 있도록
    RecyclerView의 컴포넌트에 액티비티의 리스너를 설정해주는 방법에 대한 것이다.

  • 방법은 다음과 같다.

    1. Adapter는 생성자의 파라미터를 통해 람다함수를 받을 준비를 한다.

    2. Adapter에서 컴포넌트에 리스너를 설정하고,
      리스너의 실행부에서 파라미터로 들어온 람다함수를 호출시키도록 한다.

    3. 액티비티에서는 RecyclerView의 컴포넌트를 클릭했을 경우 실행될 내용을 담은 람다함수를 만든다.

    4. Adapter를 생성할 때 Adapter의 파라미터로 3번에서 정의한 람다함수를 넣어서 Adapter에 넘겨준다.

  • 예시

  1. RecyclerView에서 Adapter의 일부분

    
      ......
    
      class HistoryAdapter(
      val historyDeleteClickedListener:(String)->Unit 
    ) : ListAdapter<History, HistoryAdapter.HistoryItemViewHolder>(diffUtil) {
    
     inner class HistoryItemViewHolder(private val binding: ItemHistoryBinding) :
         RecyclerView.ViewHolder(binding.root) {
    
         fun bind(historyModel: History) {
             binding.historyKeywordTextView.text = historyModel.keyword
    
              // Adapter가 액티비티로부터 파라미터를 통해 받아온 리스너를 해당 버튼에 설정해주고 있다.
             binding.historyKeywordDeleteButton.setOnClickListener {
                 historyDeleteClickedListener(historyModel.keyword.orEmpty())
             }
    
         }
    
     }
     
       ......
    • 이 부분에서 람다함수를 받을 준비를 하고 있음

      class HistoryAdapter(
      val historyDeleteClickedListener:(String)->Unit 
      ) : ......
      • 들어올 람다함수는 historyDeleteClickedListener()라는 이름의 메소드가 됨
        ( val을 통해 메소드의 변수 선언해주고 있음 )

      • 매개변수의 타입에 대한 설명
        " (String)->Unit "
        historyDeleteClickedListener()로 될 람다함수는
        String타입의 매개변수를 받는 함수이며, 값을 리턴하지 않음( Unit )

    • 이 부분에서 람다함수로 들어온 메소드를 리스너에 세팅 + 메소드를 호출하고 있음

            binding.historyKeywordDeleteButton.setOnClickListener {
             historyDeleteClickedListener(historyModel.keyword.orEmpty())
            }
      • RecyclerView의 컴포넌트에 Listener 설정

      • Listener가 호출되면, 액티비티에서 파라미터로 받은 람다함수를 호출함
        ( 해당 람다함수는 String 타입을 인자로 받으며, 리턴하지 않음 )
        ( 해당 람다함수의 내용은 액티비티에서 이미 정의되어 있음 )

  2. RecyclerView를 사용하는 액티비티의 일부분

           historyAdapter = HistoryAdapter(historyDeleteClickedListener = { keyword ->
               deleteSearchKeyword(keyword)
           })
    
           binding.historyRecyclerView.layoutManager = LinearLayoutManager(this)
           binding.historyRecyclerView.adapter = historyAdapter
    • 이 부분에서 Adapter 생성과 함께
      파라미터로 람다함수를 구현 및 전달해주고 있음

             historyAdapter = HistoryAdapter(historyDeleteClickedListener = { 
                      Thread {
                      db.historyDao().delete(it)
                      }.start()
          })
      • 여기서 구현한 람다함수는 Adapter에서 호출될 것임

      • 호출되었을 때ㅡ, 파라미터로 들어온 값은 일단 람다함수와 같이 접근할 수 있음
        ( 지금은 매개변수가 하나기 떄문에 it으로 접근가능 )

        --> 아래의 Adapter에서 매개변수로 설정한 람다함수의 형식과 일치해야함
        ( 매개변수 개수 및 타입, 리턴 )

        class HistoryAdapter(
        val historyDeleteClickedListener:(String)->Unit 
        ) : ......

RecyclerView의 특정 컴포넌트가 아니라 View의 레이아웃에 대한 리스너를 설정할 수도 있음

위의 Adapter에 아래와 같이
이렇게 레이아웃 자체에 리스너를 주면
RecyclerView의 각 View에 대한 리스너를 만들 수 있다.
--> 즉, RecyclerView의 각 item을 클릭하면 호출되는 리스너


            binding.root.setOnClickListener {

            }

Glide - 서버에 있는 이미지 URL 로드해서 사용하기

Glide 라이브러리 : https://github.com/bumptech/glide

Glide의 기능?

  • Glide 라이브러리는 Url형식으로 되어있는 이미지를
    안드로이드 스튜디오에서 간편하게 사용할 수 있도록 해주는 기능을 가지고 있다.

  • 예를들어
    이번과 같이 외부의 서버에서 데이터( Json데이터나 딕셔너리 데이터나 등등 )를 받아서 사용하는 경우에,
    ( String데이터는 문제가 없지만 )
    일반적으로 이미지 데이터의 경우 Url형식으로 전달한다.

    이렇게 Url형식으로 이미지가 전달될 경우에,
    해당 이미지를 사용하기 위해서는
    우선 그 이미지를 다운받은 다음에 사용해야하는데,
    이런 부분을 도와주는 것이 Glide란 기능이다.

Glide 사용시 발생할 수 있는 오류

  • Internet에 대한 권한이 있는지 확인 -> 인터넷을 사용하므로 권한이 있어야 함

  • Glide로 가져올 이미지 Url의 프로토콜이 http인지, https인지 확인

    --> 만약에 http로 되어 있다면, 안드로이드 9버전부터는 http를 기본적으로 지원하지 않으므로,
    http를 사용하겠다고 AndroidManifest에 설정해야 함

Glide 사용하기

  1. 외부 라이브러리이므로 build.gradle을 통해 다운받아야 함

    --> 앱 수준의 build.gradle에 코드 추가

    • 위에 Glide에 대한 github로 가서,
      아래 부분을 참고
      ( 현재는 4.13.0버전이 최신이므로 아래의 내용을 build.gradle에 넣어줌 )

        implementation 'com.github.bumptech.glide:glide:4.13.0'
  2. 이미지 Url를 가져와서 넣어주기

    • 예시
              Glide
                 .with(binding.coverImageView.context)
                 .load(bookModel.coverSmallUrl)
                 .into(binding.coverImageView)
    • Glide를 활용하여 이미지를 넣는 방법은 위와 같인 3개의 메소드를 설정해주면 된다.

      • with()는 파라미터로 Context를 받는다.

        --> 위의 경우, Activity가 아니라 Adapter에서 사용한 Glide이기 때문에
        ViewBinding하여 가져온 View에서 Context를 가져온 모습이다.
        ( Activity는 Context를 내장하고 있지만, Adapter 구현 클래스는 그렇지 않다. )

      • load()는 파라미터로 Glide를 사용하여 가져올 이미지의 Url를 받는다.

      • into()는 파라미터로 load()를 통해 받아온 이미지를 넣을 View를 받는다.

        -->위의 경우 ViewBinding으로 가져온 레이아웃의 ImageView에다가 받아온 이미지를 세팅시키고 있다.

    • 위의 예시를 좀 더 직관적으로 보면 다음과 같다.

            val imageView : ImageView = findViewById(R.id.imageView1)
      
                Glide
                  .with(Context)
                  .load("https://images.velog.io/images/odesay97/post/7c4aef67-6a99-4f46-bb9d-17a9c0393f3f/image.png")
                  .into(imageView)

데이터클래스 직렬화 ( Intent에 데이터클래스 담아서 넘기기 )

Intent를 통해 액티비티에서 다른 액티비티로 데이터를 전달할 때,
일반적인 데이터 클래스는 putExtra()를 통해 Intent에 담을 수 없다.

그 이유는 Intent에 데이터를 담을 때, 해당 데이터를 직렬화하여 담게 되는데,
클래스는 직렬화가 불가능하기 때문이다.

이를 해결하기 위해 Parcelize 기능을 통해
데이터 클래스를 직렬화 가능하게 만들 수 있다.

직렬화( Parcelize 방법 ) 가능하게 만들기

  1. 앱 수준의 build.gradle에 Parcelize 플러그인을 장착

       id 'kotlin-parcelize'
    
  2. 데이터 클래스에 "@Parcelize"주석을 달고 "Parcelable"을 상속하여
    해당 클래스를 직렬화 가능하게 만듬

    예시

    
     package com.example.aop_part3_chapter12.model
    
     import android.os.Parcelable
     import kotlinx.parcelize.Parcelize
    
     // 직렬화 가능하게 만듬 -> @Parcelize주석 + Parcelable 상속
     // 이것을 해줘야 해당 데이터 클래스를 Intent에 담아서 전달할 수 있음
     @Parcelize
     data class Book(
          val id: Long,
          val title: String,
          val description: String,
          val coverSmallUrl : String
    
     ): Parcelable

이후 이렇게 직렬화 가능하게 된 데이터 클래스는 아래와 같이
Intent에 담아서 전달할 수 있다.

예시

            val book = Book(1,"aa","cc","ss")

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

            intent.putExtra("bookModel", book)

            startActivity(intent)

이렇게 Intent로 담아서 보낸 데이터는 startActivity를 통해 넘어간 Activity에서
아래와 같이 getParcelableExtra<>() 메소드를 사용하여 받을 수 있다.

        val model = intent.getParcelableExtra<Book>("bookModel")

Android Room - history를 저장하는 기능 ( localDB의 기능 ) -> 4장 계산기에서 했었음

4장 Room 설명 : https://velog.io/@odesay97/%EC%95%B1-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-04-1-%EA%B3%84%EC%82%B0%EA%B8%B0-Layout-TableLayout-ConstraintLayout-LayoutInflator-Room-LocalDB%EA%B8%B0%EB%8A%A5-LocalDB%EC%97%90%EC%84%9C%EC%9D%98-Thread-%EC%82%AC%EC%9A%A9-runOnUiThread-Span-Text%EC%97%90-%ED%9A%A8%EA%B3%BC%EC%A3%BC%EA%B8%B0-ripple-%ED%95%B4%EB%8B%B9-Shape-Drawable-%ED%81%B4%EB%A6%AD-%EC%8B%9C-%EC%83%89-%EB%B3%80%EA%B2%BD-%ED%99%95%EC%9E%A5%ED%95%A8%EC%88%98-%EB%A7%8C%EB%93%A4%EA%B8%B0

DB 새로 구성할 때 버전업 하는 법

  • 위에서 Room의 DB를 생성하는 추상 클래스를 보면 알겠지만,
    Room에는 Version이라는 개념이 존재한다.

    Version은 말그대로 해당 DB의 Version을 의미하며,

    DB의 구조가 변경될 경우ㅡ,
    Version을 올려서 새로 배포할 필요가 있다.

  • 여기서 중요한 점이 DB는 현재 구조적으로 데이터들을 담고 있으며,
    따라서 그냥 내가 코드를 바꿔서 DB를 새로 구성했다고해서 그에 맞춰 갈 수는 없다.

    • 데이터가 어떻게 이동하고, 무엇이 새로 생기고 이런 DB의 사용 부분에 대해 안드로이드 스튜디오는 모르기 때문이다.
  • 따라서 이렇게 DB를 고치는 부분에 대해 문제가 없도록,
    무결성을 가진 SQL의 언어로 DB가 어떻게 변화될지를
    안드로이드 스튜디오에 명령해 줄 필요성이 있는데,
    그것이 Migration이다.

Migration의 예시를 들자면 다음과 같다.

  • Version = 1 인 AppDatabase.kt파일 --> 처음 구성상태

    @Database(entities = [History::class], version = 1)
    abstract class AppDatabase : RoomDatabase() {
       abstract fun historyDao(): HistoryDao
    }
    
    fun getAppDatabase(context: Context): AppDatabase {
       return Room.databaseBuilder(
           context,
           AppDatabase::class.java,
           "BookSearchDB"
       ).build()
    }
  • Version = 2 인 AppDatabase.kt파일 --> 처음 구성상태에서 DB에 변화가 생겨,
    DB의 구성을 바꾸고 이를 Migration으로 명시해줌

     // 만약 Database를 업그레이드 하여 다음 버전으로 갈 경우, migration을 통해 변경사항을 알려줘야한다.
     @Database(entities = [History::class, Review::class], version = 2)
     abstract class AppDatabase : RoomDatabase() {
         abstract fun historyDao(): HistoryDao
         abstract fun reviewDao(): ReviewDao
     }
    
     fun getAppDatabase(context: Context): AppDatabase {
     
         // 1버전에서 2버전으로 가는 Migration을 구현해준 것
         // 버전을 올릴 떄 이렇게 Migration을 반드시 구현해줘야 함
         val migration_1_2 = object :Migration(1,2){
             override fun migrate(database: SupportSQLiteDatabase) {
                 //TODO 버전이 올라갈때ㅡ, DB에 어떤 변경사항이 있을지 직접 Query문으로 작성해줘야 함
                 database.execSQL("CREATE TABLE `REVIEW` (`id` INTEGER, `review` TEXT" + "PRIMARY KEY(`id`))")
    
             }
         }
    
         // 가져온 DB를 build()하기 전에, addMigrations() 메소드를 사용하여 Migration 세팅
         return Room.databaseBuilder(
             context,
             AppDatabase::class.java,
             "BookSearchDB"
         )
             .addMigrations(migration_1_2)
             .build()
     }
    • 이 부분에서 기존의 DB에서 Review라는 새로운 DB가 나타났기 때문에
      이것을 적용시켜서 Version = 2 로 업그레이드 시켜줌

      @Database(entities = [History::class, Review::class], version = 2)
      abstract class AppDatabase : RoomDatabase() {
        abstract fun historyDao(): HistoryDao
        abstract fun reviewDao(): ReviewDao
      }
    • 이 부분에서 Version UP에 대한 변경사항을 제공하기 위한 Migration을 구현하고 있음

           val migration_1_2 = object :Migration(1,2){
             override fun migrate(database: SupportSQLiteDatabase) {
                 //TODO 버전이 올라갈때ㅡ, DB에 어떤 변경사항이 있을지 직접 Query문으로 작성해줘야 함
                 database.execSQL("CREATE TABLE `REVIEW` (`id` INTEGER, `review` TEXT" + "PRIMARY KEY(`id`))")
      
             }
         }
      • Migration() 추상 클래스를 무명클래스로 구현하고 있다.

      • Migration() 추상 클래스의 생성자는 2개의 파라미터를 받는데,

        • 첫번째 파라미터는 변경 전 버전

        • 두번째 파라미터는 변경 후 버전

          을 받는다.

      • Migration() 추상 클래스는 migrate() 추상 메소드를 구현해야 하는데
        migrate() 메소드의 파라미터로 들어오는 SupportSQLiteDatabase 객체의
        execSQL() 메소드를 사용하여 변경사항을 제공할 수 있다.

        이떄 변경사항은 SQL코드의 형태로 execSQL() 메소드의 인자로 넣는다.

    • 이 부분에서 Migration을 DB에 적용시키고 있음

          return Room.databaseBuilder(
          context,
          AppDatabase::class.java,
          "BookSearchDB"
      )
          .addMigrations(migration_1_2)
          .build()
      • DB를 build()하기 전에 addMigrations() 메소드를 통해 구성한 Migration을
        DB에 적용시켜줄 수 있다.

        • 이렇게 할 경우,
          만약에 현재 앱에서 DB가 Version = 1이라면 Version = 2로 업그레이드 시켜주고,
          현재 앱에서 DB가 Version = 2 라면 그대로 생성된다.
    • 즉, 위의 내용을 확대하자면 DB의 버전이 1,2,3,4,5,6 이 있다고 했을 때,
      ( 1에 가까울수록 예전, 6에 가까울수록 최신이라고 가정 )
      현재 앱에서 DB의 버전이 3 버전일 경우,

      Version = 3 에서 Version = 4 로 업데이트,
      Version = 4 에서 Version = 5 로 업데이트,
      Version = 5 에서 Version = 6 로 업데이트

      의 과정이 순차적으로 일어나서 DB를 업그레이드 시키게 된다.


Open API -> 다양한 서버들에서 제공하는 Open API를 신청해서 가져오는 방법

네트워크 상에는 다양한 서버들이 자신의 정보나 기술에 대한 사용권한을 Open API로 제공하고 있음

  • 일반적으로 Open API는 다음과 같은 과정으로 데이터를 전달함
    ( 일반적으로 URL형태 )

    1. 사용자가 원하는 정보에 대한 값을 서버측에 보내어 Request

    2. 서버측에서 해당 값에 대해 반환값을 줌
      -> 예를 들어, 각 지역의 미세먼지 정도에 대한 Open API가 있을 때,
      서울에 대한 미세먼지 정도를 알기 위해 서울의 값을 보내면,
      해당 서버가 API를 통해 서울의 미세먼지 정보에 대한 값을 나한테 반환해준다.
      ( 물론 Open API들마다 각각 사용법이 다르기 때문에 알아보고 사용해야 한다. )

      -> 여기서 사용법이 다르다는 것은 보낼 수 있는 값이 다르고, 반환하는 데이터의 형태와 종류가 다르다는 것이다.

  • 큰 기업들이나, 데이터를 다루는 기관들은 대부분 Open API를 가지고 있음
    예시 ) 네이버, 카카오, 구글, 공공데이터 포털 등등

  • 일반적으로 Open API는 해당 API에 대한 인증키를 받아서 사용한다.
    --> 인증키는 해당 Open API를 사용할 때 필요하며,
    내가 해당 OpenAPI를 사용했다고 말하는 신분증명이다.
    따라서 인증키는 외부로 노출되어서는 안된다. -> 노출되었을 경우 새로 발급받아야 한다.

예시 -> 인터파크 도서 리스트 Open API

인터파크 도서 : http://book.interpark.com/bookPark/html/book.html?utm_source=google&utm_medium=cpc&utm_campaign=book_brand_20210617_pc_cpc_paidsearch&utm_content=consider_34&utm_term=%EC%9D%B8%ED%84%B0%ED%8C%8C%ED%81%AC%EB%8F%84%EC%84%9C&utm_term=%EC%9D%B8%ED%84%B0%ED%8C%8C%ED%81%AC%EB%8F%84%EC%84%9C&gclid=Cj0KCQiAxoiQBhCRARIsAPsvo-wHKF64orp8uyjLtMl-HRM6a5E9wNW390ORvWZhBCI0Sdar5-RGfrUaAhEbEALw_wcB

  • 인증키 발급 받기
    -> 이건 인터파크의 경우이고, 서버마다 발급방법이 다름

    1. 로그인 후, 북피니언 접근

    2. 북피니언에서 관리 클릭

    3. 오픈업 관리 클릭 후, 인증키 발급받기

      --> 이렇게 받은 인증키는 외부로 노출되어서는 안된다. ( 일종의 신분증명의 역할을 하므로 )

  • 인터파크 Book Open API에 대한 사용설명

    http://book.interpark.com/bookPark/html/bookpinion/api_main.html

    -> 책검색, 베스트셀러, 추천도서, 신간도서의 4개의 API가 있음

    • [ 베스트셀러 Open API를 예시로 설명 ]

      위와 같이 서버에서 API에 대한 Request를 담당하는 URL에 요청변수를 담아서
      서버에 Request( 브라우저에서 요청하듯이 )하면
      아래와 같이 xml형태로 데이터를 보내준다.
      --> 위의 문서를 보면 output의 기본값이 xml로 되어 있다.

      만약에 xml이 아니라 Json형식으로 받고 싶다면, 위에 문서에 나온대로
      URL을 보낼 때 output에 대한 변수를 json으로 주면 된다.

      --> 이렇게 받은 데이터 뭉치에서 내가 필요한 데이터를 뽑아내어 사용하면 된다.
      --> 데이터의 형식과 각 카테고리의 의미에 대한 부분은 해당 Open API의 설명에 다 나와있다.


Postman --> Open API에 Request했을 때, 그 결과값을 쉽게 볼 수 있도록 해주는 도구

만약에 안드로이드에서 Open API의 결과값을 확인하려고 하면,
일일이 안드로이드 앱을 실행시키고, URL을 변경해줘야되는 번거로움이 있는데,
이 도구를 사용하면 그런 부분을 쉽게 할 수 있다.

  • Postman 검색하여 Chrome에 추가

  • 이후 Chrome 앱에서 실행

  • 로그인 혹은 회원가입

  • 이후 실행

    A : 어떤 형식으로 보낼 것인가 -> GET, POST, PATCH, DELETE 등등

    B : 요청 URL

    C : 변수추가 -> key-value

  • 예시


Retrofit - 네트워크 호출과 API 호출을 도와주는 라이브러리

공식 문서 : https://square.github.io/retrofit/

  • 라이브러리 다운 --> 공식문서 확인할 것

    implementation 'com.squareup.retrofit2:retrofit:(insert latest version)'

    --> https://github.com/square/retrofit 웹사이트 들어가서 최신버전 확인해서 위 코드에 넣을 것
    --> 현재 2.9.0버전이 최신이므로 implementation 'com.squareup.retrofit2:retrofit:2.9.0'
    ( API 21버전 이상부터 가능 )

  • converter-gson 라이브러리도 다운 -> 버전은 위와 동일

    implementation 'com.squareup.retrofit2:converter-gson:2.9.0'

    --> gson이라는 형식으로 변환해주는 컨버터를 전담하는 라이브러리
    ( gson이 무엇인지 아래에 설명있음 )

    간단히 말해 네트워크에서 들어온 String타입의 json을
    바로 gson형태로 변환시켜주는 라이브러리임
    --> Object타입으로 사용할 필요가 있기 때문에 gson형태로 변환시키는 것

    gson이란??

    일반적으로 데이터는 Json의 형태로 주고 받는데( JavaScript기반 ),
    이런 Json은 직렬화되어 있기 때문에 String타입을 가진다.

    그런데 이번 앱에서는 자료를 Object형으로 사용할 필요가 있기 때문에
    String타입의 Json을 Object 타입으로 변환해주어야한다.

    이렇게 String타입의 Json을 Object형으로 변환하는 것을 도와주는 라이브러리를
    구글에서 만들었는데,
    그것이 gson 라이브러리이다.

    위에 Retrofit은 네트워크에서 String형태로 들어온 Json을
    바로 gson 형태로 바꿔주는 Conterver를 라이브러리로 제공하고 있다.

Retrofit 사용하기

@SerializedName(),
@GET(),
@Query(),

  • Retrofit은 기본적으로 인터넷을 통해 Open API와 통신하므로 인터넷에 대한 권한이 있어야 한다.
    --> AndroidManifest.xml에 추가

      <uses-permission android:name="android.permission.INTERNET"/>
  • Retrofit을 통해 받아오려는 데이터 예시

    --> 데이터는 하나의 딕셔너리로 이루어져 있으며,
    해당 딕셔너리의 item( key이름 )의 value가 Json형태로 이루어져
    책에 대한 데이터를 담고 있다.

    {
       "title": "베스트셀러",
       "link": "http://book.interpark.com",
       "language": "ko",
       "copyright": "Copyright ⓒ 2009 INTERPARK INT All rights reserved.",
       "pubDate": "8 Feb 2022 22:30:03 GMT",
       "imageUrl": "http://bimage.interpark.com/renewPark/topGnb/logo.jpg",
       "totalResults": 30,
       "startIndex": 1,
       "itemsPerPage": 30,
       "maxResults": 30,
       "queryType": "",
       "searchCategoryId": "100",
       "searchCategoryName": "국내도서",
       "returnCode": "000",
       "returnMessage": "정상",
       "item": [
           {
               "itemId": 348921505,
               "title": "불편한 편의점(15만부 기념 윈터 에디션)",
               "description": "※ 인터넷 한정 특별판: 매장 구매, 바로드림 구매 시에는 기존 일반판 표지와 랜덤으로 제공됩니다.\n\n원 플러스 원의 기쁨, 삼각김밥 모양의 슬픔, 만 원에 네 번의 폭소가 터지는 곳!\n힘겨운 시대를 살아가는 우리들에게 다가온 조금 특별한 편의점 이야기\n\n『불편한 편의점』 15만부 기념 한정판 윈터 에디션 출간!\n2021년 4월에 출간되어 15만이 넘는 독자의 뜨거운 사랑을 받고 있는 소설! 청파동 골목의 작은 편의점에서 펼쳐지는 마법 같은 이야기 『불편한 편의점』이 윈터 에디션을 선보입니다.\n눈 내리는 밤, 크리스마스 조명을 밝힌 편의점이 등대처럼 든든하게 골목을 지키고 있네요. 전면에 홀로그램 박을 입혀 신비롭게 반짝이는 표지가 특별함을 더합니다. 책 속에는 김호연 작가의 친필 서명도 인쇄되어 있답니다....",
               "pubDate": "20210420",
               "priceStandard": 14000,
               "priceSales": 12600,
               "discountRate": "10",
               "saleStatus": "판매중",
               "mileage": "700",
               "mileageRate": "6",
               "coverSmallUrl": "https://bimage.interpark.com/partner/goods_image/1/5/0/5/348921505h.jpg",
               "coverLargeUrl": "https://bimage.interpark.com/partner/goods_image/1/5/0/5/348921505s.jpg",
               "categoryId": "101",
               "categoryName": "국내도서",
               "publisher": "나무옆의자",
               "customerReviewRank": 9.8,
               "author": "김호연",
               "translator": "",
               "isbn": "9791161571188",
               "link": "http://book.interpark.com/blog/integration/product/itemDetail.rdo?prdNo=348921505&refererType=8305",
               "mobileLink": "http://m.book.interpark.com/view.html?PRD_NO=348921505&SHOP_NO=0000400000",
               "additionalLink": "http://book.interpark.com/gate/ippgw.jsp?goods_no=348921505&biz_cd=",
               "reviewCount": 11,
               "rank": 1
           },
           {
               "itemId": 354294965,
               "title": "종목 선정 나에게 물어봐",
    
              ......
             
           
               "isbn": "9788919205013",
               "link": "http://book.interpark.com/blog/integration/product/itemDetail.rdo?prdNo=208478241&refererType=8305",
               "mobileLink": "http://m.book.interpark.com/view.html?PRD_NO=208478241&SHOP_NO=0000400000",
               "additionalLink": "http://book.interpark.com/gate/ippgw.jsp?goods_no=208478241&biz_cd=",
               "reviewCount": 17,
               "rank": 30
           }
       ]
    }

1. 데이터를 담을 데이터 모델 만들기

  • @SerializedName는 retrofit의 주석중 하나이다.
    파라미터로 Json에서 Key이름을 받으며,
    그 Key의 Value를 넣을 변수에 세팅한다.

    예를들어,
    아래와 같이 되어 있다면,
    들어온 딕셔너리의 itemID라는 key의 value는 id변수에 들어간다.

       @SerializedName("itemID") val id: Long,

예시1 ) Book.kt -> 책 데이터를 담을 데이터 모델

package com.example.aop_part3_chapter12.model
 
import com.google.gson.annotations.SerializedName

data class Book(
   @SerializedName("itemID") val id: Long,
   @SerializedName("title") val title: String,
   @SerializedName("description") val description: String,
   @SerializedName("coverSmallUrl") val coverSmallUrl : String

)

그런데 위에 Json데이터의 형태를 보면,
하나의 딕셔너리로 이루어져 있고
그 딕셔너리의 item(KEY이름) 안에 책의 데이터가 Json형태로 있는 것을 알 수 있다.

즉, 해당 데이터가 들어올 때는 하나의 딕셔너리로 들어오는 것이다.

따라서 이렇게 들어온 데이터를 받아줄 모델이 따로 필요한데,
이렇게 전체 모델에서 데이터를 꺼내올 수 있게 연결시켜주는 모델을

[ DTO(Data Transfer Object, 데이터 전송 객체) ]라고 한다.

예시 2 ) BestSellerDTO 모델 예시 -> DTO 모델


package com.example.aop_part3_chapter12.model
 
import com.google.gson.annotations.SerializedName
 

 data class BestCellerDto(
     @SerializedName("title") val title: String,
      @SerializedName("item") val books: List<Book>

 )

예시 3 ) SearchBookDto.kt -> DTO 모델


package com.example.aop_part3_chapter12.model

import com.google.gson.annotations.SerializedName

data class SearchBookDto(
 @SerializedName("title") val title: String,
 @SerializedName("item") val books: List<Book>
)

Json의 형태를 보면,
최초에 들어올 때, 데이터는 하나의 딕셔너리로 되어있는데,
이 딕셔너리의 데이터가 이 DTO 모델에 들어가며,

이 모델(딕셔너리를 담고 있는)에서 책 데이터에 대한 Json을 가지고 있는 item키에서
Json데이터를 받아서 하나씩 Book 모델에 넣는 것이다.

  • DTO ( Data Transfer Object, 데이터 전송 객체 )

    전체 모델에서 데이터를 꺼내올 수 있게 연결시켜주는 개념을
    DTO(Data Transfer Object, 데이터 전송 객체)라고 함

    --> 위의 코드를 예로 들자면ㅡ,
    예시 2,3의 모델은 Book에 데이터를 넣기 위해
    Open API로부터 전체 데이터를 받는 역할만을 맡는다.

    예시 2,3의 모델이 데이터를 받으면, 받은 데이터의 item키에 접근하여
    Json데이터를 Book모델에 하나씩 저장하는 형식인 것이다.

    이러한 쓰임새 때문에 데이터 전송 객체라고 불리는 것이다.

2. 데이터를 받기위한 Interface를 만듬

예시 ) BookServiec.kt

package com.example.aop_part3_chapter12.api

import com.example.aop_part3_chapter12.model.BestSellerDto
import com.example.aop_part3_chapter12.model.SearchBookDto
import retrofit2.Call
import retrofit2.http.GET
import retrofit2.http.Query

interface BookService {

  // 결국 GET형식으로 GET의 파라미터와 Query부분을 합쳐서 Url로 API에 Request를 보내고, 그에 대한 Response를 Call의 제네릭타입으로 설정한 모델의 객체로 받는 것이다.
  @GET("/api/search.api?output=json")
  fun getBooksByName(
      @Query("key") apiKey: String,
      @Query("query") keyword: String
  ): Call<SearchBookDto>

  // 베스트셀러의 경우 카테고리번호는 항상 100번으로 일정하므로, categoryId도 Get에 추가시켜준다.
  @GET("/api/bestSeller.api?output=json&categoryId=100")
  fun getBestSellerBooks(
      @Query("key") apiKey: String,
  ): Call<BestSellerDto>

}
  • @GET은 API에 GET형식의 REQUEST를 하기 위한 retrofit 주석이다.

    @GET은 파라미터로 요청 url에서 프로토콜( http://, https:// )과
    도메인( www.~~~.com 부분 )을 제외하고,
    뒤에 세부주소와 조건을 넣어주면 된다.

    예를 들어)

    "http://book.interpark.com/api/search.api"

    이런 주소라면

    "/api/search.api"

    이 부분만 넣어주면 된다.

    • 또한 해당 GET Request에서 항상 들어갈 조건은 @GET에 파라미터로 넣어준다.

      예를들어 위에

      "/api/bestSeller.api?output=json&categoryId=100"

      에서

      output=json
      categoryId=100

      은 해당 메소드를 통한 Request시에 항상 들어갈 조건이었기 때문에
      @GET의 파라미터에 같이 넣었다.

    • @GET은 메소드에 붙여서 사용하며, 이후 해당 메소드가 호출되면 GET의 경로로 Request를 한다.

  • @Query는 메소드의 파라미터에 붙는 retrofit 주석이다.

    • @Query는 Request시에 해당 메소드의 파라미터를
      Request의 Url에 넣어주는 역할을 한다.

      예를들어)

       @GET("/api/bestSeller.api?output=json&categoryId=100")
      fun getBestSellerBooks(
         @Query("key123123") apiKey: String,
      ): Call<BestSellerDto>

      에서

      getBestSellerBooks("aaasdsdsd")

      이런 식으로 메소드를 사용했다면

      http://~~ 도메인 ~~/api/bestSeller.api?output=json&categoryId=100&key123123=aaasdsdsd

      이런 식으로 뒤에 &key123123=aaasdsdsd가 추가된 Url을 만들어 Request 하게 된다.

  • 즉, 항상 들어가는 고정변수를 @GET의 파라미터에 추가하는 것이고,

  • 변경될 수 있는 사항들을 아래에 @Query에 추가시켜주는 것이다.

  • 이렇게 만든 메소드는 위에서 정의한 DTO 모델을 제네릭 타입으로 하여 Call을 호출한다.
    --> 즉, 위의 Request를 통해 데이터가 들어왔다면
    Call을 통해 제네릭타입으로 지정한 모델의 구조로 들어온 데이터를 저장하는 것

3. Retrofit 구현 및 인터페이스 세팅

        val retrofit = Retrofit.Builder()
            .baseUrl("https://book.interpark.com")
            .addConverterFactory(GsonConverterFactory.create())
            .build()
            
        val bookService = retrofit.create(BookService::class.java)
  • baseUrl()의 파라미터로 프로토콜을 포함하여 API를 요청할 도메인을 넣어주면 된다.

    --> 참고로 프로토콜을 https가 아닌 http로 하면 오류가 발생한다.
    ( 9버전부터 http 지원하지 않음 )

  • 이 부분에서 retrofit에 아까 가져왔던 gson-converter를 생성하여 세팅해준다.

               .addConverterFactory(GsonConverterFactory.create())
  • 이렇게 Build한 Retrofit의 create() 메소드를 사용하여
    2번에서 만든 인터페이스를 세팅함

4. Retrofit에서 인터페이스를 구현

       bookService.getBestSellerBooks("API키가 들어가는 부분입니다.")
            .enqueue(object: Callback<BestSellerDto>{
                override fun onResponse(
                    call: Call<BestSellerDto>,
                    response: Response<BestSellerDto>
                ) {
                    //TODO 성공처리

                    if(response.isSuccessful.not()){
                        return
                    }

                    response.body()?.let {
                        Log.d(TAG,it.toString())

                        it.books.forEach { book ->
                            Log.d(TAG,it.toString())

                        }
                    }
                }

                override fun onFailure(
                    call: Call<BestSellerDto>,
                    t: Throwable
                ) {
                    //TODO 실패처리

                    Log.e(TAG,t.toString())
                }
            })
    }
    
        companion object{
        private const val TAG = "MainActivity"
    }
  • 이 부분에서 인터페이스의 메소드를 구현함

    bookService.getBestSellerBooks("API키가 들어가는 부분입니다.")
               .enqueue(object: Callback<BestSellerDto>{
               
               ......
    • getBestSellerBooks()는 2번에서 인터페이스로 만든 메소드이며,
      Call<BestSellerDto>를 반환한다.

    • Call<BestSellerDto>의 메소드인 enqueue() 메소드가 실행되며
      인자로 Callback<BestSellerDto>을 구현하는 익명클래스를 받는다.

  • 이 부분에서 Callback<BestSellerDto> 익명클래스를 구현함

    object: Callback<BestSellerDto>{
                   override fun onResponse(
                       call: Call<BestSellerDto>,
                       response: Response<BestSellerDto>
                   ) {
                       //TODO 성공처리
    
                       if(response.isSuccessful.not()){
                           return
                       }
    
                       response.body()?.let {
                           Log.d(TAG,it.toString())
     
                           it.books.forEach { book ->
                               Log.d(TAG,it.toString())
                           }
                       }
                   }
    
                   override fun onFailure(
                       call: Call<BestSellerDto>,
                       t: Throwable
                   ) {
                       //TODO 실패처리
    
                       Log.e(TAG,t.toString())
                   }
               }
    • Callback<BestSellerDto> 클래스는
      onResponse()와 onFailure()를 구현해야한다.

    • onResponse()는 데이터를 가져오는 것에 성공했을 경우 호출되며,
      onFailure() 는 데이터를 가져오는 것에 실패했을 경우 호출된다.

    • onResponse()의 두번째 파라미터인 response( Response<제네릭> 클래스의 객체 )에는
      들어온 데이터가 들어있으며,
      body() 메소드를 사용하여 해당 데이터( 제네릭타입의 모델안에 들어가 있는 데이터 )를
      반환할 수 있다.
      ( 예를 들어,
      위에서는 제네릭이 BestSellerDto이므로 body() 메소드를 사용하여
      들어온 데이터가 담겨있는 BestSellerDto객체를 반환한다.)


KeyListener 주의할 점

KeyListener는 해당 컴포넌트에 대해
자판의 키를 누를 때와
자판에서 키를 떼었을 때
호출되는 메소드이다.

  • 그런데 주의할 점이 위에서 설명했던 대로
    자판의 키는 2번의 상태 변화가 있는데, 다음과 같다.

    1. 키를 누르지 않은 상태 -> 키를 누른 상태
    2. 키를 누른 상태 -> 키를 떼서 다시 누르지 않은 상태로 돌아옴

KeyListener()의 경우, 위의 2번의 상태변화에 각각 실행된다.

  • 따라서 KeyListener()를 설정하는 경우에는
    아래와 같이 Action_Down일 때( 키가 눌렸을때 )에만 실행될 것이라고 지정해줘야
    리스너가 2번 호출되는 것을 방지할 수 있다.

        binding.searchEditText.setOnKeyListener { v, keyCode, event ->


            if (keyCode == KeyEvent.KEYCODE_ENTER && event.action == MotionEvent.ACTION_DOWN) {
                search(binding.searchEditText.text.toString())

                // true를 반환하면 Key 클릭에 대한 동작이 모두 완료되었다는 의미
                return@setOnKeyListener true
            }
            // false를 반환하면 Key 클릭에 대한 동작이 모두 완료되지 않았다는 의미
            return@setOnKeyListener false

        }

코드 소개

MainActivity.kt

package com.example.aop_part3_chapter12

import android.content.Intent
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.util.Log
import android.view.KeyEvent
import android.view.LayoutInflater
import android.view.MotionEvent
import androidx.core.view.isVisible
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.room.Room
import com.example.aop_part3_chapter12.adapter.BookAdapter
import com.example.aop_part3_chapter12.adapter.HistoryAdapter
import com.example.aop_part3_chapter12.api.BookService
import com.example.aop_part3_chapter12.databinding.ActivityMainBinding
import com.example.aop_part3_chapter12.model.BestSellerDto
import com.example.aop_part3_chapter12.model.Book
import com.example.aop_part3_chapter12.model.History
import com.example.aop_part3_chapter12.model.SearchBookDto
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory

class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding
    private lateinit var adapter: BookAdapter
    private lateinit var bookService: BookService
    private lateinit var historyAdapter: HistoryAdapter

    private lateinit var db: AppDatabase

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

        // activity_main.xml에 대한 ViewBinding ㅡ, actuvuty_main이라는 이름이기 때문에 ActivityMainBinding이라는 이름의 ViewBinding이 된 것임
        // Activity에는 LayoutInflater가 내장되어 있으므로 그것을 인자로 사용 ( layoutInflater )
        binding = ActivityMainBinding.inflate(layoutInflater)

        // setContentView()에는 ViewBinding을 통해 가져온 activity_main.xml의 주소값을 넣어주고 있음( binding.root )
        setContentView(binding.root)

        initBookRecyclerView()
        initHistoryRecyclerView()

        db = getAppDatabase(this)


        val retrofit = Retrofit.Builder()
            .baseUrl("https://book.interpark.com")
            .addConverterFactory(GsonConverterFactory.create())
            .build()

        bookService = retrofit.create(BookService::class.java)

        // API_KEY와 같이 전역적으로 사용해야하는 변수는 따로 xml로 만들어서 관리하는 것이 좋다.
        bookService.getBestSellerBooks(getString(R.string.interparkAPIKEY))
            .enqueue(object : Callback<BestSellerDto> {
                override fun onResponse(
                    call: Call<BestSellerDto>,
                    response: Response<BestSellerDto>
                ) {
                    //TODO 성공처리

                    if (response.isSuccessful.not()) {
                        return
                    }


                    // RecyclerView의 Adapter에 submitList()메소드를 통해서 리스트를 넣으면 그것이 Adapter의 리스트로 새롭게 세팅된다.
                    adapter.submitList(response.body()?.books.orEmpty())
                }

                override fun onFailure(
                    call: Call<BestSellerDto>,
                    t: Throwable
                ) {
                    //TODO 실패처리

                    Log.e(TAG, t.toString())
                }

            })

        initSearchEditText()


    }

    private fun search(keyword: String) {

        // API_KEY와 같이 전역적으로 사용해야하는 변수는 따로 xml로 만들어서 관리하는 것이 좋다.
        bookService.getBooksByName(getString(R.string.interparkAPIKEY), keyword)
            .enqueue(object : Callback<SearchBookDto> {
                override fun onResponse(
                    call: Call<SearchBookDto>,
                    response: Response<SearchBookDto>
                ) {
                    //TODO 성공처리

                    hideHistoryView()
                    saveSearchKeyword(keyword)

                    if (response.isSuccessful.not()) {
                        return
                    }

                    // RecyclerView의 Adapter에 submitList()메소드를 통해서 리스트를 넣으면 그것이 Adapter의 리스트로 새롭게 세팅된다.
                    adapter.submitList(response.body()?.books.orEmpty())
                 //   Log.e("bb", response.body().toString())

                }

                override fun onFailure(
                    call: Call<SearchBookDto>,
                    t: Throwable
                ) {
                    //TODO 실패처리

                    hideHistoryView()
                   // Log.e(TAG, t.toString())
                }

            })

    }

    fun initBookRecyclerView() {


        // 만든 Adapter를 가져옴
        adapter = BookAdapter(itemClickedListener = {
            val intent = Intent(this, DetailActivity::class.java)
            intent.putExtra("bookModel", it)
      //      Log.e("bb", it.id.toString())
            startActivity(intent)
        })

        // 해당 RecyclerView가 실제로 어떻게 그려질 것인지에 대한 부분이 LayoutManager이다ㅡ, 이번에는 Linear하게 그려지길 원하기 때문에 LinearLayoutManager를 세팅했다.
        binding.bookRecyclerView.layoutManager = LinearLayoutManager(this)

        // ViewBinding을 통해 레이아웃에 접근, RecyclerView에 Adapter를 세팅하고 있음
        binding.bookRecyclerView.adapter = adapter
    }


    fun initHistoryRecyclerView() {

        // 클릭리스너를 람다함수로 만들어서 Adapter에 넘겨주고 있음
        historyAdapter = HistoryAdapter(historyDeleteClickedListener = {
            deleteSearchKeyword(it)
        })

        binding.historyRecyclerView.layoutManager = LinearLayoutManager(this)
        binding.historyRecyclerView.adapter = historyAdapter

    }

    private fun initSearchEditText() {
        // ViewBinding을 통해 EditText에 접근
        // setOnKeyListener는 EditText에 키가 입력될 때마다 호출되는 메소드
        binding.searchEditText.setOnKeyListener { v, keyCode, event ->

            // 키의 리스너가 호출되는 상황은 기본적으로 키를 누를때와 키에 누르는 것을 멈췄을 때의 2가지가 존재한다.
            // 따라서 아래와 같이 Action_Down일 때( 키가 눌렸을때 )에만 실행될 것이라고 해줘야 리스너가 2번 호출되는 것을 방지할 수 있다.
            if (keyCode == KeyEvent.KEYCODE_ENTER && event.action == MotionEvent.ACTION_DOWN) {
                search(binding.searchEditText.text.toString())

                // true를 반환하면 Key 클릭에 대한 동작이 모두 완료되었다는 의미
                return@setOnKeyListener true
            }
            // false를 반환하면 Key 클릭에 대한 동작이 모두 완료되지 않았다는 의미
            return@setOnKeyListener false

        }

        binding.searchEditText.setOnTouchListener { v, event ->

            // TouchListener는 해당 위젯을 Touch할때마다 실행되므로, 어떤 action일 경우에 코드가 실행되어야하는지 아래와 같이 분기해줘야 함
            if (event.action == MotionEvent.ACTION_DOWN) {
                showHistoryView()

                // 리턴값을 true로 주게 되면 Touch이라는 action에 대한 동작이 모두 완료되었다고 인식되서
                // Touch의 본래 기능( 예를 들어 EditText라고 치면 EditText에 하이라이트 되는 기능 )을 상실하므로 true로 하지 않았다.
                //return@setOnTouchListener true
            }
            return@setOnTouchListener false
        }
    }

    private fun showHistoryView() {
        Thread {
            val keyword = db.historyDao().getAll().reversed()

            runOnUiThread {
                historyAdapter.submitList(keyword.orEmpty())
                binding.historyRecyclerView.isVisible = true
            }

        }.start()

    }

    private fun hideHistoryView() {
        binding.historyRecyclerView.isVisible = false
    }

    private fun saveSearchKeyword(keyword: String) {
        Thread {
            db.historyDao().insertHistory(History(null, keyword))
        }.start()
    }

    private fun deleteSearchKeyword(keyword: String) {
        Thread {
            db.historyDao().delete(keyword)
        }.start()

        // todo View 갱신
        showHistoryView()
    }

    companion object {
        private const val TAG = "MainActivity"
    }
}

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/searchEditText"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:inputType="text"
        android:lines="1"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />
    <!--    lines 속성을 1로 잡아준 이유는 사용자가 Enter를 눌렀을 시에 줄바꿈을 방지하기 위함이다-->

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/bookRecyclerView"
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/searchEditText"
        tools:listitem="@layout/item_book" />
    <!--    tools의 listitem에 레이아웃값을 줘서 RecyclerView가 어떻게 보일지 미리 볼 수도 있다.-->

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/historyRecyclerView"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:background="@color/white"
        android:visibility="gone"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/searchEditText" />


</androidx.constraintlayout.widget.ConstraintLayout>

DetailActivity.kt -> BookRecyclerView를 클릭하면 실행될 상세 액티비티

package com.example.aop_part3_chapter12

import android.os.Bundle
import android.util.Log
import androidx.appcompat.app.AppCompatActivity
import androidx.room.Room
import com.bumptech.glide.Glide
import com.example.aop_part3_chapter12.databinding.ActivityDetailBinding
import com.example.aop_part3_chapter12.model.Book
import com.example.aop_part3_chapter12.model.Review

class DetailActivity : AppCompatActivity() {

    private lateinit var binding: ActivityDetailBinding
    private lateinit var db: AppDatabase


    override fun onCreate(savedInstanceState: Bundle?) {

        binding = ActivityDetailBinding.inflate(layoutInflater)

        super.onCreate(savedInstanceState)
        setContentView(binding.root)

        db = getAppDatabase(this)

        val model = intent.getParcelableExtra<Book>("bookModel")
       // Log.e("bb", model?.id.toString())

        binding.titleTextView.text = model?.title.orEmpty()
        binding.descriptionTextView.text = model?.description.orEmpty()

        Glide.with(binding.coverImageView.context)
            .load(model?.coverSmallUrl.orEmpty())
            .into(binding.coverImageView)

        Thread{

            val review: Review? = db.reviewDao().getOneReview(model?.id?.toInt() ?: 0)

            runOnUiThread {
                binding.reviewEditText.setText(review?.review.orEmpty())
            }
        }.start()

        binding.saveButton.setOnClickListener {
            Thread {
                db.reviewDao().saveReview(
                    Review(model?.id?.toInt() ?: 0,
                        binding.reviewEditText.text.toString()
                    )
                )
            }.start()
        }
    }
}

acitivity_detail.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"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <ScrollView
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent">

        <androidx.constraintlayout.widget.ConstraintLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content">

            <TextView
                android:id="@+id/titleTextView"
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:layout_margin="16dp"
                android:gravity="center"
                android:textColor="@color/black"
                android:textSize="24sp"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toTopOf="parent" />

            <ImageView
                android:id="@+id/coverImageView"
                android:layout_width="300dp"
                android:layout_height="300dp"
                android:layout_marginTop="16dp"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toBottomOf="@+id/titleTextView" />

            <TextView
                android:id="@+id/descriptionTextView"
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:layout_margin="16dp"
                android:textColor="@color/black"
                android:textSize="16sp"

                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toBottomOf="@id/coverImageView" />

            <EditText
                android:id="@+id/reviewEditText"
                android:layout_width="0dp"
                android:layout_height="300dp"
                app:layout_constraintBottom_toTopOf="@id/saveButton"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toBottomOf="@id/descriptionTextView" />

            <Button
                android:id="@+id/saveButton"
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:text="@string/save"
                app:layout_constraintBottom_toBottomOf="parent"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintStart_toStartOf="parent" />


        </androidx.constraintlayout.widget.ConstraintLayout>

    </ScrollView>

</androidx.constraintlayout.widget.ConstraintLayout>

RecyclerView 사용을 위한 레이아웃들

BookAdapter.kt -> 책 리스트를 나타내기 위한 RecyclerView의 Adapter

package com.example.aop_part3_chapter12.adapter

import android.util.Log
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.AdapterView
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.example.aop_part3_chapter12.databinding.ItemBookBinding
import com.example.aop_part3_chapter12.model.Book

// RecyclerView에 있는 ListAdapter를 상속해야함
class BookAdapter(private val itemClickedListener: (Book)->Unit) : ListAdapter<Book, BookAdapter.BookItemViewHolder>(diffUtil) {

    // RecyclerView는 미리 View를 몇개 만들어 놓는 방식을 사용한다고 했는데, 
    // 이렇게 미리 만들어 놓는 View를 ViewHolder라고 한다.
    // 일반적으로 ViewHolder가 사용할 레이아웃은 LayoutInflater를 통해 가져오지만, 
    // 여기서는 ViewBinding을 통해 가져와 볼 것이다.
    inner class BookItemViewHolder(private val binding: ItemBookBinding) :
        RecyclerView.ViewHolder(binding.root) {

        fun bind(bookModel: Book) {

            // LayoutInflater가 아닌 ViewBinding을 통해 레이아웃에 접근하는 방식을 사용한 것임
            binding.titleTextView.text = bookModel.title
            binding.descriptionTextView.text = bookModel.description

			// 액티비티에서 람다함수를 가져와서 RecyclerView의 Listener에 세팅
            binding.root.setOnClickListener {
                itemClickedListener(bookModel)
            }

			// 이미지 url에서 이미지 파일 추출
            Glide
                .with(binding.coverImageView.context)
                .load(bookModel.coverSmallUrl)
                .into(binding.coverImageView)

        }

    }


    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BookItemViewHolder {
        // TODO 이미 만들어진 ViewHolder가 없을 경우에 새로 생성하는 메소드

        // ItemBookBinding( item_book.xml에 대한 ViewBinding )을 통해 해당 레이아웃을 Inflate하여 ViewHolder에 인자로 전달
        // ( 이후 ViewHolder는 이 레이아웃의 주소값을 ViewHolder 자신이 상속한 RecyclerView.ViewHolder()에 인자로 전달하는 것으로 자신의 레이아웃을 그린다. )
        return BookItemViewHolder(
            ItemBookBinding.inflate(
                LayoutInflater.from(parent.context),
                parent,
                false
            )
        )
    }

    override fun onBindViewHolder(holder: BookItemViewHolder, position: Int) {
        // TODO 실제로 이 ViewHolder가 View에 그려지게 되었을 때, 데이터를 그려주게 되는( 데이터를 Bind하게 되는 ) 메소드

        // ListAdapter에 세팅되어 있는 데이터들의 List는 currentList 변수에 저장되어 있음
        holder.bind(currentList[position])
    }


//     diffUtil이란 RecyclerView에서 실재로 View의 position이 변경이 되었을 때
//     ( 사용자가 화면을 넘겨서 ),
//     새로운 값을 할당할지 말지를 결정하는 기준이 있는데,
//     ( 예를 들어 , 같은 값이 할당되어 있을 경우 똑같은 값을 굳이 할당해줄필요는 없다. )
//     그것을 결정이나 판단해주는 것이 diffUtil이라고 한다.
    companion object {
        val diffUtil = object : DiffUtil.ItemCallback<Book>() {

            override fun areItemsTheSame(oldItem: Book, newItem: Book): Boolean {
                // TODO oldItem과 newItem이 실제로 Item이 같은지 판단

                return oldItem == newItem
            }

            override fun areContentsTheSame(oldItem: Book, newItem: Book): Boolean {
                // TODO 안에 있는 Contents가 같은지 다른지 판단

                // id의 값이 같은지 다른지를 보고 Contents가 같은지 판단할 것임 ( 이것은 Contents의 구성에 따라 무엇을 기준으로 할지 선택하면 됨 )
                return oldItem.id == newItem.id
            }
        }
    }

}

item_book.xml -> BookAdapter와 함께 RecyclerView에 사용될 레이아웃

<?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"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:padding="16dp">
<!--        RecyclerView에서 이 레이아웃이 사용될 때, 영역이 고정되지 않고 컴포넌트 크기만큼 잡기 위해서-->
<!--        height를 wrap_content로 설정하였다.-->


    <ImageView
        android:id="@+id/coverImageView"
        android:layout_width="100dp"
        android:layout_height="100dp"
        android:background="@drawable/background_gray_stroke_radius_16"
        android:padding="8dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <TextView
        android:id="@+id/titleTextView"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginStart="12dp"
        android:ellipsize="end"
        android:lines="1"
        android:text="안드로이드 마스터하기안드로이드 마스터하기안드로이드 마스터하기안드로이드 마스터하기안드로이드 마스터하기"
        android:textColor="@color/black"
        android:textSize="16sp"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toEndOf="@+id/coverImageView"
        app:layout_constraintTop_toTopOf="parent" />

    <TextView
        android:id="@+id/descriptionTextView"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:layout_marginTop="12dp"
        android:ellipsize="end"
        android:maxLines="3"
        android:textSize="12sp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="@+id/titleTextView"
        app:layout_constraintTop_toBottomOf="@+id/titleTextView" />


</androidx.constraintlayout.widget.ConstraintLayout>

HistoryAdapter.kt -> 검색기록에 대한 RecyclerView에 사용될 Adapter


package com.example.aop_part3_chapter12.adapter

import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.example.aop_part3_chapter12.databinding.ItemHistoryBinding
import com.example.aop_part3_chapter12.model.History

// 액티비티 차원의 리스너를 RecyclerView 내부의 컴포넌트에 달아주기 위해서는먼저 매개변수로 해당 리스너를 받아와야한다.
// 그래서 리스너를 받아오기 위해서 매개변수를 설정하였는데, String타입의 매개변수를 받으며, Return값이 없는(Unit) 람다함수를 변수의 타입으로 지정해주었다.
class HistoryAdapter(val historyDeleteClickedListener:(String)->Unit ) : ListAdapter<History, HistoryAdapter.HistoryItemViewHolder>(diffUtil) {

    inner class HistoryItemViewHolder(private val binding: ItemHistoryBinding) :
        RecyclerView.ViewHolder(binding.root) {

        fun bind(historyModel: History) {
            binding.historyKeywordTextView.text = historyModel.keyword

            // Adapter가 액티비티로부터 파라미터를 통해 받아온 리스너를 해당 버튼에 설정해주고 있다.
            binding.historyKeywordDeleteButton.setOnClickListener {
                historyDeleteClickedListener(historyModel.keyword.orEmpty())
            }


            // 이렇게 레이아웃 자체에 리스너를 주면 RecyclerView의 각 View에 대한 리스너를 만들 수 있다.
//            binding.root.setOnClickListener {
//
//            }

        }
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HistoryItemViewHolder {
        // TODO 이미 만들어진 ViewHolder가 없을 경우에 새로 생성하는 메소드


        return HistoryItemViewHolder(
            ItemHistoryBinding.inflate(
                LayoutInflater.from(parent.context),
                parent,
                false
            )
        )
    }

    override fun onBindViewHolder(holder: HistoryItemViewHolder, position: Int) {
        // TODO 실제로 이 ViewHolder가 View에 그려지게 되었을 때, 데이터를 그려주게 되는( 데이터를 Bind하게 되는 ) 메소드

        holder.bind(currentList[position])
    }


    companion object {
        val diffUtil = object : DiffUtil.ItemCallback<History>() {

            override fun areItemsTheSame(oldItem: History, newItem: History): Boolean {
                // TODO oldItem과 newItem이 실제로 Item이 같은지 판단

                return oldItem == newItem
            }

            override fun areContentsTheSame(oldItem: History, newItem: History): Boolean {
                // TODO 안에 있는 Contents가 같은지 다른지 판단

                return oldItem.keyword == newItem.keyword
            }
        }
    }

}

item_history.xml -> HistoryAdapter와 함께 RecyclerView에 사용될 레이아웃

<?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"
    android:layout_width="match_parent"
    android:layout_height="wrap_content">

    <TextView
        android:id="@+id/historyKeywordTextView"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginStart="16dp"
        android:layout_marginTop="8dp"
        android:layout_marginBottom="8dp"
        android:textColor="@color/black"
        android:textSize="16sp"
        android:layout_marginEnd="8dp"
        app:layout_constraintEnd_toStartOf="@+id/historyKeywordDeleteButton"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <ImageButton
        android:id="@+id/historyKeywordDeleteButton"
        android:layout_width="12dp"
        android:layout_height="12dp"
        android:src="@drawable/ic_baseline_clear_24"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

Open API에서 데이터를 가져와서 분류하기 위한 파일들

BookService.kt -> Retrofit을 사용하여 Open API로부터 데이터를 받아오기 위한 인터페이스

package com.example.aop_part3_chapter12.api


import com.example.aop_part3_chapter12.model.BestSellerDto
import com.example.aop_part3_chapter12.model.SearchBookDto
import retrofit2.Call
import retrofit2.http.GET
import retrofit2.http.Query

interface BookService {

//     요청 url에서 프로토콜과 도메인을 제외하고 뒤에 요청부분을 넣어주면 됨 http://book.interpark.com/api/search.api
//     그리고 항상 Json형태로 받을 것이기 떄문에 "?output=json"를 뒤에 추가해준다.( 이건 지금 사용하는 API에만 해당되는 문법 )
//     즉, 항상 들어가는 고정변수를 @GET의 파라미터에 추가하는 것이고, 변경될 수 있는 사항들을 아래에 @Query에 추가시켜주는 것이다.

    // 결국 GET형식으로 GET의 파라미터와 Query부분을 합쳐서 Url로 API에 Request를 보내고, 그에 대한 Response를 Call의 제네릭타입으로 설정한 모델의 객체로 받는 것이다.
    @GET("/api/search.api?output=json")
    fun getBooksByName(
        @Query("key") apiKey: String,
        @Query("query") keyword: String
    ): Call<SearchBookDto>

    // 베스트셀러의 경우 카테고리번호는 항상 100번으로 일정하므로, categoryId도 Get에 추가시켜준다.
    @GET("/api/bestSeller.api?output=json&categoryId=100")
    fun getBestSellerBooks(
        @Query("key") apiKey: String,
    ): Call<BestSellerDto>


}

Book.kt -> BookRecyclerView에 사용될 Book의 데이터모델

package com.example.aop_part3_chapter12.model

import android.os.Parcelable
import com.google.gson.annotations.SerializedName
import kotlinx.parcelize.Parcelize

// @SerializedName는 파라미터로 Json에서의 key명을 받으며, 그것을 넣을 변수에 세팅한다.

// 직렬화 가능하게 만듬 -> @Parcelize주석 + Parcelable 상속
// 이것을 해줘야 해당 데이터 클래스를 Intent에 담아서 전달할 수 있음
@Parcelize
data class Book(
    @SerializedName("itemID") val id: Long,
    @SerializedName("title") val title: String,
    @SerializedName("description") val description: String,
    @SerializedName("coverSmallUrl") val coverSmallUrl : String

): Parcelable

BestSellerDto.kt -> BestSeller에 대한 API에서 데이터를 받을 데이터 모델

--> 이후 들어온 데이터를 통해 Book 데이터모델로 이루어진 리스트를 만들 것임

  • 데이터를 분류하여 Book으로 전달하기 전에 일단 API로부터 받아놓기 위한 데이터모델
    --> 이런 데이터 모델을 DTO ( Data Transfer Object )라고 함
 package com.example.aop_part3_chapter12.model

import com.google.gson.annotations.SerializedName

// 전체 모델에서 데이터를 꺼내올 수 있게 연결시켜주는 개념을 Dto(data transfer object, 데이터 전송 객체)라고 함
// 즉, 한 Json데이터 전체에 대한 모델이 이것이고, 그 안에 Json데이터 하나하나에 대한 모델이 Book 모델이다.

// Json의 형태를 보면 가장 바깥에 있는 딕셔너리의 데이터가 이 모델에 들어가며,
// 그 딕셔너리에는 item이라는 key가 있는데, 그 키의 value는 Book들에 대한 리스트이다.
// 이 리스트에는 각각의 책에 대한 데이터가 딕셔너리의 형태로 들어가있는데, 이 데이터들이 Book 모델에 들어간다.

// 즉, 이 모델의 개념이 Dto인 이유는, 이 모델을 통해 전체 Json에서 item키의 value(책에 대한 데이터 리스트)를 받아오고,
// 이렇게 가져온 리스트의 각각의 요소들을 Book모델에 넣어주는 것이기 떄문에 해당 BestCellerDto모델은 Dto(데이터 전송 객체)라는 개념이 붙는 것이다.
data class BestSellerDto(
    @SerializedName("title") val title: String,
    @SerializedName("item") val books: List<Book>

)

SearchBookDto.kt -> 책 검색에 대한 API에서 데이터를 받을 데이터 모델

--> 이후 들어온 데이터를 통해 Book 데이터모델로 이루어진 리스트를 만들 것임

  • 데이터를 분류하여 Book으로 전달하기 전에 일단 API로부터 받아놓기 위한 데이터모델
    --> 이런 데이터 모델을 DTO ( Data Transfer Object )라고 함
 package com.example.aop_part3_chapter12.model

import com.google.gson.annotations.SerializedName

data class SearchBookDto(
    @SerializedName("title") val title: String,
    @SerializedName("item") val books: List<Book>
)

Room을 사용한 LocalDB 사용을 위해 만든 파일들

AppDatabase.kt -> Room을 사용하여 LocalDB를 만들고, 이에 접속하기 위한 추상 클래스

package com.example.aop_part3_chapter12

import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.example.aop_part3_chapter12.dao.HistoryDao
import com.example.aop_part3_chapter12.dao.ReviewDao
import com.example.aop_part3_chapter12.model.History
import com.example.aop_part3_chapter12.model.Review

// 만약 Database를 업그레이드 하여 다음 버전으로 갈 경우,
// migration을 통해 변경사항을 알려줘야한다.
@Database(entities = [History::class, Review::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
   abstract fun historyDao(): HistoryDao
   abstract fun reviewDao(): ReviewDao
}

fun getAppDatabase(context: Context): AppDatabase {

   // 1버전에서 2버전으로 가는 Migration을 구현해준 것
   // 버전을 올릴 떄 이렇게 Migration을 반드시 구현해줘야 함
   val migration_1_2 = object :Migration(1,2){
       override fun migrate(database: SupportSQLiteDatabase) {
           //TODO 버전이 올라갈 때ㅡ, DB에 어떤 변경사항이 있을지 직접 Query문으로 작성해줘야 함
           database.execSQL("CREATE TABLE `REVIEW` (`id` INTEGER, `review` TEXT" + "PRIMARY KEY(`id`))")
       }
   }

   // 가져온 DB를 build()하기 전에, addMigrations() 메소드를 사용하여 Migration 세팅
   return Room.databaseBuilder(
       context,
       AppDatabase::class.java,
       "BookSearchDB"
   )
       .addMigrations(migration_1_2)
       .build()
}

HistoryDao -> History DB를 제어하기 위한 메소드들이 들어있는 인터페이스

--> 이렇게 DB를 제어하기 위한 메소드들을 모아놓은 인터페이스를
DAO( Data Access Object )라고 함

package com.example.aop_part3_chapter12.dao

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

@Dao
interface HistoryDao {

    @Query("SELECT * FROM history")
    fun getAll():List<History>

    @Insert
    fun insertHistory(history: History)

    @Query("DELETE FROM history WHERE keyword == :keyword")
    fun delete(keyword: String)

}

ReviewDao -> Review DB를 제어하기 위한 메소드들이 들어있는 인터페이스

--> 이렇게 DB를 제어하기 위한 메소드들을 모아놓은 인터페이스를
DAO( Data Access Object )라고 함

package com.example.aop_part3_chapter12.dao

import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.example.aop_part3_chapter12.model.Review

@Dao
interface ReviewDao {

    @Query("SELECT * FROM review WHERE id == :id")
    fun getOneReview(id: Int): Review

    // 같은 Id의 데이터가 있을 경우 새로 들어온 것으로 대체가 되서 들어가도록 설정
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    fun saveReview(review: Review)

}

History.kt -> HistoryRecyclerView에 사용될, 그리고 History DB의 데이터 구조를 위한 History의 데이터 모델

package com.example.aop_part3_chapter12.model

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

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

Review.kt -> Review DB의 데이터 구조를 위한 데이터 모델

package com.example.aop_part3_chapter12.model

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

@Entity
data class Review(
    @PrimaryKey val id: Int?,
    @ColumnInfo(name = "review") val review: String?

)

앱수준의 build.gradle

plugins {
    id 'com.android.application'
    id 'kotlin-android'
    id 'kotlin-kapt'
    id 'kotlin-parcelize'
}

......

android {
    compileSdk 31

......

    
    // 이 부분을 해줘야 ViewBinding이 활성화되어,  RecyclerView의 어댑터에서 ViewBinding이 됨
    viewBinding{
        enabled = true
    }

}



dependencies {

    implementation 'androidx.core:core-ktx:1.7.0'
    implementation 'androidx.appcompat:appcompat:1.4.1'
    implementation 'com.google.android.material:material:1.5.0'
    implementation 'androidx.constraintlayout:constraintlayout:2.1.3'


    implementation 'com.squareup.retrofit2:retrofit:2.9.0'
    implementation 'com.squareup.retrofit2:converter-gson:2.9.0'

    implementation 'com.github.bumptech.glide:glide:4.13.0'


    implementation "androidx.room:room-runtime:2.4.1"
    kapt "androidx.room:room-compiler:2.4.1"

    testImplementation 'junit:junit:4.+'
    androidTestImplementation 'androidx.test.ext:junit:1.1.3'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
}
  • parcelize(직렬화)를 사용하기 위해 관련 라이브러리가 plugin 되어있음

  • kapt를 통해 라이브러리를 다운받기 위해서 kapt가 plugin 되어있음

  • ViewBinding을 사용하기 위해
    android에 ViewBinding에 대한 enable 값을 true로 설정해주고 있음

  • Retrofit, Glide, Room을 사용하기 위해 각각의 라이브러리들을 다운 받아오고 있음


AndroidManifest.xml

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.aop_part3_chapter12">

    <uses-permission android:name="android.permission.INTERNET"/>

    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:usesCleartextTraffic="true"
        android:theme="@style/Theme.Aop_part3_chapter12">
        <activity
            android:name=".MainActivity"
            android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>

        <activity android:name=".DetailActivity"/>
        
    </application>

</manifest>
profile
ㅎㅎ

0개의 댓글