7주차. FLO 앱 클론 코딩 - RoomDB

변현섭·2023년 11월 6일
0

5th UMC Android Study

목록 보기
7/10
post-thumbnail

✅ 7주차 목표

  • SQL Query, DAO, DBMS의 개념을 이해한다.
  • Database와 SharedPreferences의 차이점을 이해한다.
  • RoomDB의 개념을 이해하고, 이를 활용하여 데이터베이스를 구축할 수 있다.

1. Song 데이터베이스 구축하기

① build.gradle에 아래의 의존성을 추가한다.

  • plugins를 먼저 sync now한 후에 의존성을 추가해야 kapt를 사용할 수 있다.
plugins {
    id("com.android.application")
    id("org.jetbrains.kotlin.android")
    id("kotlin-kapt")
}

dependencies {
    implementation("androidx.room:room-migration:2.6.0")
    implementation("androidx.room:room-runtime:2.6.0")
    kapt("androidx.room:room-compiler:2.6.0")
}
  • 만약 코드를 실행했을 때 아래와 같은 에러가 발생한다면, Kotlin과 Java의 JVM 대상 호환성 버전이 충돌하고 있는 것이다.
  • Module 수준 build.gradle의 compileOptions와 kotlinOptions를 아래와 같이 수정하면 된다.

② Song을 아래와 같이 수정한다.

@Entity(tableName = "SongTable")
data class Song(
    var title: String = "",
    var singer: String = "",
    var second: Int = 0,
    var playTime: Int = 0,
    var isPlaying: Boolean = false,
    var music: String = "",
    var coverImg: Int? = null,
    var isLike: Boolean = false
){
    @PrimaryKey(autoGenerate = true) var id: Int = 0
}

③ SongDao 인터페이스를 추가한다.

@Dao
interface SongDao {
    @Insert
    fun insert(song: Song)

    @Update
    fun update(song: Song)

    @Delete
    fun delete(song: Song)

    @Query("SELECT * FROM SongTable")
    fun getSongs(): List<Song>

    @Query("SELECT * FROM SongTable WHERE id = :id")
    fun getSong(id: Int): Song
}

※ DAO
DAO는 Data Access Object의 약자로, DB의 data에 접근하기 위한 객체를 의미한다. 즉, 직접 DB에 접근하여 데이터를 삽입, 삭제, 조회 등의 DML 기능을 수행한다. (JPA에서의 Repository와 비슷한 개념으로 생각해도 된다.)

④ abstract class로 SongDatabase를 추가한다.

@Database(entities = [Song::class], version = 1)
abstract class SongDatabase: RoomDatabase() {
    abstract fun songDao(): SongDao

    companion object {
        private var instance: SongDatabase? = null

        @Synchronized
        fun getInstance(context: Context): SongDatabase? {
            if (instance == null) {
                synchronized(SongDatabase::class){
                    instance = Room.databaseBuilder(
                        context.applicationContext,
                        SongDatabase::class.java,
                        "song-database" // 다른 데이터 베이스랑 이름이 겹치지 않도록 주의
                    ).allowMainThreadQueries().build() // 편의상 메인 쓰레드에서 처리하게 한다.
                }
            }

            return instance
        }
    }
}
  • @Database(entities = [Song::class], version = 1)
    • @Database: Room 라이브러리에서 데이터베이스 클래스를 정의할 때 사용하는 어노테이션이다.
    • entities: 데이터베이스에서 관리할 엔티티(테이블)를 정의한다.
    • version: 데이터베이스의 버전을 의미한다. 여기서는 초기 버전인 1을 사용하고 있다.
  • abstract class SongDatabase: RoomDatabase()
    • DB를 정의할 때에는 RoomDatabase를 클래스를 상속하는 것이 일반적이다.
    • RoomDatabase()를 상속 받기 위해서는 데이터베이스 클래스가 반드시 추상 클래스로 정의되어야 한다.
    • 데이터베이스 클래스 안에는 DAO 객체의 메서드를 반환하는 추상 메서드가 선언되어 있어야 한다.
  • companion object
    • Kotlin에서 싱글톤 패턴을 구현할 때 사용한다.
    • instance 변수는 데이터베이스의 인스턴스를 저장하는 변수로, 처음 생성될 때는 null로 초기화된다.
  • @Synchronized: 다중 스레드 환경에서 안전한 방식으로 인스턴스를 생성하기 위해 사용하는 어노테이션이다.
  • fun getInstance
    • getInstance: 인스턴스가 없다면 데이터베이스를 생성하여 반환하고, 이미 있는 경우에는 기존 인스턴스를 반환한다.
    • allowMainThreadQueries(): 메인 쓰레드에서 데이터베이스 쿼리를 실행할 수 있게 해주는 메소드이다. 이는 좋은 방법이 아니며, 실제 앱을 개발할 때에는 백그라운드 스레드에서 데이터베이스 작업을 수행해야 한다.

⑤ MainActivity를 아래와 같이 수정한다.

  • music(재생할 MP3 파일명)에 해당하는 mp3 파일이 raw 디렉토리 하위에 존재해야 한다.
  • 해당 노래의 MP3 파일을 구하기 어렵다면 아무 MP3 파일이나 넣고 이름만 잘 설정해주자.
class MainActivity : AppCompatActivity() {
	...
    
    override fun onCreate(savedInstanceState: Bundle?) {
        ...

        inputDummySongs()
        
        activityResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
            if (result.resultCode == RESULT_OK) {
                val data = result.data
                if (data != null) {
                    val message = data.getStringExtra("message")
                    Log.d("message", message!!)
                    Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
                }
            }
        }
        ...
    
