TIL

[TIL] kotlin Youtube Data API 3 - [ 4 ] Room Database 채널 데이터 저장하기

정상호소인 2023. 10. 10. 23:59

kotlin Youtube Data API 3 -  [ 4 ] Room Database 채널 데이터 저장하기

 

이전 글에서 Youtube Data API 3 를 통해 카테고리 별 Channels 데이터를 가져와 Home Fragment에서 Rcv를 통해 아이템을 확인해보는 기능을 만들어 보았는데 Room DB에 대해서 개념에 대해 정리해보기만 하고 예제를 정리하지 않아서 해당 글에서 Room Library를 사용해 채널을 Room DB에 저장하고, DB 목록을 불러오고 삭제하는 CRUD의 CRD 부분을 학습해보자.

 

이전 게시글 ( Channels API 호출하기기와 Rcv 만들기 등등 )
 

[TIL] kotlin Youtube Data API 3 - [ 3 ] 단일 Retrofit으로 여러 EndPoint호출하기

Youtube Data API에서 다른 EndPoint를 사용해서 각각의 API를 호출해보자. 이전 게시글에서 Videos EndPoint를 사용해서 Trending / Top 10 List를 받아오는 코드를 작성해 보았는데 Video EndPoint는 API 호출 비용1 인

junes-daily.tistory.com

 

Room Database Library 개념
 

[TIL] kotlin Room Database 개념

-1- Room Database Room DataBase Library를 사용하는 이유 특정 Database는 서버에서 관리하지 않고 로컬에서 관리하는 것이 필요한데, 이때 Android Studio에 내장되어 있는 SQLite를 사용하여 DB를 관리하는 경우

junes-daily.tistory.com

 

 

 

[ 0 ] Build.gradle // Room Libary 추가 , KSP Plugin 추가,

 

Entity에 넣을 데이터의 타입은타입 변환을 하게될 경우 serialization Plugin 추가해준다

  • Room Entity는 데이터 베이스 Table의 구조를 정의하는 클래스인데, 이때 데이터에 List와 Array와 같이 Room Entity의 ColumnInfo로 지정할 수 없는 타입의 경우, 타입 형변환 로직을 @TypeConverter Annotation으로 정의하여 컴파일 시 타입 변환을 통해 Column을 정상적으로 지정할 수 있도록 해줘야 한다.
  • 해당 글에서는 별도의 Entity 데이터 클래스를 생성을 통해 Entity를 지정한 뒤 Room DB에 데이터를 저장하는 방식으로 진행할 것이다. 추후에 별도로 TypeConverter에 대해서 학습할 예정.
  • 이전 글에서는 KAPT를 사용했으나 이번 글에서는 KSP를 사용할 것이다 기존의 KAPT 관련 build.gradle 코드를 지우고 KSP로 적용해주도록 하자. KSP는 KAPT의 요소가 하나라도 있다면 KSP의 장점인 컴파일 시간 단축의 효과를 볼 수 없다.

 

 

  • Room Library 버전 확인
 

Room을 사용하여 로컬 데이터베이스에 데이터 저장  |  Android 개발자  |  Android Developers

Room 라이브러리를 사용하여 더 쉽게 데이터를 유지하는 방법 알아보기

developer.android.com

 

  • KSP 버전 확인
    • KSP, KAPT의 버전은 현재 Kotlin의 버전과 일치시켜주어야 한다. 두 기능 모두 Kotlin의 주석 처리 및 코드 생성을 위한 Tool이므로 Kotlin 컴파일러와 함께 사용된다. 이때 컴파일러와 Annotation Processing Tool 사이에 버전이 일치하지 않게 되면 오류가 발생할 수 있기 때문이다.
    • 현재 Kotlin의 버전은 Project 수준의 build.gradle의 kotlin.android 뒤에 version 을 통해 확인할 수 있다.
 

kapt에서 KSP로 이전  |  Android 개발자  |  Android Developers

주석 프로세서의 사용을 kapt에서 KSP로 이전합니다.

developer.android.com

 

