본문 바로가기
Android

[Android Oreo+] 백그라운드에서 서비스 실행

by Saerong2 2020. 8. 30.

 

배경

최근 간단한 알람앱을 기획, 구현하면서 안드로이드 공부를 하는 도중에 백그라운드에서 서비스의 실행이 필요했고 정리하고 넘어가야 할 내용이라 글에서 다루고자 합니다. 알람 앱이 갖추어야할 가장 기본적이고 핵심적인 요소는 아래 두 가지 정도 있다고 생각합니다.

 

 

 

1. 사용자가 설정한 정확한 시각에 알람이 울린다

  알람 앱 덕분에 사용자는 일어나고자 하는 시각까지 마음 편히 잠을 잘 수 있습니다. 하지만 다음날 등교, 출근을 하는 여러분이 여느때와 같이 알람을 설정하고 잠을 청했는데 아침에 알람이 울리지 않았다면 그 앱은 사용자로부터 신뢰를 잃고 바로 삭제 될 것임이 분명합니다.

 

 

 

2. 사용자가 비정상적인 방법으로 종료할 수 없어야 한다

   사람마다 다르겠지만 저의 경우는 아침잠이 굉장히 많습니다. 자기 전에 알람을 설정할 때만 해도 "이 시간에 일어나야지!" 다짐하고 또 다짐하지만 아침에 울리는 알람만큼 짜증나는 것이 없고 알람을 끄고 나서 다시 잠 드는 경우도 허다하죠..

그래서 요즘의 알람 앱들은 사용자에게 머리를 쓰도록 하거나 몸을 일으켜야하는 등의 활동을 강제합니다. 이러한 방법까지 사용하면서 다시 자는게 저입니다만...

 

아무튼 알람 앱은 적어도 사용자가 자기 전에 의도하여 설정한 정상적인 루트로 해제가 되어야합니다. 그렇지 않고 프로세스를 강제로 종료한다던가 등의 다른 방법으로 해제 된다면 아침잠이 많은, 의지가 약한 사용자는 다시 잠들어 버리기 쉽고 심하면 본인이 알람을 껐는지도 모르고 알람이 안 울렸다며 앱을 원망할지도 모르죠.

 

 

 

 

 


 

Android Oreo(8.0) 변경사항

 

안드로이드의 버전이 올라가면서, 안드로이드 OS가 탑재된 디바이스를 사용하는 사용자의 입장에서는 배터리 이슈, 보안 이슈 등이 개선되며 좋아졌습니다. 하지만 보다 더 까다로워지고 계속 변화하는 API를 이용하는 개발자의 입장에서는 점점 개발, 유지보수 하기가 쉽지 않은 듯 합니다.

 

<1. 사용자가 설정한 정확한 시각에 알람이 울린다> 에 대해서..

Pending을 할 때 버전에 따라 상이한 API를 써야하며 타이밍에 대한 이슈가 있는 듯합니다.

이 글에서는 다루지 않을 것이며 시간이 되면 다음에 다루도록 하겠습니다.

 

<2. 사용자가 비정상적인 방법으로 종료할 수 없어야 한다> 에 대해서...

알람은 한 번 울리면 비정상적인 방법으로는 서비스가 죽지 않도록 구현해야 했습니다. 디바이스를 강제로 종료시키는 것까지 막는 것은 사용자 옵션으로 만들어야 할 부분이라고 생각되고 적어도 볼륨을 0으로 만든다던가, 태스크를 강제로 종료시킨다던가 등의 방법으로는 알람이 꺼지지 않아야 합니다.

 

즉, 정상적인 방법 이외에는 절대 죽지 않는 서비스의 구현이 필요한 것입니다. 처음에는 단순히 액티비티의 lifecycle에 따라 onDestroy()에서 종료된 액티비티와 서비스를 다시 실행하도록 처리 하면 되지 않을까? 생각했는데 여기서 이번 글에서 다루는 이슈가 등장하게 됩니다.

 