    	binding.mainPlayerCl.setOnClickListener {
        	val editor = getSharedPreferences("song", MODE_PRIVATE).edit()
            editor.putInt("songId", song.id)
            editor.apply()

            val intent = Intent(this,SongActivity::class.java)
            activityResultLauncher.launch(intent)
        }
        ...
        
    override fun onStart() {
        super.onStart()

        val sharedPreferences = getSharedPreferences("song", MODE_PRIVATE)
        // songId는 SongActivity에서 onPause가 호출되었을 때
        // 재생 중이던 노래의 id(pk)이다.
        val songId = sharedPreferences.getInt("songId", 0)

		// SongDatabase의 인스턴스를 가져온다.
        val songDB = SongDatabase.getInstance(this)!!

        song = if (songId == 0){ // 재생 중이던 노래가 없었으면
            songDB.songDao().getSong(1)
        } else{ // 재생 중이던 노래가 있었으면
            songDB.songDao().getSong(songId)
        }

        Log.d("song ID", song.id.toString())
        setMiniPlayer(song) // song의 정보로 MiniPlayer를 setting
    }
    ...
    
    private fun inputDummySongs(){
        val songDB = SongDatabase.getInstance(this)!!
        val songs = songDB.songDao().getSongs()

		// songs에 데이터가 이미 존재해 더미 데이터를 삽입할 필요가 없음
        if (songs.isNotEmpty()) return

		// songs에 데이터가 없을 때에는 더미 데이터를 삽입해주어야 함
        songDB.songDao().insert(
            Song(
                "Lilac",
                "아이유 (IU)",
                0,
                200,
                false,
                "music_lilac",
                R.drawable.img_album_exp2,
                false,
            )
        )

        songDB.songDao().insert(
            Song(
                "Flu",
                "아이유 (IU)",
                0,
                200,
                false,
                "music_flu",
                R.drawable.img_album_exp2,
                false,
            )
        )

        songDB.songDao().insert(
            Song(
                "Butter",
                "방탄소년단 (BTS)",
                0,
                190,
                false,
                "music_butter",
                R.drawable.img_album_exp,
                false,
            )
        )

        songDB.songDao().insert(
            Song(
                "Next Level",
                "에스파 (AESPA)",
                0,
                210,
                false,
                "music_next",
                R.drawable.img_album_exp3,
                false,
            )
        )


        songDB.songDao().insert(
            Song(
                "Boy with Luv",
                "music_boy",
                0,
                230,
                false,
                "music_boy",
                R.drawable.img_album_exp4,
                false,
            )
        )


        songDB.songDao().insert(
            Song(
                "BBoom BBoom",
                "모모랜드 (MOMOLAND)",
                0,
                240,
                false,
                "music_bboom",
                R.drawable.img_album_exp5,
                false,
            )
        )

		// DB에 데이터가 잘 들어갔는지 확인
        val songDBData = songDB.songDao().getSongs()
        Log.d("DB data", songDBData.toString())
    }
}

⑥ SongActivity를 아래와 같이 수정한다.

  • song 대신 songs[nowPos]로 대체한다.
class SongActivity : AppCompatActivity() {

    lateinit var binding : ActivitySongBinding
    lateinit var timer : Timer
    private var mediaPlayer : MediaPlayer? = null // 추후에 미디어 플레이어 해제를 위해 nullable로 선언

    val songs = arrayListOf<Song>()
    lateinit var songDB: SongDatabase
    var nowPos = 0

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivitySongBinding.inflate(layoutInflater)
        setContentView(binding.root)

        initPlayList() // SongDB에 있는 모든 노래를 songs에 저장
        
        // 재생 중이던 노래가 있으면 해당 노래의 정보로 화면을 구성하고,
        // 재생 중이던 노래가 없으면 0번 index의 노래의 정보로 화면을 구성한다.
        initSong() 

        binding.songMiniplayerIv.setOnClickListener {
            setPlayerStatus(true)
            startStopService()
        }

