[Android / Kotlin] 프래그먼트 위에 타이머 기능 구현하기

frogKing·2022년 1월 2일
1

현재 작성중인 포스트로 계속 수정해나갈 예정입니다.



문제 상황

타이머가 그 놈이 그 놈인 줄 알았다..


Jetpack에서 제공하는 Bottom Navigation bar를 이용하여 프래그먼트 중 하나에 타이머 기능을 넣었다. 단순하게 시작, 멈춤, 리셋 기능이 제공되게 구현하였고 타이머 기능은 이제 손 대지 않아도 될 것 같았다. 하지만 다른 프래그먼트를 갔다가 타이머 프래그먼트로 돌아갔을 때 돌아가고 있어야 할 타이머가 리셋이 되어 있었다...!

시도한 방법


왜 리셋이 되나 알아봤더니 다른 프래그먼트로 돌아갈 때 기존의 타이머 프래그먼트가 Destroy 되었다가 다시 타이머 프래그먼트로 돌아갔을 때 onCreate부터 다시 시작해서 그런 것으로 파악되었다.

  1. Bottom Navigation Bar를 지원하는 jetpack에서 프래그먼트를 돌아다닐 때 onDestroy가 실행되지 않게 하는? 메서드를 지원해줄까 싶어 알아보았지만 그런 건 없었다. 오히려 보이지 않는 프래그먼트가 돌아가고 있으면 메모리를 먹기 때문에 효율적인 코드를 지원하는 Android에서 그런 방법은 지양하고 있었다.

  2. Fragment Manager를 사용하면 1번에서 내가 원하던 방식대로 동작이 가능한 것으로 확인되었다. 하지만 Android에서 지양하는 방식으로 굳이 하고 싶지는 않았고 다른 방법이 있을 것이라 생각했다.

  3. 서비스에서 타이머를 돌리고 그 결과를 브로드캐스트 리시버를 이용하여 타이머 프래그먼트로 전송하도록 만드는 것이 최선이라는 결론에 도달했다.


해결 방법


0. 큰 그림


이번 포스트에서 소개할 큰 그림은 다음과 같다. 아직 class diagram 그리는 것은 너무 낯설어서 화살표가 이상해도 양해 부탁드립니다.. 우선 Fragment와 Service 두 개의 큰 기능을 중심으로 세부적인 파일들을 나열하였다. ExtensionHelper는 second -> time으로 바꾸는 매서드를 지원하고 TimerState는 parcelable을 통해 timer의 상태(start, pause, Stop)를 나타낸다. NotificationHelper는 foreground Service로써 타이머를 동작시키기 위해 상단바를 내리면 나타나는 notification 관련 코드를 담았다.

1. 서비스 만들기


1) 서비스 클래스 만들기

TimerService.kt

const val SERVICE_COMMAND = "Command"
const val NOTIFICATION_TEXT = "NotificationText"

class TimerService : Service(), CoroutineScope {

    var serviceState: TimerState = TimerState.INITIALIZED
    private val helper by lazy { NotificationHelper(this) }
    private var currentTime by Delegates.notNull<Int>()
    /*private var startedAtTimestamp: Int = 0
        set(value) {
            currentTime = value
            field = value
        }*/

    private val handler = Handler(Looper.getMainLooper())
    private var runnable: Runnable = object : Runnable {
        override fun run() {
            currentTime++
            broadcastUpdate()
            // Repeat every 10 millisecond
            handler.postDelayed(this, 1000)
        }
    }
    private val job = Job()
    override val coroutineContext: CoroutineContext
        get() = Dispatchers.IO + job

    override fun onBind(intent: Intent): IBinder? = null

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        super.onStartCommand(intent, flags, startId)

        currentTime = 0

        intent?.extras?.run {
            when (getSerializable(SERVICE_COMMAND) as TimerState) {
                TimerState.START -> startTimer()
                TimerState.PAUSE -> pauseTimerService()
                TimerState.STOP -> endTimerService()
                else -> return START_NOT_STICKY
            }
        }
        return START_NOT_STICKY
    }

    override fun onDestroy() {
        super.onDestroy()
        handler.removeCallbacks(runnable)
        job.cancel()
    }

    private fun startTimer() {
        serviceState = TimerState.START

        // startedAtTimestamp = 0

        // publish notification
        startForeground(NotificationHelper.NOTIFICATION_ID, helper.getNotification())
        broadcastUpdate()

        startCoroutineTimer()
    }

    private fun broadcastUpdate() {
        // update notification
        if (serviceState == TimerState.START) {
            // count elapsed time
            // val elapsedTime = (currentTime - startedAtTimestamp)
            val elapsedTime = currentTime

            // send time to update UI
            sendBroadcast(
                Intent(TIMER_ACTION)
                    .putExtra(NOTIFICATION_TEXT, elapsedTime)
            )

            helper.updateNotification(
                getString(R.string.time_is_running, elapsedTime.secondsToTime())
            )
        } else if (serviceState == TimerState.PAUSE) {
            helper.updateNotification(getString(R.string.get_back))
        } else{
            sendBroadcast(
                Intent(TIMER_ACTION)
                    .putExtra(NOTIFICATION_TEXT, 0)
            )
        }
    }

    private fun pauseTimerService() {
        serviceState = TimerState.PAUSE
        handler.removeCallbacks(runnable)
        broadcastUpdate()
    }

    private fun endTimerService() {
        serviceState = TimerState.STOP
        handler.removeCallbacks(runnable)
        broadcastUpdate()
        stopService()
    }

    private fun stopService() {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            stopForeground(true)
        } else {
            stopSelf()
        }
    }

    private fun startCoroutineTimer() {
        launch(coroutineContext) {
            handler.post(runnable)
        }
    }
}