위와 같이 구현해본 결과, 서비스를 onDestroy()에서 다시 호출하였으나 서비스가 다시 시작을 하지 않았고, 로그를 살펴보아 IllegalStateException에 대해서 구글링을 해보니 다음과 같은 안드로이드 8 (Oreo) 변경사항을 확인 할 수 있었습니다.

 

https://developer.android.com/about/versions/oreo/android-8.0-changes?hl=ko

 

Android 8.0 동작 변경 사항  |  Android 개발자  |  Android Developers

System and API behavior changes that can affect apps running on Android 8.0.

developer.android.com

안드로이드 8 (Oreo)의 자세한 변경사항은 위의 공식문서에서 확인할 수 있습니다.

이 글에서는 많은 변경 사항 중에서 백그라운드 제한에 대한 부분만을 참고하며 최종적으로 죽지 않는 서비스는 어떤 방식으로 구현을 해야하는지에 대해서 집중적으로 알아보도록 하겠습니다.

 

 

 

 

 

 

 


 

죽지 않는 서비스

 

백그라운드 서비스는 불필요한 배터리, RAM 등의 리소스를 사용하고 악의적인 프로세스가 포함될 수 있습니다. Android 8.0에서는 이와 관련하여 사용자 경험을 개선하기 위해 백그라운드에서의 실행에 제한을 두었습니다. 그래서 이미 죽은 서비스 (백그라운드)에서 서비스를 다시 실행시키기가 쉽지 않게 되었습니다. 그렇지만 공식문서를 확인해보면 문제해결의 실마리를 제공합니다.

 

Android 8.0 공식문서 내용 중 (1)
Android 8.0 공식문서 내용 중 (2)

위의 공식문서의 내용 중 죽지 않는 서비스의 구현에 필요한 핵심부분을 더 간단히 정리하면 아래와 같습니다.

  • 백그라운드 서비스 생성이 허용되지 않는 상황에서 더 이상 startService( )를 호출 할 수 없다.
    --> 여전히 백그라운드 서비스 생성이 허용되는 상황에서는 startService( )를 호출 할 수 있다.
    --> 허용되는 상황은 어떤 것이 있지?
    --> 알림에서 pendingIntent가 실행되는 것은 몇 분 동안 허용된다.
  • 백그라운드에서도 startForegroundService( )로 서비스를 시작할 수 있다.
    단, startForegroundService( )로 새로 시작하는 서비스 안에서 5초 이내에 startForeground( )를 호출해야한다.


구현

바로 소스코드로 넘어가지 마시고 위의 정리한 내용을 천천히 읽어보시길 추천드립니다.

위의 3가지만 활용하면 생각보다 간단하게 적용할 수 있습니다.

 

 

1. 앱이 비정상적으로 종료되어 Service가 죽을 때,

   서비스의 onDestroy() 에서 AlarmManager의 PendingIntent를 통해 broadcast를 합니다

   (백그라운드에서도 startService( )의 호출이 허용 되는 상황)

 

2. AlarmReceiver의 onReceive에서 위의 pendingIntent를 받아서

  Oreo이상이면 startForegroundService(RestartService)를 호출합니다.

  그렇지 않으면 startService(AlarmService)를 호출합니다.

  (백그라운드에서도 startForegroundService( )로 서비스를 시작할 수 있다)

 

3. RestartService에서 반드시 5초 이내에 startForeGround( )를 호출해야하므로

   임시 notification을 만들어 startForeGround( )를 호출합니다.

   곧 바로, startService(AlarmService) 를 호출하여 살리고자 하는 서비스를 다시 호출합니다.

   stopForeground( )를 호출하여 임시로 만든 notification이 나타나지 않도록 합니다.

   notification은 그저 "5초 이내 StartForeground( )호출" 이라는 룰을 지키기 위한 임시방편입니다.

 

 

 

소스 코드

AlarmService: Service()

class AlarmService : Service() {

    companion object  {
        var service: Intent? = null
        var normalExit: Boolean = false
    }