        binding.songPauseIv.setOnClickListener {
            setPlayerStatus(false)
            startStopService()
        }
    }

    override fun onBackPressed() {
        val intent = Intent(this, MainActivity::class.java)
        intent.putExtra("message", "뒤로가기 버튼 클릭")

        setResult(RESULT_OK, intent)
        finish()
    }

    override fun onPause() {
        super.onPause()
        songs[nowPos].second = (songs[nowPos].playTime * binding.songProgressSb.progress) / 100000
        songs[nowPos].isPlaying = false
        setPlayerStatus(false)
        val sharedPreferences = getSharedPreferences("song", MODE_PRIVATE)
        val editor = sharedPreferences.edit()
        editor.putInt("songId", songs[nowPos].id)
        editor.apply()
    }

    override fun onDestroy() {
        super.onDestroy()
        timer.interrupt()
        mediaPlayer?.release() // 미디어 플레이어가 갖고 있던 리소스를 해제한다.
        mediaPlayer = null // 미디어 플레이어를 해제한다.
    }

    private fun initPlayList(){
        songDB = SongDatabase.getInstance(this)!!
        songs.addAll(songDB.songDao().getSongs())
    }

    private fun initClickListener(){
        binding.songDownIb.setOnClickListener {
        	val intent = Intent(this, MainActivity::class.java)
            intent.putExtra("message", songs[nowPos].title + "_" + songs[nowPos].singer)
            setResult(RESULT_OK, intent)
            finish()
        }

        binding.songMiniplayerIv.setOnClickListener {
            setPlayerStatus(true)
        }

        binding.songPauseIv.setOnClickListener {
            setPlayerStatus(false)
        }
    }

    private fun initSong() { // intent 방식 사용 안함
        val spf = getSharedPreferences("song", MODE_PRIVATE)
        val songId = spf.getInt("songId", 0)

        nowPos = getPlayingSongPosition(songId)

        Log.d("now Song ID", songs[nowPos].id.toString())

        startTimer()
        setPlayer(songs[nowPos])
    }

	// songId로 position을 얻는 메서드
    private fun getPlayingSongPosition(songId: Int): Int{
        for (i in 0 until songs.size){
            if (songs[i].id == songId){
                return i
            }
        }
        return 0
    }

    private fun setPlayer(song : Song) {
        binding.songMusicTitleTv.text = song.title
        binding.songSingerNameTv.text = song.singer
        binding.songStartTimeTv.text = String.format("%02d:%02d", song.second / 60, song.second % 60)
        binding.songEndTimeTv.text = String.format("%02d:%02d", song.playTime / 60, song.playTime % 60)
        binding.songAlbumIv.setImageResource(song.coverImg!!)
        binding.songProgressSb.progress = (song.second * 1000 / song.playTime)

        val music = resources.getIdentifier(song.music, "raw", this.packageName)
        mediaPlayer = MediaPlayer.create(this, music)
        setPlayerStatus(song.isPlaying)
    }

    fun setPlayerStatus (isPlaying : Boolean){
        songs[nowPos].isPlaying = isPlaying
        timer.isPlaying = isPlaying

        if(isPlaying){ // 재생중
            binding.songMiniplayerIv.visibility = View.GONE
            binding.songPauseIv.visibility = View.VISIBLE
            mediaPlayer?.start()
        } else { // 일시정지
            binding.songMiniplayerIv.visibility = View.VISIBLE
            binding.songPauseIv.visibility = View.GONE
            if(mediaPlayer?.isPlaying == true) { // 재생 중이 아닐 때에 pause를 하면 에러가 나기 때문에 이를 방지
                mediaPlayer?.pause()
            }
        }
    }
    
    private fun startStopService() {
        if (isServiceRunning(ForegroundService::class.java)) {
            Toast.makeText(this, "Foreground Service Stopped", Toast.LENGTH_SHORT).show()
            stopService(Intent(this, ForegroundService::class.java))
        }
        else {
            Toast.makeText(this, "Foreground Service Started", Toast.LENGTH_SHORT).show()
            startService(Intent(this, ForegroundService::class.java))
        }
    }

    private fun isServiceRunning(inputClass : Class<ForegroundService>) : Boolean {
        val manager : ActivityManager = getSystemService(
            Context.ACTIVITY_SERVICE
        ) as ActivityManager

        for (service : ActivityManager.RunningServiceInfo in manager.getRunningServices(Integer.MAX_VALUE)) {
            if (inputClass.name.equals(service.service.className)) {
                return true
            }

        }
        return false
    }

    private fun startTimer() {
        timer = Timer(songs[nowPos].playTime, songs[nowPos].isPlaying)
        timer.start()
    }

    inner class Timer(private val playTime: Int, var isPlaying: Boolean = true) : Thread() {
        private var second : Int = 0
        private var mills : Float = 0F

        override fun run() {
            super.run()

            try {
                while(true) {
                    if(second >= playTime) {
                        break
                    }

                    while (!isPlaying) {
                        sleep(200) // 0.2초 대기
                    }

                    if(isPlaying) {
                        sleep(50)
                        mills += 50

                        runOnUiThread {
                            // binding.songProgressSb.progress = ((mills/playTime*1000) * 100).toInt()
                            binding.songProgressSb.progress = ((mills/playTime) * 100).toInt()
                        }

                        if(mills % 1000 == 0F) { // 1초
                            runOnUiThread {
                                binding.songStartTimeTv.text = String.format("%02d:%02d", second / 60, second % 60)
                            }
                            second++
                        }
                    }
                }
            } catch (e: InterruptedException) {
                Log.d("SongActivity", "Thread Terminates! ${e.message}")
            }
        }
    }
}
    

2. Song 데이터베이스 활용하기

1) 다음 노래로 이동하기

SongActivity에 아래의 내용을 추가한다.

override fun onCreate(savedInstanceState: Bundle?) {
	initPlayList()
    initSong()
    initClickListener()
    ...

private fun initClickListener(){
    ...
    
    binding.songNextIv.setOnClickListener {
        moveSong(+1)
    }
    binding.songPreviousIv.setOnClickListener {
        moveSong(-1)
    }
}

private fun moveSong(direct: Int) { // direct는 +1 또는 -1임
        if (nowPos + direct < 0) {
            Toast.makeText(this,"first song",Toast.LENGTH_SHORT).show()
        }

        else if (nowPos + direct >= songs.size){
            Toast.makeText(this,"last song",Toast.LENGTH_SHORT).show()
        }

        else {
            nowPos += direct
            timer.interrupt()
            startTimer()

            mediaPlayer?.release()
            mediaPlayer = null

            setPlayer(songs[nowPos])
        }
    }
}

코드를 실행시켜보면, SongActivity에서 다음 버튼을 클릭했을 때, 다음 곡으로 잘 넘어가고, Prgoress Bar도 잘 초기화되는 것을 확인할 수 있다. 당연히 노래도 잘 재생된다.

2) 좋아요 및 좋아요 취소 구현하기

① SongDao에 아래의 쿼리를 추가한다.

