개발/안드로이드

WorkManager로 백그라운드 작업

스몰스테핑 2024. 6. 10. 16:21

WorkManager란?

WorkManager는 상황별 실행과 보장된 실행을 조합하여 적용해야 하는 백그라운드 작업을 위한 아키텍처 구성요소로서 Android Jetpack의 일부이다. 상황별 실행을 적용하면 WorkManager가 최대한 빨리 백그라운드 작업을 실행한다. 보장된 실행을 적용하면 WorkManager가 사용자가 앱을 벗어난 경우를 비롯한 다양한 상황에서 로직을 처리하여 작업을 시작합니다.

 

  • WorkManager는 매우 유연한 라이브러리로, 다음과 같은 이점이 있다.
  • 비동기 일회성 작업과 주기적인 작업 모두 지원
  • 네트워크 상태, 저장공간, 충전 상태와 같은 제약 조건 지원
  • 동시 작업 실행과 같은 복잡한 작업 요청 체이닝
  • 한 작업 요청의 출력이 다음 작업 요청의 입력으로 사용됨
  • API수준 14까지 호환됨(참고 확인)
  • Google Play 서비스를 사용하거나 사용하지 않고 작업
  • 시스템 상태 권장사항 준수
  • 앱 UI에 작업 요청의 상태를 쉽게 표시할 수 있도록 지원
WorkManager는 JobSchedulerAlarmManager 등 몇 가지 API 위에 있다. WorkManager는 사용자의 기기 API 수준을 비롯한 조건에 따라 사용할 올바른 API를 선택한다. 자세한 내용은 WorkManager로 작업 예약WorkManager 문서를 참고하자.

 

 

WorkManager가 적합한 작업

  • 주기적으로 최신 뉴스 기사 쿼리
  • 이미지에 필터를 적용한 다음 이미지 저장
  • 주기적으로 로컬 데이터를 네트워크와 동기화

WorkManager는 기본 스레드에서 작업을 실행하는 한 가지 옵션이지만 기본 스레드에서 모든 유형의 작업을 실행하기 위한 포괄적인 옵션이 아니다. 어떤 작업에 WorkManager를 사용할지 자세히 알아보려면 백그라운드 작업 가이드를 참고하자.

 

 

앱에 WorkManager 추가

WorkManager에는 다음과 같은 Gradle 종속 항목이 필요하다.

dependencies {
    // WorkManager dependency
    implementation("androidx.work:work-runtime-ktx:2.9.0")
}

 

WorkManager의 안정화 버전은 다음 링크를 참고하자.

 

 

WorkManager 기본사항

알아야 할 몇 가지 WorkManager 클래스가 있다.

  • Worker/CoroutineWorker: worker는 백그라운드 스레드에서 동기식으로 작업을 실행하는 클래스이다. 비동기 작업을 위해 Kotlin 코루틴과 상호 운용되는 CoroutineWorker를 사용할 수 있다.
  • WorkRequest: 이 클래스는 작업 실행 요청을 나타낸다. WorkRequest에서는 worker를 한 번 또는 주기적으로 실행해야 하는지 정의한다. 제약 조건은 작업 실행 전에 특정 조건 충족을 요구하는 WorkRequest에 배치될 수도 있다. 한 가지 예는 요청된 작업을 시작하기 전에 기기를 충전하는 것이다. WorkRequest를 만드는 과정에서 CoroutineWorker를 전달한다.
  • WorkManager: 이 클래스는 실제로 WorkRequest를 예약하고 실행한다. 지정된 제약 조건을 준수하며 시스템 리소스에 부하를 분산하는 방식으로 WorkRequest를 예약한다.

 

 

예시 코드

import android.content.Context
import android.graphics.BitmapFactory
import android.net.Uri
import android.util.Log
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import com.example.bluromatic.KEY_BLUR_LEVEL
import com.example.bluromatic.KEY_IMAGE_URI
import com.example.bluromatic.R
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

private const val TAG = "BlurWorker"

class BlurWorker(
    ctx: Context,
    params: WorkerParameters
): CoroutineWorker(ctx, params) {
    override suspend fun doWork(): Result {
        val resourceUri = inputData.getString(KEY_IMAGE_URI)
        val blurLevel = inputData.getInt(KEY_BLUR_LEVEL, 1)

        makeStatusNotification(
            applicationContext.resources.getString(R.string.blurring_image),
            applicationContext
        )

        return withContext(Dispatchers.IO) {
            return@withContext try {
                require(!resourceUri.isNullOrBlank()) {
                    val errorMessage = applicationContext.resources.getString(R.string.invalid_input_uri)
                    Log.e(TAG, errorMessage)
                    errorMessage
                }

                val resolver = applicationContext.contentResolver

                val picture = BitmapFactory.decodeStream(
                    resolver.openInputStream(Uri.parse(resourceUri))
                )

                val output = blurBitmap(picture, blurLevel)
                val outputUri = writeBitmapToFile(applicationContext, output)
                val outputData = workDataOf(KEY_IMAGE_URI to outputUri.toString())

                Result.success(outputData)
            } catch (throwable: Throwable) {
                Log.e(
                    TAG,
                    applicationContext.resources.getString(R.string.error_applying_blur),
                    throwable
                )
                Result.failure()
            }
        }
    }
}

 

위 코드는 이미지에 블러 처리를 거치는 코드이다.

세부적인 코드는 제쳐두고 틀을 살펴보자.

 

import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

class SomeThingWorker(
    ctx: Context,
    params: WorkerParameters
): CoroutineWorker(ctx, params) {
    override suspend fun doWork(): Result {
        return withContext(Dispatchers.SomeThing) {
            return@withContext try {
                // 필요한 작업

                Result.success()
            } catch (throwable: Throwable) {
                // 작업 실패시
                
                Result.failure()
            }
        }
    }
}

 