2) parcelable 클래스 만들기

@Parcelize
enum class TimerState : Parcelable {
  INITIALIZED,
  START,
  PAUSE,
  STOP
}

2. Notification 만들기


NotificationHelper.kt

private const val CHANNEL_ID = "StarWarsChannel"
private const val CHANNEL_NAME = "StarWarsChannelName"
private const val CHANNEL_DESCRIPTION = "StarWarsChannelDescription"

class NotificationHelper(private val context: Context) {
    private val notificationManager by lazy {
        context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
    }

    private val contentIntent by lazy {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
            PendingIntent.getActivity(
                context,
                0,
                Intent(context, MainActivity::class.java),
                PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
            )
        } else {
            PendingIntent.getActivity(
                context,
                0,
                Intent(context, MainActivity::class.java),
                PendingIntent.FLAG_UPDATE_CURRENT
            )
        }
    }

    // 1
    private val notificationBuilder: NotificationCompat.Builder by lazy {
        NotificationCompat.Builder(context, CHANNEL_ID)
            // 2
            .setContentTitle(context.getString(R.string.app_name))
            .setSound(null)
            .setContentIntent(contentIntent)
            .setSmallIcon(R.drawable.ic_launcher_foreground)
            .setPriority(NotificationCompat.PRIORITY_HIGH)
            // 3
            .setAutoCancel(true)
    }

    fun getNotification(): Notification {
        // 1
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            notificationManager.createNotificationChannel(createChannel())
        }

        // 2
        return notificationBuilder.build()
    }

    fun updateNotification(notificationText: String? = null) {
        // 1
        notificationText?.let { notificationBuilder.setContentText(it) }
        // 2
        notificationManager.notify(NOTIFICATION_ID, notificationBuilder.build())
    }

    @RequiresApi(Build.VERSION_CODES.O)
    private fun createChannel() =
        // 1
        NotificationChannel(
            CHANNEL_ID,
            CHANNEL_NAME,
            NotificationManager.IMPORTANCE_DEFAULT
        ).apply {

            // 2
            description = CHANNEL_DESCRIPTION
            setSound(null, null)
        }

    companion object {
        const val NOTIFICATION_ID = 99
    }
}



3. 브로드캐스트 리시버로 프래그먼트와 서비스 연동하기


1) 프래그먼트 만들기

TimerFragment.kt

const val TIMER_ACTION = "TimerAction"

class TimerFragment : Fragment() {
    // Foreground receiver
    private val timerReceiver: TimerReceiver by lazy { TimerReceiver() }

    private var _binding: FragmentTimerBinding? = null
    private val binding get() = _binding!!

    private val mainViewModel: MainViewModel by lazy {
        ViewModelProvider(this).get(MainViewModel::class.java)
    }
    lateinit var mainActivity: MainActivity

    // Timer setting
    private var isRunning = false

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

        // timerViewModel = ViewModelProvider(this).get(TimerViewModel::class.java)
        val root: View = binding.root

        binding.startBtn.setOnClickListener {
            startOrPause()
        }
        binding.resetBtn.setOnClickListener {
            reset()
        }
        /*val textView: TextView = binding.textTimer
        timerViewModel.text.observe(viewLifecycleOwner, Observer {
            textView.text = it
        })*/

        return root
    }

    override fun onResume() {
        super.onResume()
        // register foreground service receiver if needed
        if (!mainViewModel.isReceiverRegistered) {
            context?.registerReceiver(timerReceiver, IntentFilter(TIMER_ACTION))
            mainViewModel.isReceiverRegistered = true
        }
    }

    override fun onPause() {
        super.onPause()
        // reset foreground service receiver if it's registered
        if (mainViewModel.isReceiverRegistered) {
            context?.unregisterReceiver(timerReceiver)
            mainViewModel.isReceiverRegistered = false
        }
    }

    override fun onAttach(context: Context) {
        super.onAttach(context)
        mainActivity = context as MainActivity
    }

    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null
    }

    private fun startOrPause(){
        isRunning = !isRunning
        if (isRunning){
            start()
        }else{
            pause()
        }
    }

    private fun start(){
        binding.startBtn.setImageResource(R.drawable.ic_pause)
        sendCommandToForegroundService(TimerState.START)
    }

    private fun pause(){
        binding.startBtn.setImageResource(R.drawable.ic_start)
        sendCommandToForegroundService(TimerState.PAUSE)
    }

    private fun reset(){
        binding.startBtn.setImageResource(R.drawable.ic_start)
        sendCommandToForegroundService(TimerState.STOP)
        isRunning = false
    }

    private fun updateUi(elapsedTime: Int) {
        binding.tvTime.text = elapsedTime.secondsToTime()
    }

    // Foreground Service Methods

    private fun sendCommandToForegroundService(timerState: TimerState) {
        ContextCompat.startForegroundService(mainActivity.applicationContext, getServiceIntent(timerState))
        mainViewModel.isForegroundServiceRunning = timerState != TimerState.STOP
    }

    private fun getServiceIntent(command: TimerState) =
        Intent(mainActivity.applicationContext, TimerService::class.java).apply {
            putExtra(SERVICE_COMMAND, command as Parcelable)
        }

    inner class TimerReceiver : BroadcastReceiver() {

        override fun onReceive(context: Context, intent: Intent) {
            if (intent.action == TIMER_ACTION) updateUi(intent.getIntExtra(NOTIFICATION_TEXT, 0))
        }
    }
}



2) 뷰모델 만들기

class MainViewModel : ViewModel() {

  var isReceiverRegistered: Boolean = false
  var isForegroundServiceRunning: Boolean = false

}

참고
https://www.raywenderlich.com/20123726-android-services-getting-started
profile
내가 이걸 알고 있다고 말할 수 있을까

0개의 댓글