@Query("UPDATE SongTable SET isLike= :isLike WHERE id = :id")
fun updateIsLikeById(isLike: Boolean,id: Int)

② SongActivity에 아래의 내용을 추가한다.

	override fun onCreate(savedInstanceState: Bundle?) {
    	...
        
		binding.songLikeIv.setOnClickListener {
            setLike(songs[nowPos].isLike)
        }
        ...
        
    private fun setLike(isLike: Boolean){
        songs[nowPos].isLike = !isLike
        songDB.songDao().updateIsLikeById(!isLike,songs[nowPos].id)

        if (!isLike){
            binding.songLikeIv.setImageResource(R.drawable.ic_my_like_on)
        } else{
            binding.songLikeIv.setImageResource(R.drawable.ic_my_like_off)
        }

    }
    
    private fun setPlayer(song : Song) {
        binding.songMusicTitleTv.text = song.title
        binding.songSingerNameTv.text = song.singer
        binding.songStartTimeTv.text = String.format("%02d:%02d", song.second / 60, song.second % 60)
        binding.songEndTimeTv.text = String.format("%02d:%02d", song.playTime / 60, song.playTime % 60)
        binding.songAlbumIv.setImageResource(song.coverImg!!)
        binding.songProgressSb.progress = (song.second * 1000 / song.playTime)

        val music = resources.getIdentifier(song.music, "raw", this.packageName)
        mediaPlayer = MediaPlayer.create(this, music)

        if(song.isLike) {
            binding.songLikeIv.setImageResource(R.drawable.ic_my_like_on)
        }
        else {
            binding.songLikeIv.setImageResource(R.drawable.ic_my_like_off)
        }

        setPlayerStatus(song.isPlaying)
    }
    ...

코드를 실행시켜보면, 좋아요 및 좋아요 취소 기능이 잘 동작할 것이다. 이 좋아요 데이터는 앱을 껐다가 다시 실행하더라도 그대로 유지된다.

3) RecyclerView 구성하기

이전 포스팅에서 LockerFragment > SavedSongFragment에 RecyclerView로 아이템을 나타내고, 일시적으로 삭제하는 방법에 대해 학습하였다. 이번 포스팅에서는 SavedSongFragment의 RecyclerView 아이템에 좋아요 표시한 음악만 보이도록 수정하고, 더 보기 버튼 클릭 시 아이템이 영구적으로 삭제되도록 만들어보겠다.

① LockerAlbumRVAdpater를 아래와 같이 수정한다.

  • 이제는 RVAdapter의 입력 인자가 필요 없어진다.
class LockerAlbumRVAdapter () : RecyclerView.Adapter<LockerAlbumRVAdapter.ViewHolder>() {

    private val switchStatus = SparseBooleanArray()
    private val songs = ArrayList<Song>()

    override fun onCreateViewHolder(
        parent: ViewGroup,
        viewType: Int
    ): LockerAlbumRVAdapter.ViewHolder {
        val binding: ItemLockerAlbumBinding = ItemLockerAlbumBinding
            .inflate(LayoutInflater.from(parent.context), parent, false)

        return ViewHolder(binding)
    }

    override fun onBindViewHolder(holder: LockerAlbumRVAdapter.ViewHolder, position: Int) {
        holder.bind(songs[position])

        holder.binding.itemLockerAlbumMoreIv.setOnClickListener {
            itemClickListener.onRemoveAlbum(songs[position].id) // 좋아요 취소로 업데이트하는 메서드
            removeSong(position) // 현재 화면에서 아이템을 제거
        }

        val switch =  holder.binding.switchRV
        switch.isChecked = switchStatus[position]
        Log.d("SparseBooleanArray", switch.isChecked.toString())
        switch.setOnClickListener {
            if (switch.isChecked) {
                switchStatus.put(position, true)
            }
            else {
                switchStatus.put(position, false)
            }

            notifyItemChanged(position)
        }
    }

    override fun getItemCount(): Int = songs.size

    inner class ViewHolder(val binding: ItemLockerAlbumBinding): RecyclerView.ViewHolder(binding.root){

        fun bind(song : Song){
            binding.itemLockerAlbumTitleTv.text = song.title
            binding.itemLockerAlbumSingerTv.text = song.singer
            binding.itemLockerAlbumCoverImgIv.setImageResource(song.coverImg!!)
        }
    }

    interface OnItemClickListener {
        fun onRemoveAlbum(songId: Int)
    }

    private lateinit var itemClickListener : OnItemClickListener

    fun setItemClickListener(onItemClickListener: OnItemClickListener) {
        this.itemClickListener = onItemClickListener
    }

    @SuppressLint("NotifyDataSetChanged") // 경고 무시 어노테이션
    fun addSongs(songs: ArrayList<Song>) {
        this.songs.clear()
        this.songs.addAll(songs)

        notifyDataSetChanged()
    }

    @SuppressLint("NotifyDataSetChanged")
    private fun removeSong(position: Int){
        songs.removeAt(position)
        notifyDataSetChanged()
    }
}

② SongDao에 아래의 쿼리를 추가한다.

@Query("SELECT * FROM SongTable WHERE isLike= :isLike")
fun getLikedSongs(isLike: Boolean): List<Song>

③ SavedSongFragment를 아래와 같이 수정한다.

class SavedSongFragment : Fragment() {

    lateinit var songDB: SongDatabase
    lateinit var binding : FragmentSavedSongBinding

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

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        binding = FragmentSavedSongBinding.inflate(inflater, container, false)

        songDB = SongDatabase.getInstance(requireContext())!!

