4주차. FLO 앱 클론 코딩 - Timer & Splash Activity 구현

변현섭·2023년 10월 20일
0

5th UMC Android Study

목록 보기
4/10

✅ 4주차 목표

  • SeekBar를 활용하여 진척도를 그림으로 나타낼 수 있다.
  • 쓰레드를 활용하여 타이머를 제작할 수 있다.
  • Splash 화면을 구성하는 방식을 이해하고, 이를 구현할 수 있다.

1. Timer SeekBar 적용하기

① Song data class를 아래와 같이 수정한다.

  • 노래의 시간이 90초라고 가정하고 playTime에 90을 넣는다.
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 태그를 추가한다.

  • 기존 View 태그 삭제로 인해 발생하는 에러를 해결한다. 레이아웃의 제약조건으로 사용되고 있는 기존 View의 id를 SeekBar의 id인 song_progress_sb로 바꿔주면 된다.
<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"  />
  • SeekBar: 사용자가 값을 선택할 수 있는 슬라이더 형식의 위젯이다.
  • background="@null": progress bar의 배경을 설정하지 않는다.
  • progress="0": SeekBar의 현재 진행 상태를 나타낸다. 여기서는 0으로 초기화하고 있다.
  • progressBackgroundTint, progressTint: SeekBar의 배경 색상과 진행 상태의 색상을 설정한다.
  • thumb: SeekBar의 슬라이더의 모양(썸)을 설정한다. 여기서는 "@color/transparent"로 설정되어 있으므로 썸이 표시되지 않는다. (썸이 무엇인지 궁금하다면 이 속성을 삭제해보자.)
  • max: SeekBar의 최대 값을 100,000으로 설정한다. 참고로, 기본 값은 100이다. progress를 십만분율에 맞게 slide 하기 위해 사용한다. (왜 100이 아닌 100,000을 사용했는지는 나중에 설명하기로 한다.)

⑥ 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)
    }
    ...

2. 타이머를 위한 쓰레드 생성하기

1) 쓰레드 생성하기

시간을 세는 작업을 무한루프로 구현할 것이기 때문에, 반드시 타이머를 위한 별도의 쓰레드를 생성해주어야 한다. 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

  • inner class로 정의한 Timer 타입의 변수이다.
  • SongActivity가 create 되는 시점에서 lateinit property인 timer를 초기화해주기 위해 initSong()에서 startTimer()를 호출한다.

② startTimer()

  • song의 총 재생시간, 재생중 여부를 생성자의 입력으로 받는다.
  • Timer 타입 객체를 생성하고, 생성된 timer 객체를 통해 멤버 함수인 run을 호출한다.

③ inner class Timer

  • Thread 클래스 상속: 새로운 스레드를 생성하고 독립적으로 실행할 코드 블록을 정의한다.
  • run 함수 오버라이딩: 해당 스레드가 실행할 코드를 정의한다. run 함수 내에 작성된 코드는 스레드가 실행될 때 순차적으로 실행된다.
  • while (true): 무한 루프를 구성하고 탈출 조건을 second 변수가 playTime보다 크거나 같을 때로 설정하여 song의 총 재생 시간 동안만 스레드가 실행되게 한다.
  • runOnUiThread: Main Thread(UI Thread)가 아닌 다른 스레드에서 UI 컴포넌트에 접근하기 위해 사용한다. isPlaying이 true일 때에만, 50ms마다 progress bar를 slide 한다. 즉, 초당 20번의 slide를 진행함으로써 자연스러운 슬라이딩 효과를 내고 있는 것이다.
  • ((mills/playTime) * 100).toInt(): mills는 ms단위이고, playTime은 초단위이기 때문에, 우리가 익히 사용하는 백분율로 계산하려면 playTime에 1000을 곱해야 한다. 그러나 여기서는 백분율이 아닌 십만분율을 사용하고 있는데, 그 이유는 아래와 같다.
    • 낮은 가독성: ((mills/(playTime * 1000)) * 100).toInt()와 같이 작성하면, 가독성이 떨어지기 때문에 SeekBar의 max 값을 100,000으로 설정했던 것이다.
    • 소수점 이하 버림: 소수점 이하를 버리게 되면서, 정수부가 바뀔 때까지 SeekBar가 갱신되지 않는다. playTime이 1분이라고 가정할 때, 600ms마다 SeekBar가 갱신되는 셈이니, 뻣뻣하게 슬라이딩되는 느낌을 주게 될 것이다.
  • if(mills % 1000 == 0F): 1초가 지날 때마다 seconde를 1 증가시키고, songStartTimeTv의 text를 갱신한다.

