카테고리 없음

[Android/Kotlin] Service (2) Foreground Service 구현

란서 2022. 10. 18. 10:57

1. Foreground Service와 LOLAlarm APP

Service (1) 에서는 서비스의 개념 및 수명주기에 대해 학습하였다. 
서비스에는 3가지 종류의 서비스가 존재하고, 그 중 Foreground Service는
서비스가 현재 실행되고 있음을 사용자에게 알려줄 필요가 있을 때 사용한다.


위의 이미지 처럼 '음악 재생 앱 : 뮤직 플레이어' 또는 버'스 시간 앱 : 버스 도착 알림' 등 사용자가
앱과 직접 상호작용하고 있지 않더라도 상단 작업 표시줄에  알림을 이용하여
사용자서비스실행 여부를 인지하고 기능을 제어할 수 있다.


나 또한 Foreground Service를 더 잘 이해하고 응용하기 위해 Foreground Service를 사용해 다음과 같은 앱을 구현하였다.

● LOLAlarm APP 개요

LOLAlarm 앱은 온라인 게임 리그 오브 레전드에서 제공하는 RIOT API를 이용해

앱에 등록된 플레이어가 게임에 접속했을 경우 해당 플레이어가 게임에 접속했음을 알리는 어플리케이션을 구상하였다.

 

사용자가 다른 앱을 사용하고 있더라도  플레이어가 게임을 플레이할 때를 캐치하여 사용자에게 알려야 하는 작업이 필요하다. 따라서 오랜시간 동안 백그라운드 작업을 수행할 '서비스'를 만들어 이를 구현하였다.

 

또한 해당 서비스가 시스템이 리소스 확보를 위해 종료시키는 경우를 방지하고,
다른 앱으로 전환한 사용자에게 현재 'LOLAlarm 서비스'가 실행 중임을 알리고 서비스 기능을 제어할 수 있도록
Foreground Service구현하였다.

 

앱의 기능을 정리하면 다음과 같다.

 

  1. 리그 오브 레전드의 플레이어를 검색
  2. 검색한 플레이어를 어플리케이션에 저장
  3. 각 저장된 플레이어들에 대한 게임 접속 알림 서비스 실행
  4. 플레이어가 게임에 접속했다면, 이를 사용자에게 알림
  5. 해당 플레이어의 접속 이력이 저장됨

 

 



● LOLAlarm APP 구현

이번 포스팅에서는 LOLAlarm을 개발할 때 Service를 어떻게 구현했는지에 중점을 두어 기록하고자 한다.

 

알람 서비스 실행 UI


위의 이미지는 각 저장된 플레이어들에 대해 게임 접속 알림 서비스를 실행할 수 있는 UI의 모습이다.

플레이어 옆에 '시작' 버튼을 누르면 서비스가 시작된다.

 

  • '시작' 버튼을 클릭할 때 서비스를 실행하는 소스 코드는 다음과 같다.
    //AlarmFragment
    private fun startMonitorService(summonerId: String) {
        val intent = Intent(this.context, MonitorService::class.java).also {
            it.action = ServiceIntentAction.START.name
            it.putExtra(requireContext().getString(R.string.extra_key_summoner_id), summonerId)
        }
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            requireContext().startForegroundService(intent)
        } else{
            requireContext().startService(intent)
        }
    }

서비스의 이름을 명시(MonitorService)하여 만든 intent에 action과 extra를 설정하여 startService() 인자로 넘긴다.

-action의 경우 custom action (:String) 을 만들어 onStartCommand()에서 이를 이용한 조건문을 구현.

-extra의 경우 플레이어의 ID (:String)를 담아 onStartCommand()에서 이를 이용해
다수의 (started) 서비스실행하고 중지할 때 각 플레이어의 서비스를 선별할 수 있는 작업을 수행합니다.
-Android Version이 OREO 이상인 기기에서는 ForegroundService를 사용하려고 할 때
startForegroundService(intent) 호출 (해당 메서드 호출 시, 시스템이 foreground Service이 사용될 것을 숙지(?))


서비스를 실행하게 되면 버튼은 '시작'에서 '중지'로 변하게 되고 UI는 다음과 같은 모습을 띈다.

알람 서비스 실행 UI

 

  • 사용자가 '중지' 버튼을 누를 때 해당 플레이어에 대한 서비스를 중지하게 되고 소스 코드는 다음과 같다.
   //AlarmFragment
   private fun stopMonitorService(summonerId: String) {
        val intent = Intent(this.context, MonitorService::class.java).also {
            it.action = ServiceIntentAction.STOP.name
            it.putExtra(getString(R.string.extra_key_summoner_id), summonerId)
        }

        requireContext().startService(intent)
    }

 