build.gradle( 프로젝트 )
plugins {
    id("com.android.application") version "8.1.0" apply false
    id("org.jetbrains.kotlin.android") version "1.8.0" apply false // 버전 확인
    id("com.google.android.libraries.mapsplatform.secrets-gradle-plugin") version  "2.0.1" apply false
    id("com.google.devtools.ksp") version "1.8.0-1.0.9" apply false
    id("org.jetbrains.kotlin.plugin.serialization") version "1.8.0" apply false
}

 

 

build.gradle( module )
plugins {
    ...
    
    id("com.google.devtools.ksp")
    id("com.google.android.libraries.mapsplatform.secrets-gradle-plugin")
    ...
}


dependencies {

    ...

    // Room
    implementation("androidx.room:room-runtime:2.5.2")
    implementation("androidx.room:room-ktx:2.5.2")
    ksp("androidx.room:room-compiler:2.5.2")

    // Serialization
    implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0")

}

 

 

 

[ 1 ] Entity, DAO, RoomDB 생성하기

ChEntitiy.kt
@Entity(tableName = "channels")
data class ChEntity(
    @PrimaryKey(autoGenerate = false)
    val id: String,
    @ColumnInfo
    val thumbnailUrl: String,
    @ColumnInfo
    val title: String,
    @ColumnInfo
    val description: String?
)
  • @Entity(tableName = " " ) Annotation을 통해 Entitiy를 정의해주고 Primary Key와 테이블 ColumnInfo도 지정해준다 autoGenerate 의 경우 숫자형의 자료형만 사용이 가능하며 해당 Key의 숫자를 자동으로 1씩 더하며 추가해줄 수 있다. 

 

ChDao.kt
@Dao
interface ChDao {

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertChannel(chEntity:ChEntity)

    @Delete
    suspend fun deleteChannel(chEntity: ChEntity)

    @Query("SELECT * FROM channels")
    fun getChannelDetail(): LiveData<List<ChEntity>>
}
  • Entity를 통해 수행할 메서드를 DAO Interface에서 정의해주며 해당 클래스를 @Dao Annotation을 사용해서 Dao를 정의해준다.
  • 각각의 CRUD 기능
    • @Insert(onConflict = OnConflictStrategy.REPLACE)
      • Room DB에 데이터를 저장하는 것을 정의하는 Annotation으로 중복되는 PrimaryKey를 가진 데이터가 Insert 되었을 경우, 기존의 데이터를 지우고 새로 들어온 데이터로 대체한다.
    • @Delete
      • DB삭제 메소드 정의.
    • @Query("SELECT * FROM channels")
      • SELECT * FROM 테이블 이름 쿼리문을 사용해 Entity로 지정해놓은 테이블을 찾아 선택하며 ChEntity의 형태의 List LiveData 를 반환한다.

 

 

ChRoomDatabase.kt
@Database(
    entities = [ChEntity::class],
    version = 1,
    exportSchema = false
)

abstract class ChRoomDatabase: RoomDatabase() {
    abstract fun chDao():ChDao

    companion object{
        @Volatile
        private var INSTANCE: ChRoomDatabase? = null

        private fun buildDatabase(context: Context): ChRoomDatabase =
            Room.databaseBuilder(
                context.applicationContext,
                ChRoomDatabase::class.java,
                "channel_Detail"
            ).build()

        fun getInstance(context: Context):ChRoomDatabase =
            INSTANCE ?: synchronized(this){
                INSTANCE ?: buildDatabase(context).also { INSTANCE = it }
            }
    }
}
  • @Database Annotation을 통해 Entity, Version을 지정한 Room Database abstarct 클래스를 생성해준다( Room DB 클래스는 abstract class로만 생성해주어야 한다 ) 
  • Room DB 클래스도 객체가 여러개가 생성이 될 경우 메모리에 큰 부담을 줄 수 있으므로 단일 Instance를 생성하여 사용할 수 있도록 한다.
  • buildDatabase() 메서드를 통해 데이터베이스 인스턴스를 빌드하는 로직을 정의해준다.
  • @Volatile 키워드를 사용하여 변수가 항상 주 메모리에서 읽고 쓰임을 보장한다.

 

 

Repository.kt
interface VideoRepository {
    suspend fun search(
        key: String,
        part: String,
        videoCategoryId: String,
        chart: String,
        hl: String,
        maxResults: Int,
        regionCode: String,
        pageToken: String,
    ): Response<SearchResponse>