코드를 실행시켜보면, SeekBar가 부드럽게 슬라이딩되고, 1초마다 songStartTimeTv의 text가 갱신되는 것을 확인할 수 있을 것이다. 또한, 일시정지 버튼을 눌러 second와 SeekBar를 멈출 수도 있다.

2) 쓰레드 일시정지하기

위의 방식에서 일시정지를 누르면, 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초를 대기하는 것만으로도 리소스 낭비를 크게 줄일 수 있음을 이해할 수 있을 것이다.

3) 쓰레드 정지시키기

쓰레드를 완전히 멈추기 위해 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를 어떻게 사용하는지만 제대로 이해할 수 있으면 될 것 같다.

3. Splash 화면 구성하기

1) Handler 이용하기

① 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"/>
  • <intent-filter>
    • <action android:name="android.intent.action.MAIN" />: 이 액티비티가 메인 액티비티임을 나타낸다.
    • <category android:name="android.intent.category.LAUNCHER" />: 이 액티비티가 시작 액티비티(런처 액티비티)임을 나타낸다.

③ 스플래시 화면에 사용할 이미지를 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(Looper.getMainLooper())
    • Handler 인스턴스를 생성하고, Looper.getMainLooper()를 사용하여 메인 스레드의 Looper를 가져온다.
    • Handler는 주로 메인 스레드에서 실행되어야 하는 작업을 예약하기 위해 사용한다.
  • handler.postDelayed({ ... }, 2000): postDelayed 메서드를 사용하여 람다 블록 내에 작업을 정의하고, 2초 후에 이 작업이 메인 스레드에서 실행된다.

2) Theme 이용하기

Handler를 사용하여 스플래시 화면을 구성하는 방법은 매우 간단하지만, 효율적인 방법은 아니다. 스플래시 화면은 MainActivity가 로드되는 동안에만 표시되고, MainActivity 로딩이 완료되면, 곧바로 MainActivity로 전환해주어야 한다. Handler는 고정된 time interval 이후에 넘기는 방식이기 때문에 이러한 처리가 어렵다. (물론, SplashActivity를 고의적으로 길게 띄우고 싶다면, Handler를 사용하는 편이 좋다.)

그러므로 MainActivity의 로드에 맞추어 Splash 화면을 구성하기 위해서는 Handler가 아닌, Theme을 사용해야 한다.

① Handler를 이용한 방식에서 추가한 모든 내용을 삭제한다.

  • AndroidManifest.xml에서 SplashActivity 제거 후 intent-filter를 MainActivity로 이동하기
  • activity_splash.xml, SplashActivity를 제거한다.

② 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>
  • layer-list의 첫 번째 아이템은 밑바탕이 된다. 즉, 흰색 배경을 설정하고 있는 것이다.
  • 흰색 배경 위에 두 번째 아이템을 레이어링한다. bitmap은 비트맵 이미지를 정의하기 위해 사용된다.

③ 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>
  • NoActionBar: "Theme.AppCompat.NoActionBar" 테마를 상속하여 액션바(상단 바)를 표시하지 않는다.
  • windowBackground: 창(window)의 배경을 스플래시 화면으로 설정한다.

④ 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가 그 시간 동안 잘 나타날 것이다.

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

0개의 댓글