서비스 실행과 마찬가지로 MonitorService 클래스를 명시한 Intent를 사용한다. 

여기서 중요한 점은 중지버튼을 눌렀을 때 stopService()를 이용하여 서비스를 중지하는 것이 아니라 starService() 실행.
intent에 담긴 action과 extra값을 onStartCommand() 에서 적절하게 사용하여 서비스를 중지하거나 소멸시킨다.

 


 

다음은 Monitor 서비스의 소스 코드와 서비스가 시작 및 중지되는 과정을 살펴보자.

@AndroidEntryPoint
class MonitorService : LifecycleService() {

    private val TAG = "MONITOR_SERVICE"
    private lateinit var hashLoopFlag: HashMap<String, Boolean>
    private var count: Int = 0

    @Inject
    lateinit var monitorDataSource: MonitorDataSource
	
    ...
}

 

 

 

MonitorService는 LifecycleService() 클래스확장하였다.
이유는 서비스수명주기를 따르는 lifecycleScope를 이용하여 백그라운드 작업처리할 것이기 때문.

  • 프로퍼티 간략 설명

    - hashLoopFlag : HashMap<playerId: String, flag: Boolean> = 각 서비스를 실행하거나 중지.
    - count : Int = 서비스가 추가될 때 +1 if(0==count) startForeground(),
                           서비스를 종료될 때 -1. if(0==count)  stopSelf()
    - monitorDataSource : MonitorDataSource = network 작업을 수행하기 위해 필요한 DataSource.

 

- onCreate()

 

서비스가 첫 시작시 서비스를 생성하면서 onCreate() 를 호출.

    override fun onCreate() {
        super.onCreate()
        log(TAG, "onCreate()", LogType.I)
        hashLoopFlag = hashMapOf()
    }

해당 메서드에서 hashLoopFlag 변수를 초기화.

 

 

-onStartCommand()

 

다른 앱의 구성요소에서 startService() 메서드를 호출하면 서비스는 onStartCommand() 호출

 @CallSuper
    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        super.onStartCommand(intent, flags, startId)
        log(
            TAG,
            "onStarteCommand(), intent.Action : ${intent?.action} ,startId : ${startId}",
            LogType.I
        )
        if (intent != null) {
            when (intent.action) {
                ServiceIntentAction.START.name -> {
                    if(count==0) startForeground(1, notifyForeground())
                    refreshLastStartId()
                    startMonitor(intent)
                }
                ServiceIntentAction.STOP.name -> {
                    count--
                    if (count == 0) {
                        log(TAG, "stopSelfResult : ${startId}",LogType.I)
                        stopSelfResult(startId)
                        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
                            stopForeground(STOP_FOREGROUND_REMOVE)
                        }

                    } else {
                        stopMonitor(intent)
                    }

                }
            }
        }
        return START_NOT_STICKY
    }

 

onStartCommand() 메서드는 intent를 전달받는다.

intent에 담긴 action의 값은 START, STOP 두개로 나뉘어지고, 각 분기별로 서비스를 실행할지 중지할지 결정할 수 있다.

  • START

    START일 경우, 여러 작업을 수행한다.

    1. 플레이어의 게임 접속 유무를 알아내기 위해
    RIOT API 서버에 정보를 요청하고 결과값을 받아오는 네트워크 작업을 처리하는 것이다.
private fun startMonitor(intent: Intent) {
        val summonerId =
            intent.extras?.getString(applicationContext.getString(R.string.extra_key_summoner_id))
                ?: return

        hashLoopFlag[summonerId] = true

        lifecycleScope.launch(Dispatchers.IO) {

            while (hashLoopFlag[summonerId]!!) {
                log(TAG, "startMonitor summonerId : ${summonerId}", LogType.I)
                monitorDataSource.moniterTargetPlayer(summonerId) { spectator ->
                    log(TAG, "startMonitor : ${spectator}", LogType.I)
                    if (spectator != null) {
                        val date = DateTime.getNowDate()
                        insertGameInfo(spectator, date, summonerId)
                        notifyForUser(spectator.participants.filter { participant -> participant.summonerId == summonerId }
                            .first().summonerName, date)
                    }
                }
                delay(50_000L)
            }

            this.cancel()
        }
    }