    suspend fun searchChannel(
        key: String,
        part: String,
        maxResults: Int,
        q: String,
        regionCode: String,
        type: String,
    ): Response<ChannelResponse>

    // 추가된 부분
    suspend fun insertChannels(chEntity: ChEntity)

    suspend fun deleteChannels(chEntity: ChEntity)

    fun getFavoriteChannels(): LiveData<List<ChEntity>>
}

 

RepositoryImpl.kt
class VideoRepositoryImpl(private val chDb: ChRoomDatabase) : VideoRepository {
    override suspend fun search(
        key: String,
        part: String,
        videoCategoryId: String,
        chart: String,
        hl: String,
        maxResults: Int,
        regionCode: String,
        pageToken: String,
    ): Response<SearchResponse> {
        return api.searchYoutube(key, part, videoCategoryId, chart, hl, maxResults, regionCode, pageToken)
    }

    override suspend fun searchChannel(
        key: String,
        part: String,
        maxResults: Int,
        q: String,
        regionCode: String,
        type: String,
    ): Response<ChannelResponse> {
        return api.searchChannel(key, part, maxResults, q, regionCode, type)
    }

    override suspend fun insertChannels(chEntity: ChEntity) {
        chDb.chDao().insertChannel(chEntity)
    }

    override suspend fun deleteChannels(chEntity: ChEntity) {
        chDb.chDao().deleteChannel(chEntity)
    }

    override fun getFavoriteChannels(): LiveData<List<ChEntity>> {
        return chDb.chDao().getChannelDetail()
    }
}
  • Data 로직이 새로 추가되었으므로 Repository에 함수를 정의해주도록 한다.
  • Repository의 프로퍼티에 ChRoomData를 넣어 의존성 주입을 통해 외부에서 DB객체를 제공 받고, 해당 객체를 사용하여 DB 작업을 수행할 수 있도록 한다. 
  • Repository Impl은 chRoomDatabase를 프로퍼티로 받아  Repository에서 추가된 함수를 오버라이드해서 DB CRUD 작업을 수행하는 로직을 정의해준다.

 

 

MainActivity.kt - Room DB 객체 생성 및 RepositoryImpl 객체에 Room DB파라미터 추가.
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = MainActivityBinding.inflate(layoutInflater)
        setContentView(binding.root)
        
        ...
        
        val database = ChRoomDatabase.getInstance(this)
        val searchRepository = VideoRepositoryImpl(database)
        
        ...
        
    }
}
  • 기존에 생성해두었던 VideoRepository 객체에 Room DB 의존성이 추가되었으므로  Room DB 객체를 생성해서 기존 VideoRepositoryImpl 객체의 파라미터로 지정해준다.

 

 

 

HomeChannelRcvAdapter.kt - 클릭 리스너 추가
class HomeChannelRcvAdapter(private val itemClick: ChannelItemClick) :
    ListAdapter<ChItem, HomeChannelRcvHolder>(DifferCallback.channelDifferCallback) {


    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HomeChannelRcvHolder {
        return HomeChannelRcvHolder(
            HomeRcvItemChannelBinding.inflate(LayoutInflater.from(parent.context), parent, false)
        )
    }

    override fun onBindViewHolder(holder: HomeChannelRcvHolder, position: Int) {
    
        // 클릭 이벤트 추가
        val chItem = currentList[position]
        holder.itemView.setOnClickListener {
            itemClick?.onClick(ChEntity( chItem.snippet.channelId,chItem.snippet.thumbnails.medium.url,chItem.snippet.title,chItem.snippet.description))
        }
        val category = currentList[position]
        val snippet = category.snippet
        val thumbnails = snippet.thumbnails

        holder.apply {
            channelThumbnails.load(thumbnails.medium.url)
            channelTitle.text = snippet.title
        }
    }

}

class HomeChannelRcvHolder(binding: HomeRcvItemChannelBinding) :
    RecyclerView.ViewHolder(binding.root) {
    val channelThumbnails: ImageView = binding.homeRcvImgChannelThumbnail
    val channelTitle: TextView = binding.homeRcvTvChannelTitle
}
  • Channel 어뎁터에 Click 이벤트 프로퍼티를 추가해주고 클릭 아이템을 지정해준다.

 

 

