Android 공부(8)

백상휘·2025년 11월 5일
0

Android

목록 보기
5/5

앱 백그라운드 작업을 관리하고 추가 작업을 하는 방법에 대해 알아본다.

모바일 환경에서는 온고잉 백그라운드 작업이 흔하다. 온고잉 백그라운드 작업이란 앱이 활성화되지 않은 경우에도 실행하는 작업으로 파일 다운로드, 리소스 정리, 음악 재생, 사용자 위치 추적 등이 이런 작업의 예이다.

이런 작업을 수행하기 위해선 서비스, JobScheduler, 파이어베이스의 JobDispatcher, AlarmManager 등을 사용할 수 있다. WorkManager 는 API 버전에 따라 백그라운드 실행 메커니즘을 선택하는 방식을 추상화하였다. 그러나 음악 재생, 실행중인 앱의 위치 추적에는 Foreground service 를 사용한다.

서비스는 앱이 실행 중이지 않을 때도 백그라운드에서 실행되도록 설계된 앱 구성 요소다. 서비스는 사용자 인터페이스를 갖지 않는다. 포어 그라운드 서비스만 예외적으로 사용자 인터페이스를 갖는다. 알람을 제외하고는 유저 인터페이스에 직접적으로 영향을 주지 않는다.

이를 토대로 아래의 내용을 다루고자 한다.

  • WorkManager 를 사용한 백그라운드 작업 시작
  • 사용자에게 알림을 주는 백그라운드 작업: 포어그라운드 서비스 사용

WorkManager 를 사용한 백그라운드 작업 시작

WorkManager, Foreground Service 어떤 것을 선택해야 할까? 이를 선택하기 위해서는 실시간으로 상태를 추적하는지 물어봐야 한다.

  • 추적 필요 : Foreground Service
  • 추적 불필요(오래걸림) : WorkManager

WorkManager 2.3.0-alpha02 버전부터는 setForegroundAsync(ForegroundInfo) 를 이용해 WorkManager 싱글톤을 사용해서 Foreground Service 를 시작할 수 있다. 기능은 제한적이지만 작업과 동시에 알림을 지정할 수 있어 알아두면 편하다.

WorkManager 를 사용하려면 4가지 클래스를 알아야 한다.

  • WorkManager : 제공된 인자와 제약조건(인터넷 연결, 충전 등)을 기반으로 작업을 수신하고 대기열에 추가한다.
  • Worker : 수행해야 할 작업을 래핑한다. doWork() 함수 하나를 가지며, 이 함수를 재정의해 백그라운드 작업 코드를 구현한다. 이 함수는 백그라운드 스레드에서 실행된다.
  • WorkRequest : Worker 클래스를 인자와 제약 조건에 바인딩한다. 두 종류의 WorkRequest 가 있으며, 작업을 한 번 실행하는 OneTimeWorkRequest, 일정한 간격으로 작업을 예약하는 PeriodicWorkRequest 가 있다.
  • ListenableWorker.Result : 실행된 작업의 결과를 가진다. 결과는 Success, Failure, Retry 중 하나다.

WorkManager 를 사용하기 전에 먼저 앱의 종속성을 추가한다.

// build.gradle.kts
implementation(libs.androidx.work.runtime)

// libs.versions.toml
[versions]
workRuntime = "2.8.0"
[libraries]
androidx-work-runtime = { group = "androidx.work", name = "work-runtime", version.ref = "workRuntime" }

Worker 를 생성해서 작업을 정의해보자.

class CatStretchingWorker(
  context: Context, workerParameters: WorkerParameters
) : Worker(context, workerParameters) {
  override fun doWork(): Result {
    val catAgentId = inputData.getString(INPUT_DATA_CAT_AGENT_ID)
    Thread.sleep(3000L)
    val outputData = Data.Builder()
      .putString(OUTPUT_DATA_CAT_AGENT_ID, catAgentId).build()
    return Result.success(outputData)
  }
  companion object {
    const val INPUT_DATA_CAT_AGENT_ID = "id"
    const val OUTPUT_DATA_CAT_AGENT_ID = "id"
  }
}