-  lifecycleScope.launch(Dispatchers.IO) {} = 백그라운드에서 네트워크 작업.
  요청에 대해 성공적인 결과를 받으면 사용자에게 플레이어의 접속 사실을 알림 (=notifyForUser())

- delay(long) = while 루프 속에서 delay를 통해 원하는 시간마다 백그라운드 작업을 수행하게 함.

- 만약 백그라운드 작업이 끝난다면 루프를 벗어나게 되고 this.cancel() 을 통해 해당 corotinue 종료.


 

  • STOP

    STOP일 경우 서비스는 멤버변수 count를 -1 감소시키고,
    count값이 0일 경우 stopSelf()를 호출하여 서비스를 소멸시킨다.


    count값이 0 아닐 경우에는 서비스를 종료하지 않고, 다음과 같은 메서드를 호출한다.
    private fun stopMonitor(intent: Intent) {
        val summonerId =
            intent.extras?.getString(applicationContext.getString(R.string.extra_key_summoner_id))
                ?: return

        log(TAG, "stopMonitor() summonerId : ${summonerId}", LogType.I)

        hashLoopFlag[summonerId] = false
    }

 

hashLoopFlag[summonerId]value 값 false로 설정, 현재 실행중 coroutine while 루프가 멈추도록 한다. 

 

 

 

 

 


Start ForegroundService

포그라운드 서비스를 시작하려면 서비스가 생성되어 onCreate()를 호출한 다음
onStartCommand()호출할 때 Foreground Service를 실행하는 것이 좋다.

ForegroundService를 실행하려면 다음과 같은 메서드를 사용한다.
startForeground(id :Int, notification:Notification) 

 

             //onStartCommand()
             ServiceIntentAction.START.name -> {
                    if(count==0) startForeground(startId, notifyForeground())
                    ..
                }

 

포그라운드 서비스를 실행하려면 사용자에게 보여줄 알림(Notifiaction)이 필요하다.
따라서 NotificationManager, NotificationChannel, Notification.Builder를 이용해 적절한 Notification 객체를 만들어
startForeground() 메서드로 넘겨주도록 한다.


ForegroundService 가 실행되어 나타난 알림.

 

 

또한 stopForeground() 메서드를 이용하여 Foreground Service를 중지할 수 있다.

                ServiceIntentAction.STOP.name -> {

                        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
                            stopForeground(STOP_FOREGROUND_REMOVE)
                        }

                }

 

 

● Foreground Service을 사용해본 느낌

 

서비스를 구현해보며 Foreground Service를 사용해본 결과,

서비스 실행 하고 난 뒤 알림이 계속해서 상단에 표시되어 있으니 서비스가 잘 실행되고 있음을 알 수 있어서 좋았다.


또한 포그라운드 서비스를 사용하지 않고 백그라운드 서비스만 사용할 때는
시스템이 리소스 부족 등을 이유로 우선순위가 낮은 서비스(TASK)를 중지시키는 일이 종종 있었는데
이 때 백그라운드 작업이 제대로 실행이 되지 않는 것을 볼 수 있었다.
(이에 대응하기 위해서 onStartCommand() 의 반환 타입

['START_STICKY , START_NOT_STICKY, START_REDELIVER_INTENT'] 을 설정해야 한다)

하지만 포그라운드 서비스를 사용하면 시스템이 웬만해서는 서비스를 종료하는 일이 없었다.
따라서 서비스가 종료되지 않고 지속적으로 실행되어 백그라운드 작업을 해야된다면 포그라운드 서비스를

사용하는 것이 좋다고 생각. *(알림의 우선순위에 따라 달라질 수 있다.)

 

또한 이번 포스팅에서는 보여주지 않았지만 포그라운드 서비스를 실행하면 나타나는 알림(Notification)에
action button을 추가하면 서비스 기능을 제어할 수 있는 것도 큰 장점이라고 생각된다.

 

Foreground Service 사용해본 후기를 정리하자면 다음과 같다.

  1. Foreground Service를 사용하는게 어렵지 않다는 점. (startForeground(), stopForeground()) 
  2. 앱과 상호작용 하지 않고 있어도 알림이 상단에 표시되기 때문에 서비스 실행 유무에 대한 가시성 확보.
  3. 시스템이 서비스를 종료하는 일이 거의 없어서 지속적인 작업 수행 가능.
  4. 알림의 버튼을 추가해 서비스를 제어 가능 .(앱에 들어가지 않고도 서비스를 종료/중지 하는 등)