SearchViewModel.kt - Coroutin Scope,Context ( Dispatcher ) 정의
class SearchViewModel(
    private val searchRepository: VideoRepository,
    private val savedStateHandle: SavedStateHandle,
) : ViewModel() {

    ...
    
    
    ...
    

    fun saveFavoriteCh(chEntity: ChEntity) = viewModelScope.launch(Dispatchers.IO) {
        searchRepository.insertChannels(chEntity)
    }

    fun deleteFavoriteCh(chEntity: ChEntity) = viewModelScope.launch(Dispatchers.IO) {
        searchRepository.deleteChannels(chEntity)
    }

    val getFavoriteCh: LiveData<List<ChEntity>> = searchRepository.getFavoriteChannels()
}

 

 

HomeFragment.kt - Rcv Channel Item 클릭 이벤트 정의
        homeChannelRcvAdapter = HomeChannelRcvAdapter(object  : ChannelItemClick{
            override fun onClick(item: ChEntity) {
                searchViewModel.saveFavoriteCh(item)
            }
        })
  • 추가로 토스트 메시지나 스낵바를 띄워서 좋아요가 눌렸다는 정보를 사용자가 확인할 수 있도록 하면 좋을 것 같다. Room의 정상 동작 자체가 목적이므로 채널 Rcv List에서 아이템 클릭 시 바로 마이페이지에서 확인할 수 있도록 하자

 

 

MyPageFavoriteChannelItemAdapter.kt // 어쩌다보니까 클래스명이 엄청 길어졌다
class MypageFavoriteChannelItemAdapter(private val mContext: Context, private val mItems: List<ChEntity>) :
    RecyclerView.Adapter<MypageFavoriteChannelItemAdapter.Holder>() {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
        val binding = MypageItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)
        return Holder(binding)
    }

    override fun onBindViewHolder(holder: Holder, position: Int) {

        Glide.with(mContext)
            .load(mItems[position].thumbnailUrl)
            .into(holder.itemImageView)
        holder.itemTitle.setText(mItems[position].title)
    }

    override fun getItemId(position: Int): Long {
        return position.toLong()
    }

    override fun getItemCount(): Int {
        return mItems.size
    }

    inner class Holder(binding: MypageItemBinding) : RecyclerView.ViewHolder(binding.root) {
        val itemImageView = binding.mypageItemMadiaImg
        val itemTitle = binding.mypageItemMediaTitle
    }
}

 

MyPageFragment.kt
class MyPageFragent:Fragment() {
    
    ... 
    private lateinit var myPageAdapter: MyPageFavoriteChannelItemAdapter
    ...

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)


        var currentChList: MutableList<ChEntity> = mutableListOf()
        searchViewModel.getFavoriteCh.observe(viewLifecycleOwner) {
            if (currentChList.isNullOrEmpty()) {
                currentChList.addAll(it)
            } else {
                currentChList.removeAll(currentChList)
                currentChList.addAll(it)
            }

            myPageAdapter.notifyDataSetChanged()

        }

        myPageAdapter = MyPageFavoriteChannelItemAdapter(requireContext(), currentChList)
        var chRcv = binding.mypageFrindRecyclerview
        chRcv.adapter = myPageAdapter


    }
  • getFavoriteCh를 통해 현재 좋아요 한 채널 목록을 불러와 currentChList에 담아주었다. 새로운 좋아요가 추가되면 새로 List를 받아오도록 했는데 좋은 코드는 아닌 것 같다... DifferCallback을 사용하는 Adapter를 구현하게 되면 submitList를 통해 변경된 데이터만 적용할 수 있다.
  • MyPage의 좋아요 한 채널 목록 Adapter에서 클릭 리스너를 정의해서 Insert로 데이터 넣은 것과 마찬가지로 MyPage에서 searchViewModel.deleteFavoriteCh를 통해 해당 Position의 DB와 일치하는 ID의 DB를 삭제할 수 있다.

 


 

[오늘 복습한 내용]

1. 기존의 Room DB Library의 개념에 대해서만 학습해보았었는데 직접 적용해보면서 추가로 학습하였다.

 

