① Song data class를 아래와 같이 수정한다.
data class Song(
val title : String = "",
val singer : String = "",
var second: Int = 0,
var playTime: Int = 60,
var isPlaying : Boolean = false
)
② MainActivity의 song과 mainPlayerCl에 대한 클릭 이벤트 리스너를 아래와 같이 수정한다.
val song = Song(binding.mainMiniplayerTitleTv.text.toString(), binding.mainMiniplayerSingerTv.text.toString(), 0, 60, false)
...
binding.mainPlayerCl.setOnClickListener {
val intent = Intent(this, SongActivity::class.java)
intent.putExtra("title", song.title)
intent.putExtra("singer",song.singer)
intent.putExtra("second", song.second)
intent.putExtra("playTime", song.playTime)
intent.putExtra("isPlaying", song.isplaying)
activityResultLauncher.launch(intent)
}
③ values > colors.xml에 아래의 두 color를 추가한다.
<color name="song_player">#3f3fff</color>
<color name="song_player_bg">#a8a8a8</color>
④ activity_song.xml의 progress bar는 현재 View 태그로 표현되어 있다. 우리는 이것을 시간에 따라 progress bar가 slide 될 수 있도록 SeekBar 태그로 변경해줄 것이다. activity_song.xml 파일에서 아래의 내용을 주석 처리하거나 삭제한다.
<View
android:id="@+id/song_progressbar_backgroud_view"
android:layout_width="match_parent"
android:layout_height="2dp"
android:layout_marginStart="20dp"
android:layout_marginTop="15dp"
android:layout_marginEnd="20dp"
android:background="@color/gray_color"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/song_like_iv_layout" />
<View
android:id="@+id/song_progressbar_view"
android:layout_width="50dp"
android:layout_height="2dp"
android:layout_marginStart="20dp"
android:background="@color/select_color"
app:layout_constraintBottom_toBottomOf="@+id/song_progressbar_backgroud_view"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@+id/song_progressbar_backgroud_view" />
⑤ (주석 처리 또는 삭제한) 기존 코드가 있던 자리에 아래의 SeekBar 태그를 추가한다.
<SeekBar
android:id="@+id/song_progress_sb"
android:layout_width="match_parent"
android:layout_height="10dp"
android:layout_marginStart="20dp"
android:layout_marginEnd="20dp"
android:layout_marginBottom="20dp"
android:background="@null"
android:paddingStart="0dp"
android:paddingEnd="0dp"
android:progress="0"
android:progressBackgroundTint="@color/song_player_bg"
android:progressTint="@color/song_player"
android:thumb="@color/transparent"
android:max="100000"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/song_like_iv_layout" />
⑥ SongActivity를 아래와 같이 수정한다.
class SongActivity : AppCompatActivity() {
lateinit var binding : ActivitySongBinding
lateinit var song : Song
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivitySongBinding.inflate(layoutInflater)
setContentView(binding.root)
initSong()
setPlayer(song)
var title : String? = null
var singer : String? = null
...
}
private fun initSong() {
if(intent.hasExtra("title") && intent.hasExtra("singer")) {
song = Song(
intent.getStringExtra("title")!!,
intent.getStringExtra("singer")!!,
intent.getIntExtra("second", 0),
intent.getIntExtra("playTime", 0),
intent.getBooleanExtra("isPlaying", false)
)
}
}
private fun setPlayer(song : Song) {
binding.songMusicTitleTv.text = intent.getStringExtra("title")!!
binding.songSingerNameTv.text = intent.getStringExtra("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.songProgressSb.progress = (song.second * 1000 / song.playTime)
setPlayerStatus(song.isPlaying)
}
...
시간을 세는 작업을 무한루프로 구현할 것이기 때문에, 반드시 타이머를 위한 별도의 쓰레드를 생성해주어야 한다. SongActivity에 아래와 같이 새로운 쓰레드를 생성해주자.
class SongActivity : AppCompatActivity() {
lateinit var binding : ActivitySongBinding
lateinit var song : Song
lateinit var timer : Timer
...
private fun initSong() {
...
startTimer()
}
private fun startTimer() {
timer = Timer(song.playTime, song.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()
while(true) {
if(second >= playTime) {
break
}
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++
}
}
}
}
}
}
① timer
② startTimer()
③ inner class Timer
코드를 실행시켜보면, SeekBar가 부드럽게 슬라이딩되고, 1초마다 songStartTimeTv의 text가 갱신되는 것을 확인할 수 있을 것이다. 또한, 일시정지 버튼을 눌러 second와 SeekBar를 멈출 수도 있다.
위의 방식에서 일시정지를 누르면, second 및 SeekBar가 멈추게 된다. 그러나 사실은 멈춰있는 동안에도 while문은 계속해서 돌면서 if문을 확인하고 있는 상태이므로, 비효율적으로 동작하는 중이다. 로그를 찍어보면 더 확실하게 비효율성이 느껴질 것이다.
따라서, isPlaying이 true일 때에만 쓰레드를 실행하고, isPlaying이 false일 때에는 쓰레드가 일시정지되도록 만들어주어야 한다. 이를 위한 가장 간단한 방법은 sleep을 사용하는 것이다.
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()
while(true) {
if(second >= playTime) {
break
}
while (!isPlaying) {
sleep(200) // 0.2초 대기
}
...
대기시간은 너무 짧으면 효율성이 떨어지고, 너무 길면 사용자 불편을 초래한다(재생버튼을 다시 눌러도 한참 뒤에 재생이 시작된다). 0.2초가 짧은 시간이라고 생각할 수도 있지만, 위의 로그 사진을 보면 0.2초를 대기하는 것만으로도 리소스 낭비를 크게 줄일 수 있음을 이해할 수 있을 것이다.
쓰레드를 완전히 멈추기 위해 interrupt를 사용하기로 하자. interrupt는 오류를 발생시켜 쓰레드를 강제로 멈추는 역할을 수행한다. 따라서, 오류가 발생했을 때 특정 명령을 실행하기 위한 try-catch 문을 사용해야 한다. inner class Timer를 아래와 같이 수정한다.
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}")
}
}
}
Activity가 파괴되고 메모리에서 해제될 때 호출되는 onDestroy 콜백 메서드를 추가한 후, timer 스레드에 interrupt를 발생시켜보자.
override fun onDestroy() {
super.onDestroy()
timer.interrupt()
}
이제 Activity를 종료하면, 쓰레드에 인터럽트가 발생하면서 쓰레드가 강제로 중지된다. 그런데 사실은 onDestroy 콜백 메서드는 기본적으로 액티비티와 관련된 모든 자원, 쓰레드를 정리하기 때문에 액티비티가 종료될 때, 쓰레드를 정지시키기 위한 목적으로 interrupt를 사용할 필요는 전혀 없다.
다만, 액티비티는 실행 중인 상태에서, 쓰레드만 종료시키고자 할 때에는 interrupt가 유용할 수 있다. 따라서, interrupt를 어떻게 사용하는지만 제대로 이해할 수 있으면 될 것 같다.
① default 패키지 하위로 SplashActivity를 추가한다.
② AndroidManifest.xml 파일에서 현재는 MainActivity에 있는 intent-filter를 SplashActivity로 옮겨주어야 한다.
<activity
android:name=".SplashActivity"
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=".SongActivity"
android:exported="false" />
<activity
android:name=".MainActivity"
android:exported="false"/>
③ 스플래시 화면에 사용할 이미지를 drawable 디렉토리 하위에 추가한다.
④ activity_splash.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=".SplashActivity">
<ImageView
android:src="@drawable/ic_flo_logo"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>
⑤ SplashActivity에 아래의 내용을 입력한다.
class SplashActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_splash)
val handler = Handler(Looper.getMainLooper())
handler.postDelayed({
startActivity(Intent(this, MainActivity::class.java))
finish()
}, 2000)
}
}
Handler를 사용하여 스플래시 화면을 구성하는 방법은 매우 간단하지만, 효율적인 방법은 아니다. 스플래시 화면은 MainActivity가 로드되는 동안에만 표시되고, MainActivity 로딩이 완료되면, 곧바로 MainActivity로 전환해주어야 한다. Handler는 고정된 time interval 이후에 넘기는 방식이기 때문에 이러한 처리가 어렵다. (물론, SplashActivity를 고의적으로 길게 띄우고 싶다면, Handler를 사용하는 편이 좋다.)
그러므로 MainActivity의 로드에 맞추어 Splash 화면을 구성하기 위해서는 Handler가 아닌, Theme을 사용해야 한다.
① Handler를 이용한 방식에서 추가한 모든 내용을 삭제한다.
② drawable 디렉토리 하위로, 아래와 같은 리소스 파일을 추가한다.
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/white"/>
<item>
<bitmap
android:src="@drawable/ic_flo_logo"
android:gravity="center"/>
</item>
</layer-list>
③ values > themes > themes.xml 파일에 아래의 내용을 추가한다.
<style name="SplashTheme" parent="Theme.AppCompat.NoActionBar">
<item name="android:windowBackground"> @drawable/splash </item>
<item name="android:statusBarColor">@color/transparent </item>
</style>
④ AndroidManifest.xml에서 MainActivity에 대한 activity 태그를 아래와 같이 수정한다.
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/SplashTheme">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
⑤ MainActivity가 onCreate 되는 시점에 MainActivity의 원래 테마가 나타나도록 MainActivity에 아래의 내용을 추가한다.
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setTheme(R.style.Theme_UMCFlo)
...
대부분의 경우, SplashActivity가 제대로 나오지 않을거 같은데, 정상적인 상황이니 너무 당황하지 말자. MainActivity의 onCreate가 호출되는 속도가 워낙 빠르다보니, SplashActivity를 띄우기도 전에 전환되는 것뿐이다. 만약 MainActivity가 더욱 복잡해져서 초기화에 오랜 시간이 걸리게된다면, SplashActivity가 그 시간 동안 잘 나타날 것이다.