        return binding.root

    }

    override fun onStart() {
        super.onStart()
        initRecyclerview()
    }

    private fun initRecyclerview(){
        binding.lockerMusicAlbumRv.layoutManager = LinearLayoutManager(requireActivity())
        val lockerAlbumRVAdapter = LockerAlbumRVAdapter()

        lockerAlbumRVAdapter.setItemClickListener(object : LockerAlbumRVAdapter.OnItemClickListener {

            override fun onRemoveAlbum(songId: Int) {
                songDB.songDao().updateIsLikeById(false, songId)
            }
        })
        binding.lockerMusicAlbumRv.adapter = lockerAlbumRVAdapter
        lockerAlbumRVAdapter.addSongs(songDB.songDao().getLikedSongs(true) as ArrayList<Song>)
    }
}

이제 코드를 실행시켜보면, SavedSongFragment에 내가 좋아요 표시한 음악만 나타나는 것을 확인할 수 있다. 또한, 더 보기 버튼을 눌러 해당 아이템을 목록에서 영구 삭제하는 것도 가능하다.

4) MiniPlayer의 SeekBar를 재생시간과 동기화하기

MainActivity에서도 song이 아닌 songs[nowPos]로 변경해야 한다.

① SongActivity의 onPause 메서드를 아래와 같이 수정한다.

  • SharedPreferences에 재생 중이던 song의 id와 몇초까지 재생되었는지에 대한 정보를 저장한다.
override fun onPause() {
    super.onPause()
    songs[nowPos].second = (songs[nowPos].playTime * binding.songProgressSb.progress) / 100000
    Log.d("second", songs[nowPos].second.toString())
    songs[nowPos].isPlaying = false
    setPlayerStatus(false)
    
    val sharedPreferences = getSharedPreferences("song", MODE_PRIVATE)
    val editor = sharedPreferences.edit()
    editor.putInt("songId", songs[nowPos].id)
    editor.putInt("second", songs[nowPos].second)
    editor.apply()
}

② MainActivity를 아래와 같이 수정하자.

  • 기존의 MainActivity에 있던 onStart를 onResume으로 변경한다.
  • 이는 SongActivity에서 뒤로 가기 버튼을 눌렀을 때, onResume() 콜백 메서드가 호출되기 때문이다.
class MainActivity : AppCompatActivity() {

    lateinit var binding : ActivityMainBinding
    lateinit var activityResultLauncher: ActivityResultLauncher<Intent>

    val songs = arrayListOf<Song>()
    lateinit var songDB: SongDatabase
    var nowPos = 0