doWork 함수를 오버라이드 하고 입력 데이터를 불러온 다음 3초 대기 후 success 를 데이터와 함께 반환한다.

Worker 를 WorkManager 를 이용해 연결하는 것은 아래와 같이 수행한다.

val networkConstraints = Constraints.Builder()
  .setRequiredNetworkType(NetworkType.CONNECTED)
  .build()
val catStretchingInputData = Data.Builder()
  .putString(CatStretchingWorker.INPUT_DATA_CAT_AGENT_ID, "catAgentId")
  .build()
val catStretchingRequest = OneTimeWorkRequest
  .Builder(CatStretchingWorker::class.java)
  .setConstraints(networkConstraints)
  .setInputData(catStretchingInputData)
  .build()
  
// ...

WorkManager.getInstance(this)
  .beginWith(catStretchingRequest)
  .then(catFurGroomingRequest)
  .then(catLitterBoxSittingRequest)
  .then(catSuitUpRequest)
  .enqueue()

Constraint 는 작업 실행 전 인터넷 연결이 필요하다는 제약 조건이다. 입력 데이터 정의 후 OneTimeWorkRequest 로 제약조건과 입력 데이터를 Worker 클래스에 바인딩한다.

각 Request 인스턴스는 고유 식별자가 있고, WorkManager 는 Request 에 대한 LiveData 속성을 통해 진행 상황 추적이 가능하도록 해준다.

workManager.getWorkInfoByIdLiveData(
  catStretchingRequest.id
).observe(this) { info ->
  if (info.state.isFinished) { doSomething() }
}

작업 상태는 다음과 같이 나뉘어져 있다.

  • BLOCKED : 요청 체인이 있고 이 작업이 체인에서 다음 차례가 아닌 경우
  • ENQUEUED : 요청 체인이 있고 이 작업이 다음 차례인 경우
  • RUNNING : doWork 에서 작업 실행 중
  • SUCCEEDED : 작업 완료
  • CANCELED : 작업 취소
  • FAILED : 작업 실패

Worker 의 결과 값으로 Result.retry 가 있는데 이 경우 대기열에 작업을 다시 넣도록 WorkManager 클래스에 지시할 수 있다. 작업 재시작 정책은 WorkRequest Builder 에서 설정한 backoff 기준으로 정의한다.

사용자가 인지할 수 있는 백그라운드 작업: 포어그라운드 서비스

현재 위치를 지속적으로 폴링하여 스티키 알림을 새 위치로 업데이트한다.

포어그라운드 서비스는 알림과 연결돼 있고, 기본 안드로이드 서비스는 사용자가 볼 수 있는 표시가 없이 백그라운드에서 실행된다. 포어그라운드 서비스에서 ANR(Application Not Responding) 메시지를 표시하는 시간 내에 포어그라운드 서비스가 알림과 연결돼 있지 않으면 서비스가 중지되고 앱이 응답하지 않는 것으로 표시한다.

서비스를 빨리 알림과 연결시키려면 서비스의 onCreate 를 사용한다.

private fun onCreate() {
  val channelId = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.0) {
    val newCahnnelId = "ChannelId"
    val channelName = "My Background Service"
    val channel = NotificationChannel(
      newChannelId,
      channelName,
      NotificationManager.IMPORTANT_DEFAULT)
    val service = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
    service.createNotificationChannel(channel)
    newChannelId
  } else { "" }
  
  val flag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) FLAG_IMMUTABLE else 0
  val pendingIntent = Intent(this, MainActivity::class.java).let { notificationIntent -> 
    PendingIntent.getActivity(this, 0, notificationIntent, flag)
  }
  
  val notification = NotificationCompat.Builder(this, channelId)
    .setContentTitle("Content Title")
    .setContentText("Content text")
    .setSmallIcon(R.drawable.notification_icon)
    .setContentIntent(pendingIntent)
    .setTicker("Ticker message").build()
  
  startForeground(NOTIFICATION_ID, notificationBuilder.build())
}

채널 ID 정의는 오레오 이상에서만 필요하다. pendingIntent 는 액티비티를 실행하는 Intent 를 래핑해 만들 수 있다. 채널 ID, pendingIntent 를 통해 알림을 만들 수 있다.