2. RecyclerView는 조금 익숙해지려고 하면 갑자기 기출변형을 내서 어려워진다


[오류,에러 등등]

1. Entity의 ColumnInfo로 지정해줄 데이터의 타입이 클래스 타입일 경우 어떻게 String으로 형 변환을 하면 좋을지 고민하다가 별도로 Entity 클래스를 생성하는 방법으로 일단 해결하긴 했다. 추후에 클래스 타입의 형변환을 통해 Entity의 Column으로 지정해주는 방식으로 재시도 해봐야겠다.

  • 키워드 embadded? TypeConverter?

 

ID 타입의 id와 Snipper 타입의 snippet을 어떻게 형변환 해야할까?
data class ChItem(
    @SerializedName("etag")
    val etag: String,
    @SerializedName("id")
    val id: Id,
    @SerializedName("kind")
    val kind: String,
    @SerializedName("snippet")
    val snippet: Snippet
) : Serializable

 

2. 기존의 ListAdapter로 Rcv를 만드는 방식 말고 RecyclerView.Adpater로 Rcv를 생성한 상태에서 해당 Rcv의 Adapter의 프로퍼티로 ChEntitiy를 받는 Rcv에서 초기 Item을 불러온 뒤, 새로운 좋아요 채널이 생기면 Rcv에 새새로 생긴 좋아요 채널만 add해줘야 하는데 그 부분을 적용하기가 매우 어려웠다.

 

문제코드 1 - 좋아요 채널 추가 후 마이페지이로 들어오면 기존 position [ 1 ] [ 2 ] 가 있던 상황에서 새로운 아이템을 추가하면 리스트가 [ 1 ] [ 2 ] [ 1 ] [ 2 ] [ 3 ] 이렇게 나온다.

        var currentChList: MutableList<ChEntity> = mutableListOf()
        searchViewModel.getFavoriteCh.observe(viewLifecycleOwner) {
           
            currentChList.addAll(it)
     
            myPageAdapter.notifyDataSetChanged()

        }
        myPageAdapter = MypageFriendItemAdapter(requireContext(), currentChList)
        var chRcv = binding.mypageFrindRecyclerview
        chRcv.adapter = myPageAdapter

 

문제 코드 2 - 마지막으로 추가된 좋아요 아이템만 리스트에 추가해주는데, 좋아요를 여러개 클릭 후 마이페이지로 들어오면 마지막에 추가된 아이템만 추가된 것으로 확인됨 

        var currentChList: MutableList<ChEntity> = mutableListOf()
        searchViewModel.getFavoriteCh.observe(viewLifecycleOwner) {
            if (currentChList.isNullOrEmpty()) {
                currentChList.addAll(it)
            } else {
                currentChList.add(it.last())
            }

            myPageAdapter.notifyDataSetChanged()

        }
        myPageAdapter = MypageFriendItemAdapter(requireContext(), currentChList)
        var chRcv = binding.mypageFrindRecyclerview
        chRcv.adapter = myPageAdapter

 

해결 코드 -기존 리스트를 지우고 다시 추가하는 방식으로 임시 처리했다.

  • DifferCallback을 사용하는 ListAdapter로 생성할 경우 submitList 로 편하게 할 수 있었는데, DifferCallback을 사용하지 않는 RecyclerView.Adapter는 submitList를 사용할 수 없어서 불편했다
        var currentChList: MutableList<ChEntity> = mutableListOf()
        searchViewModel.getFavoriteCh.observe(viewLifecycleOwner) {
            if (currentChList.isNullOrEmpty()) {
                currentChList.addAll(it)
            } else {
                currentChList.removeAll(currentChList)
                currentChList.addAll(it)
            }

            myPageAdapter.notifyDataSetChanged()

        }
        myPageAdapter = MypageFriendItemAdapter(requireContext(), currentChList)
        var chRcv = binding.mypageFrindRecyclerview
        chRcv.adapter = myPageAdapter

 


[느낀 점]

1. 한 가지 기능이라도 여러가지 접근 방법을 알아두면 좋을 것 같다.

 

2. 막히는 부분에서 시간이 많이 소요되면 뒤로 돌아가서 다른 방법으로 시도해보면 좋을 것 같다.

 

 

3. 어렵다

 

 


[Reference]