    override fun onCreate(savedInstanceState: Bundle?) {
    	...
        
    	inputDummySongs()
        initPlayList()
        initBottomNavigation()
        ...
        
     override fun onResume() {
        super.onResume()

        val sharedPreferences = getSharedPreferences("song", MODE_PRIVATE)
        val songId = sharedPreferences.getInt("songId", 0)

        nowPos = getPlayingSongPosition(songId)
        setMiniPlayer(songs[nowPos])
    }
    ...
    
    private fun getPlayingSongPosition(songId: Int): Int{
        for (i in 0 until songs.size){
            if (songs[i].id == songId){
                return i
            }
        }
        return 0
    }
    
    private fun initPlayList(){
        songDB = SongDatabase.getInstance(this)!!
        songs.addAll(songDB.songDao().getSongs())
    }
    
    private fun setMiniPlayer(song : Song) {
        binding.mainMiniplayerTitleTv.text = song.title
        binding.mainMiniplayerSingerTv.text = song.singer
        Log.d("songInfo", song.toString())
        val sharedPreferences = getSharedPreferences("song", MODE_PRIVATE)
        val second = sharedPreferences.getInt("second", 0)
        Log.d("spfSecond", second.toString())
        binding.mainMiniplayerProgressSb.progress = (second * 100000 / song.playTime)
    }
    ...

이제 코드를 실행시켜보자. 노래를 재생시킨 후 앱을 종료하거나 뒤로가기 버튼을 누르면, MiniPlayer의 SeekBar가 재생한 시간만큼 이동하여 있을 것이다.

3. Album 데이터베이스 구축하기

Album 테이블과 Song 테이블 간의 일대다관계를 매핑한다. 여기서는 같은 Album에 수록된 Song에 대해, 같은 albumIdx 값을 갖게 하는 방식을 사용하여 간단하게 매핑을 구현할 것이다.

① Song data class에 albumIdx 필드를 추가한다.

val albumIdx : Int = 0

② Album data class를 아래와 같이 수정한다.

  • Song data class의 albumIdx 필드 값이 Album 테이블의 기본키가 된다.
  • 따라서, album의 pk에는 autoGenerate 방식을 사용하지 않아야 한다.
@Entity(tableName = "AlbumTable")
data class Album(
    @PrimaryKey(autoGenerate = false) var id: Int = 0, 
    var title: String? = "",
    var singer: String? = "",
    var coverImg: Int? = null
)

③ AlbumDao 인터페이스를 생성한다.

@Dao
interface AlbumDao {
    @Insert
    fun insert(album: Album)

    @Update
    fun update(album: Album)

    @Delete
    fun delete(album: Album)

    @Query("SELECT * FROM AlbumTable") // 테이블의 모든 값을 가져와라
    fun getAlbums(): List<Album>

    @Query("SELECT * FROM AlbumTable WHERE id = :id")
    fun getAlbum(id: Int): Album
}

④ SongDatabase를 아래와 같이 수정한다.

@Database(entities = [Song::class, Album::class], version = 1)
abstract class SongDatabase: RoomDatabase() {
    abstract fun albumDao(): AlbumDao
    abstract fun songDao(): SongDao
    ...
  • 데이터베이스를 수정했을 때, 버전 업데이트 및 기존 데이터를 migration하지 않으면 Runtime Error가 발생한다.
  • 다만, 테스트 환경에서는 앱의 데이터를 지워버리는 것으로 간편하게 해결할 수 있다. (아래 보이는 화면에서 데이터 삭제 버튼을 클릭한다.)

⑤ MainActivity의 inputDummyData 메서드를 아래와 같이 수정한다.

  • albumIdx의 값을 어떻게 할당하든지 상관 없다.
  • 다만, 같은 앨범의 수록곡에는 같은 idx를 할당해야한다.
private fun inputDummySongs(){
    val songDB = SongDatabase.getInstance(this)!!
    val songs = songDB.songDao().getSongs()
    if (songs.isNotEmpty()) return
    songDB.songDao().insert(
        Song(
            "Lilac",
            "아이유 (IU)",
            0,
            200,
            false,
            "music_lilac",
            R.drawable.img_album_exp2,
            false,
            1
        )
    )
    songDB.songDao().insert(
        Song(
            "Flu",
            "아이유 (IU)",
            0,
            200,
            false,
            "music_flu",
            R.drawable.img_album_exp2,
            false,
            1
        )
    )
    songDB.songDao().insert(
        Song(
            "Butter",
            "방탄소년단 (BTS)",
            0,
            190,
            false,
            "music_butter",
            R.drawable.img_album_exp,
            false,
            2
        )
    )
    songDB.songDao().insert(
        Song(
            "Next Level",
            "에스파 (AESPA)",
            0,
            210,
            false,
            "music_next",
            R.drawable.img_album_exp3,
            false,
            3
        )
    )
    songDB.songDao().insert(
        Song(
            "Boy with Luv",
            "music_boy",
            0,
            230,
            false,
            "music_boy",
            R.drawable.img_album_exp4,
            false,
            4
        )
    )
    songDB.songDao().insert(
        Song(
            "BBoom BBoom",
            "모모랜드 (MOMOLAND)",
            0,
            240,
            false,
            "music_bboom",
            R.drawable.img_album_exp5,
            false,
            5
        )
    )
    val songDBData = songDB.songDao().getSongs()
    Log.d("DB data", songDBData.toString())
}

⑥ HomeFragment를 아래와 같이 수정한다.

  • Album 목록을 구성할 데이터를, 데이터베이스에서 가져오는 방식으로 변경한다.
class HomeFragment : Fragment(), CommunicationInterface {

    lateinit var binding : FragmentHomeBinding

    private var albumDatas = ArrayList<Album>()
    private lateinit var songDB: SongDatabase
    ...
    
    override fun onCreateView(
    	...
        
        songDB = SongDatabase.getInstance(requireContext())!!
        albumDatas.addAll(songDB.albumDao().getAlbums())
        Log.d("albumlist", albumDatas.toString())

        val albumRVAdapter = AlbumRVAdapter(albumDatas)
        binding.homeTodayMusicAlbumRv.adapter = albumRVAdapter
        binding.homeTodayMusicAlbumRv.layoutManager = LinearLayoutManager(requireActivity(), LinearLayoutManager.HORIZONTAL, false)
        ...
	
    private fun inputDummyAlbums(){
        val songDB = SongDatabase.getInstance(requireActivity())!!
        val songs = songDB.albumDao().getAlbums()

        if (songs.isNotEmpty()) return

        songDB.albumDao().insert(
            Album(
                1,
                "IU 5th Album 'LILAC'",
                "아이유 (IU)",
                R.drawable.img_album_exp2
            )
        )

        songDB.albumDao().insert(
            Album(
                2,
                "Butter",
                "방탄소년단 (BTS)",
                R.drawable.img_album_exp
            )
        )

        songDB.albumDao().insert(
            Album(
                3,
                "iScreaM Vol.10: Next Level Remixes",
                "에스파 (AESPA)",
                R.drawable.img_album_exp3
            )
        )

        songDB.albumDao().insert(
            Album(
                4,
                "Map of the Soul Persona",
                "뮤직 보이 (Music Boy)",
                R.drawable.img_album_exp4,
            )
        )


        songDB.albumDao().insert(
            Album(
                5,
                "Great!",
                "모모랜드 (MOMOLAND)",
                R.drawable.img_album_exp5
            )
        )

        val songDBData = songDB.albumDao().getAlbums()
        Log.d("DB data", songDBData.toString())
    }
}

⑦ item_album.xml을 아래와 같이 수정한다.

  • 오늘 발매 음악에 표시할 데이터가 조금 변경됨에 따라 UI를 알맞게 수정하였다.
<?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="wrap_content"
    android:layout_height="wrap_content"
    android:layout_marginHorizontal="20dp">

    <androidx.cardview.widget.CardView
        android:id="@+id/item_album_cover_img_cardView"
        android:layout_width="150dp"
        android:layout_height="150dp"
        app:cardCornerRadius="7dp"
        app:cardElevation="0dp"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintStart_toStartOf="parent">
        <ImageView
            android:id="@+id/item_album_cover_img_iv"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:scaleType="fitCenter"
            android:src="@drawable/img_album_exp2"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />
    </androidx.cardview.widget.CardView>

    <ImageView
        android:id="@+id/item_album_play_img_iv"
        android:layout_width="40dp"
        android:layout_height="40dp"
        android:layout_marginEnd="8dp"
        android:layout_marginBottom="5dp"
        android:src="@drawable/widget_black_play"
        app:layout_constraintBottom_toBottomOf="@id/item_album_cover_img_cardView"
        app:layout_constraintEnd_toEndOf="parent" />

    <TextView
        android:id="@+id/item_album_title_tv"
        android:layout_width="150dp"
        android:layout_height="wrap_content"
        android:layout_marginTop="10dp"
        android:text="LILAC"
        android:textColor="@color/black"
        android:textSize="20sp"
        android:ellipsize="end"
        android:maxLines="1"
        app:layout_constraintStart_toStartOf="@id/item_album_cover_img_cardView"
        app:layout_constraintTop_toBottomOf="@id/item_album_cover_img_cardView" />

    <TextView
        android:id="@+id/item_album_singer_tv"
        android:layout_width="150dp"
        android:layout_height="wrap_content"
        android:layout_marginTop="3dp"
        android:text="아이유 (IU)"
        android:textColor="#a8a8a8"
        android:textSize="15sp"
        android:ellipsize="end"
        android:maxLines="1"
        app:layout_constraintStart_toStartOf="@id/item_album_cover_img_cardView"
        app:layout_constraintTop_toBottomOf="@id/item_album_title_tv" />

</androidx.constraintlayout.widget.ConstraintLayout>

4. Custom SnackBar 구현하기

스낵바는 토스트와 비슷하게 화면에 잠깐 보였다가 사라지는 팝업 메시지이다. 하지만 토스트 메시지와 달리 화면 전환이 이뤄지면 메시지가 사라지고, 버튼을 사용할 수도 있다. 더구나 Custom Toast가 불가능해지면서, SnackBar를 사용하는 경우가 더욱 늘었다. 지금부터 SnackBar를 커스터마이징하는 방법에 대해 알아보기로 하자.

① drawable 디렉토리 하위로, custom_snackbar_bg라는 이름의 리소스 파일을 추가한다.

<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
    <item>
        <shape
            android:shape="rectangle">

            <solid
                android:color="@color/colorPrimaryGrey"/>

            <corners
                android:radius="5dp"/>

        </shape>

    </item>

</selector>

② custom_snackbar.xml 파일을 layout 디렉토리 하위에 추가하고, 아래의 내용을 입력한다.

  • 데이터 바인딩을 사용한다.
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="300dp"
        android:layout_height="wrap_content"
        android:paddingStart="15dp"
        android:paddingEnd="15dp"
        android:background="@drawable/custom_snackbar_bg">

        <TextView
            android:id="@+id/custom_snackbar_tv"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="Custom SnackBar!"
            android:layout_marginRight="20dp"
            android:padding="10dp"
            android:textColor="@android:color/white"
            android:textSize="17sp"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <Button
            android:id="@+id/custom_snackbar_btn"
            android:layout_width="70dp"
            android:layout_height="40dp"
            android:text="OK"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

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

③ 앱 전체에서 공통으로 스낵바 메시지를 사용하기 위해 CustomSnackbar 클래스를 생성한다.

class CustomSnackbar(view: View, private val message: String) {

    companion object {

        fun make(view: View, message: String) = CustomSnackbar(view, message)
    }

    private val context = view.context
    private val snackbar = Snackbar.make(view, "", 5000)
    private val snackbarLayout = snackbar.view as Snackbar.SnackbarLayout

    private val inflater = LayoutInflater.from(context)
    private val snackbarBinding: CustomSnackbarBinding
        = DataBindingUtil.inflate(inflater, R.layout.custom_snackbar, null, false)

    init {
        initView()
        initData()
    }

    private fun initView() {
        with(snackbarLayout) {
            removeAllViews()
            setPadding(0, 0, 0, 0)
            setBackgroundColor(ContextCompat.getColor(context, android.R.color.transparent))
            addView(snackbarBinding.root, 0)
        }
    }

    private fun initData() {
        snackbarBinding.customSnackbarTv.text = message
        snackbarBinding.customSnackbarBtn.setOnClickListener {
            // OK 버튼을 클릭했을 때 실행할 동작을 정의할 수 있다.
        }
    }

    fun show() {
        snackbar.show()
    }
}

④ SongActivity의 moveSong 메서드의 Toast 메시지를 Custom SnackBar 메시지로 변경해보자.

private fun moveSong(direct: Int) { // +1 또는 -1
    if (nowPos + direct < 0) {
        CustomSnackbar.make(binding.root, "처음 곡입니다.").show()
        // Toast.makeText(this,"처음 곡입니다.",Toast.LENGTH_SHORT).show()
    }
    else if (nowPos + direct >= songs.size){
        CustomSnackbar.make(binding.root, "마지막 곡입니다").show()
        // Toast.makeText(this,"마지막 곡입니다.",Toast.LENGTH_SHORT).show()
    }

이제 코드를 실행시켜보자. SongActivity에서 좋아요 버튼을 클릭하면 아래와 같은 Custom SnackBar 메시지가 나타날 것이다.

5. Bottom Sheet Dialog 구현하기

구현이 복잡한 관계로 Bottom Sheet Dialog를 띄우는 방법에 대해서만 간략히 소개하고 넘어가기로 한다.

① 먼저 Module 수준의 build.gradle 파일에 아래의 의존성을 추가한다.

implementation("com.google.android.material:material:1.10.0")

② BottomSheetFragment를 생성한다.

③ fragment_bottom_sheet.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"
    android:gravity="center_horizontal"
    tools:context=".BottomSheetFragment"
    android:background="@color/select_color">

    <TextView
        android:id="@+id/bottom_sheet_tv1"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginTop="60dp"
        android:gravity="center"
        android:text="듣기"
        android:background="@color/transparent"
        android:textSize="15sp"
        android:textColor="@color/black"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toStartOf="@+id/bottom_sheet_tv2"
        app:layout_constraintTop_toTopOf="parent"/>

    <ImageView
        android:id="@+id/bottom_sheet_iv1"
        android:layout_width="0dp"
        android:layout_height="50dp"
        android:src="@drawable/btn_miniplayer_play"
        app:layout_constraintBottom_toTopOf="@+id/bottom_sheet_tv1"
        app:layout_constraintEnd_toStartOf="@+id/bottom_sheet_iv2"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <TextView
        android:id="@+id/bottom_sheet_tv2"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginTop="60dp"
        android:background="@color/transparent"
        android:gravity="center"
        android:text="재생목록"
        android:textColor="@color/black"
        android:textSize="15sp"
        app:layout_constraintEnd_toStartOf="@+id/bottom_sheet_tv3"
        app:layout_constraintStart_toEndOf="@+id/bottom_sheet_tv1"
        app:layout_constraintTop_toTopOf="parent" />

    <ImageView
        android:id="@+id/bottom_sheet_iv2"
        android:layout_width="0dp"
        android:layout_height="50dp"
        android:src="@drawable/btn_miniplayer_play"
        app:layout_constraintBottom_toTopOf="@+id/bottom_sheet_tv2"
        app:layout_constraintStart_toEndOf="@+id/bottom_sheet_iv1"
        app:layout_constraintEnd_toStartOf="@+id/bottom_sheet_iv3"
        app:layout_constraintTop_toTopOf="parent" />

    <TextView
        android:id="@+id/bottom_sheet_tv3"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginTop="60dp"
        android:background="@color/transparent"
        android:gravity="center"
        android:text="내 리스트"
        android:textColor="@color/black"
        android:textSize="15sp"
        app:layout_constraintEnd_toStartOf="@+id/bottom_sheet_tv4"
        app:layout_constraintStart_toEndOf="@+id/bottom_sheet_tv2"
        app:layout_constraintTop_toTopOf="parent" />

    <ImageView
        android:id="@+id/bottom_sheet_iv3"
        android:layout_width="0dp"
        android:layout_height="50dp"
        android:src="@drawable/btn_miniplayer_play"
        app:layout_constraintBottom_toTopOf="@+id/bottom_sheet_tv2"
        app:layout_constraintStart_toEndOf="@+id/bottom_sheet_iv2"
        app:layout_constraintEnd_toStartOf="@id/bottom_sheet_iv4"
        app:layout_constraintTop_toTopOf="parent" />

    <TextView
        android:id="@+id/bottom_sheet_tv4"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginTop="60dp"
        android:background="@color/transparent"
        android:gravity="center"
        android:text="삭제"
        android:textColor="@color/black"
        android:textSize="15sp"
        app:layout_constraintStart_toEndOf="@+id/bottom_sheet_tv3"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <ImageView
        android:id="@+id/bottom_sheet_iv4"
        android:layout_width="0dp"
        android:layout_height="50dp"
        android:src="@drawable/btn_miniplayer_play"
        app:layout_constraintBottom_toTopOf="@+id/bottom_sheet_tv2"
        app:layout_constraintStart_toEndOf="@+id/bottom_sheet_iv3"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintEnd_toEndOf="parent"/>

</androidx.constraintlayout.widget.ConstraintLayout>

④ BottomSheetFragment에 아래의 내용을 입력한다.

class BottomSheetFragment : BottomSheetDialogFragment() {

    lateinit var binding : FragmentBottomSheetBinding

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        binding = FragmentBottomSheetBinding.inflate(inflater, container, false)

        return binding.root
    }

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

        binding.bottomSheetIv1.setOnClickListener {
            Toast.makeText(requireActivity(), "듣기 버튼 클릭", Toast.LENGTH_SHORT).show()
        }

        binding.bottomSheetIv2.setOnClickListener {
            Toast.makeText(requireActivity(), "재생목록 버튼 클릭", Toast.LENGTH_SHORT).show()
        }

        binding.bottomSheetIv3.setOnClickListener {
            Toast.makeText(requireActivity(), "내 리스트 버튼 클릭", Toast.LENGTH_SHORT).show()
        }

        binding.bottomSheetIv4.setOnClickListener {
            Toast.makeText(requireActivity(), "삭제 버튼 클릭", Toast.LENGTH_SHORT).show()
        }
    }
}

⑤ LockerFragment의 전체 선택 버튼을 클릭했을 때, Bottom Sheet Dialog가 나타나도록 만들자.

val bottomSheetFragment = BottomSheetFragment()
binding.lockerSelectAllTv.setOnClickListener {
    bottomSheetFragment.show(requireFragmentManager(), "BottomSheetDialog")
}

코드를 실행시켜보면, 아래와 같이 전체 선택 버튼을 클릭했을 때, Bottom Sheet Dialog가 나타나는 것을 확인할 수 있다. 이외의 부가적인 기능들은 직접 구현해보기 바란다.

6. Firebase 활용하기

파이어베이스의 Authentication, Realtime Database, Storage, Cloud Messaging에 대한 사용법은 이미 포스팅한 바 있으니, 아래의 링크를 참조하기 바란다.

>> Authetication
>> Realtime Database
>> Storage
>> Cloud Messaging

profile
LG전자 Connected Service 1 Unit 연구원 변현섭입니다.

0개의 댓글