    override fun onCreate() {
        super.onCreate()
        normalExit = false
    }

    override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
        service = intent

            ...

        return START_STICKY
    }

    override fun onDestroy() {
        super.onDestroy()
            ...
        if (!normalExit){
            setAlarmTimer()
        }
    }

    private fun setAlarmTimer() {
        val calendar = Calendar.getInstance()
        calendar.timeInMillis = System.currentTimeMillis()
        calendar.add(Calendar.SECOND, 1)

        val intent = Intent(this, AlarmTool::class.java)
            ...
        val sender = PendingIntent.getBroadcast(this, 0, intent, 0)
        val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager
        alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, calendar.timeInMillis, sender)
    }
    
}

AlarmService에서 service와 서비스가 정상적으로 끝이났는지 여부인 normalExit 변수를 java의 static에 해당하는 companion object로 갖습니다.

 

service가 어떠한 방법이로든 종료되어 onDestroy()가 실행될 때, 정상적으로 끝나지 않았다면 setAlarmTimer()를 호출하여 알람을 통해 현재시각에서 1초 뒤로 pending을 합니다

 

 

AlarmTool: BroadcastReceiver()

class AlarmTool: BroadcastReceiver() {

    ...

    override fun onReceive(context: Context, intent: Intent) {
        when(intent.action) {
            ACTION_RUN_ALARM -> {
                ...
                if (isAlarmToday(alarmData) && alarmData.active) {
                    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
                        val serviceIntent = Intent(context, RestartAlarmService::class.java)
                            ...
                        context.startForegroundService(serviceIntent)
                    } else {
                        val serviceIntent = Intent(context, AlarmService::class.java)
                            ...
                        context.startService(serviceIntent)
                    }

                    val ringIntent = if ( ... ) {
                    } else {
                        Intent(context, DefaultRingActivity::class.java)
                    }
                    context.startActivity(ringIntent)
                }
            }
                ...
        }

    }

}

Pending한 Intent는 BroadcastReceiver인 AlarmTool에서 받아서 처리합니다. 위에서 설명한대로, Oreo 이상일 경우 startForegroundService()를 통해 서비스를 시작합니다. 그렇지 않은 경우, startService( )를 통해 서비스를 시작합니다.

 

아래 startActivity( )는 서비스와 함께 알람이 울리는 화면에 해당하는 액티비티를 호출하는 것입니다.

 

 

 

RestartAlarmService: Service()

class RestartAlarmService : Service() {

    @Override
    override fun onCreate() {
        super.onCreate()
    }

    @Override
    override fun onDestroy() {
        super.onDestroy()
    }

    @Override
    override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
        val builder = NotificationCompat.Builder(this, "default")
        builder.setSmallIcon(R.mipmap.ic_launcher)
        builder.setContentTitle(null)
        builder.setContentText(null)

        val notificationIntent = Intent(this, MainActivity::class.java)
        val pendingIntent: PendingIntent = PendingIntent.getActivity(this, 0, notificationIntent, 0)
        builder.setContentIntent(pendingIntent)

        val manager: NotificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            manager.createNotificationChannel(
                NotificationChannel(
                    "default",
                    "기본 채널",
                    NotificationManager.IMPORTANCE_NONE
                )
            )
        }

        val notification: Notification = builder.build()
        startForeground(9, notification)

        val serviceIntent = Intent(this, AlarmService::class.java)
            ...

        startService(serviceIntent)
        stopForeground(true)
        stopSelf()

        return START_NOT_STICKY
    }

}

startForegroundService( )로 시작한 RestartAlarmService에서는 반드시 5초 이내에 startForeground()를 호출해야 합니다.
따라서 (의미 없는) notification을 하나 만들어 startForegroun()를 호출하고,
그 다음 진짜 목적인 AlarmService Intent를 만들어 startService()를 호출합니다.

notification이 화면에 나오는 것은 원치 않으므로 곧 바로 stopForeground( )와 stopSelf( )를 호출합니다.

 

 

 

DefaultRingActivity

class DefaultRingActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_default_ring)

        alarm_off.setOnClickListener {
            AlarmRingOff()
        }

        if (supportActionBar != null) {
            Log.d("DEBUGGING LOG", "hide back button")
            supportActionBar!!.setDisplayHomeAsUpEnabled(false)
            supportActionBar!!.setHomeButtonEnabled(false)
        }
    }

    fun AlarmRingOff() {
        stopService(AlarmService.service)
        AlarmService.service = null
        AlarmService.normalExit = true

            ...
    }

    @Override
    override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
        when (keyCode) {
            KeyEvent.KEYCODE_VOLUME_DOWN -> {
                val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
                audioManager.adjustStreamVolume(AudioManager.STREAM_MUSIC,
                    AudioManager.ADJUST_RAISE,/* it may be helpful >_<*/
                    AudioManager.FLAG_REMOVE_SOUND_AND_VIBRATE)
                return true
            }
            KeyEvent.KEYCODE_VOLUME_UP -> {
                val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
                audioManager.adjustStreamVolume(AudioManager.STREAM_MUSIC,
                    AudioManager.ADJUST_RAISE,
                    AudioManager.FLAG_REMOVE_SOUND_AND_VIBRATE)
                return true
            }
            else -> {
                return super.onKeyDown(keyCode, event)
            }
        }
    }

    @Override
    override fun onBackPressed() {
        Log.d("DEBUGGING LOG", "DefaultRingActivity::onBackPressed()")
        //nop
    }

    override fun onUserLeaveHint() {
        super.onUserLeaveHint()
        Log.d("DEBUGGING LOG", "DefaultRingActivity::onUserLeaveHint()")
        if (AlarmService.service != null) {
            val intent = Intent(this, DefaultRingActivity::class.java)
            intent.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
            startActivity(intent)
        }
    }

    override fun onDestroy() {
        super.onDestroy()

        if (!AlarmService.normalExit) {
            if (AlarmService.service != null) {
                stopService(AlarmService.service)
            }
        }
        else {
            if (AlarmService.service != null) {
                stopService(AlarmService.service)
                AlarmService.service = null
            }
        }
    }

}

AlarmService가 실행되며 전환되는 액티비티 중 하나입니다. 아직 구현단계이지만, 비슷한 액티비티가 여러개 나올 것이기 때문에 BaseRingActivity 같은 것을 만들어 상속받는게 좋을 것 같습니다. 죽지 않는 서비스와 직접적으로 관련된 부분은 onDestroy() 의 구현만 보시면 됩니다.

 

onKeyDown()은 글의 초입부에서 나온 3에 해당하는 부분입니다. 키가 눌렸을 때 호출되는 함수를 오버라이딩하여서 위 아래 키 모두 볼륨을 올리도록 했습니다. 일어나는데 도움을 줄 수 있겠죠 ??? ㅎㅎ..

 

onBackPressed()는 마찬가지로 뒤로가기 키를 오버라이딩하여 눌러도 반응이 없도록 하였습니다.

 

onUserLeaveHint()는 홈키를 눌렀을 때 onPause()보다 먼저 호출되는 메소드로 alarmService가 아직 실행중이라면 계속 DefaultRingActivity를 유지하도록 합니다.

 

이 액티비티에서 정상적으로 알람 서비스를 종료하는 방법은 오직 alarm_off 버튼을 누르는 것 뿐입니다.

 

 


마치며

이 외에도 AndroidManifest.xml 에서 Permission, service, receiver, activity등록 등의 설정이나, 재부팅 시에도 서비스가 재등록 될 수 있도록하는 receiver의 구현도 필요합니다.

 

알람 앱의 경우 재부팅 시 서비스 재등록이 아니라 DB에 등록되어 있는 알람들만 다시 재등록 해주면 됩니다.

기타 설정은 아래에 제가 많은 도움을 얻은 블로그를 참고해주세요.

감사합니다.

 

정말 많은 도움을 얻은 글

 

 

 

 

댓글