포어그라운드에서 서비스를 시작하고 알림을 표시하기 위해 startForeground 함수를 호출한다.

현재 위 서비스는 알림 표시 외에 아무것도 수행하지 않지만 onStartCommand(Intent?, Int, Int) 를 오버라이드하면 된다. 이 함수는 UI 스레드에서 호출된다. 그러므로 긴 시간 호출되는 함수를 수행하면 사용자가 불편을 겪을 수 있다. 대신 HandlerThread 를 사용해 새 핸들러를 생성하고 작업을 해당 핸들러에 전달한다. 이러면 핸들러는 작업을 받을 때까지 계속 돈다.

완료된 후 다른 작업들을 수행해야 하면 완료를 기다리는 대상(예: 액티비티)에게 작업이 완료되었음을 알린 후 포어그라운드에서 실행을 중지시키고 서비스가 다시 필요하지 않다면 서비스를 중지시킨다.

앱이 서비스와 통신하기 위한 방법에는 바인딩, 브로드캐스트 리시버, 버스 아키텍처, 리절트 리시버 등 다양한 방법이 있다.

LiveData 는 companion object 내에서 정의한다.

companion object {
  private val mutableWorkCompletion = MutableLiveData<String>()
  val workCompletion: LiveData<String> = mutableWorkCompletion
}

MutableLiveData 인스턴스를 LiveData 인터페이스 뒤에 숨겼다. 이렇게 하면 옵저버는 LiveData 를 읽기 전용으로만 사용한다. mutableWorkCompletion 을 통해 완료 상태를 알릴 수 있고, LiveData 인스턴스에서만 값을 할당할 수 있다.

작업 완료 후 메인스레드로 전환할 메인 Looper 핸들러를 추가한다.

서비스 작업을 수행하기 전 AndroidManifest.xml 에 service 태그를 추가했는지 확인한다.

<application ...>
  <service android:name="ForegroundService" />
</application>

서비스 수행을 위한 Intent 도 생성한다.

val serviceIntent = Intent(this, ForegroundService::class.java).apply {
  putExtra("ExtraData", "Extra value")
}

// 서비스 시작
ContextCompat.startForegroundService(this, serviceIntent)

브로드캐스트 리시버만 간단히 살펴보자. 브로드캐스트 리시버를 사용하면 앱이 발행-구독 디자인 패턴과 유사한 패턴을 사용해 메시지를 보내고 받을 수 있다.

시스템은 디바이스 부팅, 충전 시작과 같은 이벤트를 브로드캐스트한다. 앱도 이벤트를 브로드캐스트할 수 있다. 브로드캐스팅은 서비스와 통신하기 위한 일반적인 방법이었지만 LocalBroadcastManager 클래스가 앱 전체 이벤트 버스로 사용돼 안티패턴을 유도해서 더 이상 사용하지 않는다. 하지만 전역 이벤트 핸들링엔 유용하다.

class ToastBoradcastReceiver: BoradcastReceiver() {
  override fun onReceive(context: Context, intent: Intent) {
    StringBuilder().apply {
      append("Action: ${intent.action}\n")
      append("URI: ${intent.toUri(Intent.URI_INTENT_SCHEME)}\n")
      toString().let { eventText ->
        Toast.makeText(context, eventText, Toast.LENGTH_LONG).show()
      }  
    }
  }
}

Manifest.xml 파일을 통해 리시버를 등록할 수 있다.

<receiver android:name=".ToastBroadcastReceiver" android:exported="true">
    <intetn-filter>
      <action android:name="android.intent.action.ACTION_POWER_CONNECTED" />
    </intent-filter>
</receiver>

android:exported 가 true 면 이 리시버는 앱 외부에서 메시지를 수신할 수 있다고 표시하는 것이다.

코드로도 가능하다.

val filter = IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION)
  .apply { addAction(Intent.ACTION_POWER_CONNECTED) }
registerReceiver(ToastBroadcastReceiver(), filter)

리시버는 컨텍스트가 유효한 한 계속 사용 가능하다.

profile
plug-compatible programming unit

0개의 댓글