public constructor CoroutineWorker(
    val appContext: Context,
    val params: WorkerParameters
)

 

CoroutineWorker를 상속받는데, 이 CoroutineWorker는 appContext와 WorkerParameters를 매개변수로 요구한다.

그렇기에 내가 작성하는 Worker Class에도 생성자 매개변수에 동일하게 넣어준다.

 

이후 doWork()라는 메서드를 재정의해야한다. 이 doWork()는 Worker로 실행할 수 없는 비동기 코드를 실행할 수 있는 정지 함수이다. 이 메서드에서 필요한 비동기 작업을 시행하면 된다.

 

CoroutineWorker는 기본적으로 Dispatchers.Default로 실행되지만 withContext()를 호출하고 원하는 디스패처를 전달하여 변경할 수 있다. (예시 코드처럼 블러처리한 이미지를 저장하기 위해서라면 Dispatchers.IO를 쓴다던지...)

 

그 withContext() 내부에는 try/Catch문을 사용해 작업이 성공했을 때와, 실패했을때를 반환시킨다.

성공했을 경우 작업물을 반환하면 될 것이고, 실패했을 경우 사용자에게 경고, 알림을 띄워주면 될 것이다.

 

 

작업 체이닝

위 과정을 통해 단일 작업을 처리할 수 있다. 그러나 추가적인 작업이 필요한 경우가 존재할 수 있다.

블러처리한 이미지를 저장하는 예시의 경우, 임시 파일을 정리하지 않아 파일이 쌓인다던지, 저장관련된 문제라던지.

 

이를 위해 WorkManager 작업 체인을 사용하여 위 기능들을 추가할 수 있다. WorkManager를 사용하면 순서대로 실행되거나 동시에 실행되는 별도의 WorkerRequest를 만들 수 있다.

 

 

예시를 토대로 필요한 작업과 순서를 따져보면 다음과 같이 정의할 수 있다.

임시 파일을 정리한 뒤, 이미지를 블러 처리하고, 이미지 파일로 저장하는 것이다.

 

위에서 주어진 코드를 토대로 작업을 구현한 뒤, 다음과 같이 3가지 작업이 생겼을 때, 이를 연속으로 처리하고 실행하도록 코드를 작성해야 한다.

 

 

WorkRequest를 처리하는 Repository에서 단일 작업을 처리하는 Worker를 불러오는 함수가 존재했을 것이다.

다음 예시 코드의 applyBlur 부분이 해당 부분이다.

 

import com.example.bluromatic.workers.BlurWorker
import androidx.work.OneTimeWorkRequestBuilder
...
override fun applyBlur(blurLevel: Int) {
    // Create WorkRequest to blur the image
    val blurBuilder = OneTimeWorkRequestBuilder<BlurWorker>()

    // Start the work
    workManager.enqueue(blurBuilder.build())
}

 

해당 코드를 작업체이닝 과정을 거치고 나면 다음과 같이 변경된다.

 

import android.content.Context
import android.net.Uri
import androidx.work.Data
import androidx.work.OneTimeWorkRequest
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkInfo
import androidx.work.WorkManager
import com.example.bluromatic.KEY_BLUR_LEVEL
import com.example.bluromatic.KEY_IMAGE_URI
import com.example.bluromatic.getImageUri
import com.example.bluromatic.workers.BlurWorker
import com.example.bluromatic.workers.CleanupWorker
import com.example.bluromatic.workers.SaveImageToFileWorker
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow

class WorkManagerBluromaticRepository(context: Context) : BluromaticRepository {

    override val outputWorkInfo: Flow<WorkInfo?> = MutableStateFlow(null)
    private var imageUri: Uri = context.getImageUri()
    private val workManager = WorkManager.getInstance(context)

    override fun applyBlur(blurLevel: Int) {
        var continuation = workManager.beginWith(OneTimeWorkRequest.Companion.from(CleanupWorker::class.java))
        val blurBuilder = OneTimeWorkRequestBuilder<BlurWorker>()

        blurBuilder.setInputData(createInputDataForWorkRequest(blurLevel, imageUri))
        continuation = continuation.then(blurBuilder.build())

        val save = OneTimeWorkRequestBuilder<SaveImageToFileWorker>().build()
        continuation.then(save).apply { enqueue() }
    }

    override fun cancelWork() {}

    private fun createInputDataForWorkRequest(blurLevel: Int, imageUri: Uri): Data {
        val builder = Data.Builder()
        builder.putString(KEY_IMAGE_URI, imageUri.toString()).putInt(KEY_BLUR_LEVEL, blurLevel)
        return builder.build()
    }
}

 

OneTimeWorkRequestBuilder를 호출하는 대신 workManager.beginWith()를 호출한다.

beginWith() 메서드를 호출하면 WorkContinuation 객체가 반환되고 체인의 첫 번째 작업 요청이 있는 WorkRequest 체인의 시작점이 생성된다.

 

시작점에는 순서에 맞게 CleanupWorker(임시 파일 정리) 작업으로 시작한다.

이후 OneTimeWorkRequestBuilder로 이미지 블러, 이미지 저장 2개의 작업을 만든다.

시작점인 WorkContinuation에 continuation.then() 메서드를 통해 WorkRequest 객체를 전달하여 이 작업 요청 체인에 추가할 수 있다.

 

모든 작업을 추가했다면, enqueue() 메서드를 호출해 작업을 시작시킨다.

그럼 작업 체이닝 구현이 완료된다.