2024. 4. 3. 16:43ㆍIT/Android
<미리 보기>
<소스 코드>
<정리>
WorkManager - 작업이 실행되는 조건과 작업의 실행이 보장되어야 하는 경우에 사용될 수 있는 도구로, 예를 들어 WiFi 연결 시에만 업로드를 실행하며 업로드가 완료될 때까지 앱이 종료되지 않고 백그라운드에서 완료되어야 한다면 사용을 고려할 수 있다.
WorkManager 이점
일회성 작업, 주기적인 작업을 비동기식으로 실행할 수 있다.
작업을 실행하기 전, 배터리 상태나 저장 공간과 같은 제약 조건을 설정할 수 있다.
작업들을 하나의 체인으로 묶어 순서를 보장할 수 있다.
작업의 출력을 다음 작업의 입력으로 사용할 수 있다.
API 14까지 호환.
현재 작업의 진행 상태를 추적할 수 있어 이를 이용한 UI 구성이 가능하다.
WorkManager 구성 요소의 간략한 설명
Worker : 백그라운드 스레드에서 동기식으로 작업을 정의하는 클래스.
CoroutineWorker : 백그라운드 스레드에서 비동기식으로 작업을 정의하는 클래스.
WorkRequest : (Coroutine)Worker의 작업에 대해 실행 빈도와 제약 조건을 설정한다.
WorkManager : WorkRequest을 사용하여 작업을 실행한다.
백그라운드에서 실행하고자 하는 작업들을 doWork() 메서드를 오버라이드 하면서 내부에 정의한다. 이는 기본적으로 Dispatchers.Default로 실행되지만 withContext()를 통해 작업에 따른 적절한 스레드로 변경하는 게 좋다.
각각의 작업들(Worker)은 Data타입의 컨테이너를 통해 객체 전달이 가능한데, 아래 코드에선 inputData로 해당 작업으로 전달된 Data객체를 불러오고 키를 사용하여 값을 불러오고 있다. 참고로 Data객체는 키 값만 다르다면 어떤 타입의 객체든 다수의 저장이 가능하다.
여러 작업들을 하나로 묶어 동기식으로 실행하는 것을 체이닝이라고 하는데, 순서가 보장되므로 이전 작업의 출력이 다음 작업의 입력이 될 수 있다. 아래의 코드에선 작업이 성공적으로 완료된 경우 Result.success의 인자로 Data객체를 전달하면서 다음 작업에 값을 전달하고 있다.
class BlurWorker(ctx: Context, params: WorkerParameters): CoroutineWorker(ctx, params) {
override suspend fun doWork(): Result {
//inputData를 통해 Data객체를 전달 받을 수 있고, 저장된 값에 해당하는 키로 해당 작업에 지정된 입력값 혹은 이전 작업의 출력값을 전달 받을 수 있다.
val resourceUri = inputData.getString(KEY_IMAGE_URI)
val blurLevel = inputData.getInt(KEY_BLUR_LEVEL, 1)
makeStatusNotification(
applicationContext.resources.getString(R.string.blurring_image),
applicationContext
)
//doWork()은 기본적으로 Dispatchers.Default로 실행되지만 작업에 따라 적절한 스레드를 선택해야 한다.
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))
) //Uri를 비트맵으로 변환.
val output = blurBitmap(picture, blurLevel) //해당 비트맵 이미지를 블러 처리.
val outputUri = writeBitmapToFile(applicationContext, output) //블러 처리된 이미지를 파일로 저장.
val outputData = workDataOf(KEY_IMAGE_URI to outputUri.toString()) //해당 이미지의 Uri를 Data로 저장.
Result.success(outputData) //반환.
} catch (throwable: Throwable) { //Uri를 읽지 못한 경우 발생하는 예외를 잡는다.
Log.e(
TAG,
applicationContext.resources.getString(R.string.error_applying_blur),
throwable
)
Result.failure()
}
}
}
}
WorkRequest는 작업의 실행 빈도에 따라 생성하는 객체가 달라지는데, 단 한 번의 작업을 실행한다면 OneTimeWorkRequest을 사용하고 일정 주기로 반복적인 작업을 실행한다면 PeriodicWorkRequest를 사용한다. 아래의 코드에선 각 작업이 단 한 번만 실행되어야 하므로 OneTimeWorkRequest를 사용했다.
특정 작업에 데이터의 전달은 해당 작업 WorkRequest에 setInputData()를 사용하면 가능한데, Data타입의 객체를 생성하여 인자로 전달해야 한다. 아래의 코드에선 두 번째로 실행되는 작업에 Int와 String값 각각에 키를 설정하여 Data 객체를 만들어 전달했다.
특정 작업에 제약 사항의 설정은 해당 작업 WorkRequest에 setConstraints()를 사용하면 가능한데, 제약 사항 객체 Constraints를 생성하여 인자로 전달해야 한다. 배터리 상태, 네트워크 상태, 충전 상황, 최소 API 기준 등등 다양한 제약이 있다. 아래의 코드에선 배터리 제약을 걸어 20% 아래에서는 실행될 수 없도록 설정하였다.
체이닝을 만드는 방법의 시작에는 beginWith()와 beginUniqueWork()이 있는데, 체인에 포함된 작업들이 현재 실행 중이고 완료되지 않았는데도 동일한 체인의 작업이 요청된 경우 대처 방법을 정의하고 싶다면 beginUniqueWork()을 사용해야 한다. 인자로 체인의 이름, 대처 방법(ExistingPolicty), 가장 먼저 실행되어야 하는 작업을 전달하면 체인의 토대가 만들어지고 이후에 실행되어야 하는 작업들을 then()을 사용하여 이어주면 완성된다.
특정 작업의 상태를 추적할 수 있는 WorkManager 메서드
유형 | WorkManager 메서드 | 설명 |
ID를 사용하여 작업 가져오기 | getWorkInfoByIdLiveData() | 이 함수는 특정 WorkRequest의 단일 LiveData<WorkInfo>를 ID를 기준으로 반환합니다. |
고유 체인 이름을 사용하여 작업 가져오기 | getWorkInfosForUniqueWorkLiveData() | 이 함수는 고유한 WorkRequest 체인에 있는 모든 작업의 LiveData<List<WorkInfo>>를 반환합니다. |
태그를 사용하여 작업 가져오기 | getWorkInfosByTagLiveData() | 이 함수는 태그의 LiveData<List<WorkInfo>>를 반환합니다. |
LiveData는 수명 주기를 인식하는 관찰 가능한 데이터 홀더로 Flow와 별반 다르지 않지만 호환성을 위해 Flow로 변환하여 사용한다. WorkInfo는 해당 작업의 현재 상태와 작업 완료 시 반환값이 있는 경우 반환값을 담고 있다.
작업의 상태는 BLOCKED, CANCELLED, ENQUEUED, FAILED, RUNNING, SUCCEEDED로 다양하다.
아래의 코드에선 세 번째 작업에 태그를 달고, getWorkInfosByTagLiveData()를 사용하여 해당 작업의 상태를 추적하고 있다.
특정 작업 혹은 체인 실행의 취소는 ID, 태그, 체인의 이름과 WorkManager 메서드를 사용하면 된다. 아래의 코드에선 cancelUniqueWork() 메서드의 인자로 체인의 이름을 전달하여 실행을 취소하고 있다.
class WorkManagerBluromaticRepository(context: Context) : BluromaticRepository {
private val imageUri: Uri = context.getImageUri()
private val workManager = WorkManager.getInstance(context) //WorkManager 객체 생성.
//해당 태그의 작업 결과를 추적하는 LiveData<List<WorkInfo>>를 Flow<WorkInfo>로 변환하여 사용.
override val outputWorkInfo: Flow<WorkInfo> = workManager.getWorkInfosByTagLiveData(TAG_OUTPUT)
.asFlow()
.mapNotNull {
if(it.isNotEmpty()) it.first() else null
}
override fun applyBlur(blurLevel: Int) {
//체인이 실행중이고, 동일한 체인의 실행 요청이 들어온 경우 ExistingWorkPolicy를 통해 조치 방법을 선택할 수 있다.
var continuation = workManager.beginUniqueWork(IMAGE_MANIPULATION_WORK_NAME,ExistingWorkPolicy.REPLACE, OneTimeWorkRequest.Companion.from(CleanupWorker::class.java))
val constraints = Constraints.Builder().setRequiresBatteryNotLow(true).build() //배터리 제약 생성.
val blurBuilder = OneTimeWorkRequestBuilder<BlurWorker>()
blurBuilder.setInputData(createInputDataForWorkRequest(blurLevel, imageUri)) //BlurWorker에 데이터 전달.
blurBuilder.setConstraints(constraints) //BlurWorker에 제약 전달.
continuation = continuation.then(blurBuilder.build())
val save =
OneTimeWorkRequestBuilder<SaveImageToFileWorker>()
.addTag(TAG_OUTPUT)
.build()
continuation = continuation.then(save)
continuation.enqueue()
}
override fun cancelWork() {
workManager.cancelUniqueWork(IMAGE_MANIPULATION_WORK_NAME) //해당 이름을 가진 체인의 작업을 취소한다.
}
private fun createInputDataForWorkRequest(blurLevel: Int, imageUri: Uri): Data { //Data 객체를 생성하는 도우미 함수.
val builder = Data.Builder()
builder.putString(KEY_IMAGE_URI, imageUri.toString()).putInt(KEY_BLUR_LEVEL, blurLevel)
return builder.build()
}
}
WorkManager는 Context를 사용하므로 작업자의 비즈니스 로직을 테스트할 때조차도 UI 테스트로 진행해야 한다.
작업을 Worker로 정의했다면 TestWorkerBuilder를, CoroutineWorker로 정의했다면 TestListenableWorkerBuilder를 사용하여 생성하는 게 일반적이다.
생성한 작업의 객체를 사용하여 doWork()를 호출하면 작업이 실행된다. 단, 이는 정지 함수이므로 runBlocking() 사용.
class WorkerInstrumentationTest {
private lateinit var context: Context
@Before
fun setUp() {
context = ApplicationProvider.getApplicationContext()
}
@Test
fun cleanupWorker_doWork_resultSuccess() {
val worker = TestListenableWorkerBuilder<CleanupWorker>(context).build()
runBlocking {
val result = worker.doWork()
assertTrue(result is ListenableWorker.Result.Success)
}
}
//...생략
}
비트맵 이미지를 블러 처리하는 함수.
@WorkerThread
fun blurBitmap(bitmap: Bitmap, blurLevel: Int): Bitmap {
val input = Bitmap.createScaledBitmap( //이미지를 파라미터로 받아 해당 이미지를 축소시킨다.
bitmap,
bitmap.width/(blurLevel*5),
bitmap.height/(blurLevel*5),
true
)
return Bitmap.createScaledBitmap(input, bitmap.width, bitmap.height, true) //축소한 이미지를 원본의 크기로 복구하여 블러 효과를 발생시킨다.
}
프로젝트의 drawable 이미지 파일을 Uri로 변환하는 함수.
fun Context.getImageUri(): Uri {
val resources = this.resources
//Uri의 구조 "scheme://authority/path"
//android.resource://com.example.bluromatic/drawable/android_cupcake
return Uri.Builder()
.scheme(ContentResolver.SCHEME_ANDROID_RESOURCE) //Uri의 구조중 scheme 설정.
.authority(resources.getResourcePackageName(R.drawable.android_cupcake)) //Uri의 구조중 authority 설정.
.appendPath(resources.getResourceTypeName(R.drawable.android_cupcake)) //Uri의 구조중 path 설정.
.appendPath(resources.getResourceEntryName(R.drawable.android_cupcake)) //Uri의 구조중 path 설정.
.build()
}
앱의 알림을 생성하는 함수.
fun makeStatusNotification(message: String, context: Context) {
// 알림 채널로 해당 알림의 우선순위, 카테고리를 지정할 수 있어 사용자의 편의가 올라간다.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val name = VERBOSE_NOTIFICATION_CHANNEL_NAME
val description = VERBOSE_NOTIFICATION_CHANNEL_DESCRIPTION
val importance = NotificationManager.IMPORTANCE_HIGH
val channel = NotificationChannel(CHANNEL_ID, name, importance)
channel.description = description
// Add the channel
val notificationManager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager?
notificationManager?.createNotificationChannel(channel)
}
// Create the notification
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_launcher_foreground)
.setContentTitle(NOTIFICATION_TITLE)
.setContentText(message)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setVibrate(LongArray(0))
// Show the notification
NotificationManagerCompat.from(context).notify(NOTIFICATION_ID, builder.build())
}
버튼의 로딩 중을 나타내는 컴포저블 구성.
when(blurUiState) {
is BlurUiState.Default -> {
//...생략
}
is BlurUiState.Complete -> {
//...생략
}
else -> {
FilledTonalButton(onClick = onCancelClick) { Text(stringResource(R.string.cancel_work)) }
CircularProgressIndicator(modifier = Modifier.padding(dimensionResource(R.dimen.padding_small)))
}
}
파일 입출력 관련 함수들도 있지만 배경지식의 부족으로 나중에 다시 검토.
'IT > Android' 카테고리의 다른 글
XML - View, ViewGroup (0) | 2024.04.08 |
---|---|
Jetpack Compose - WorkManager Practice (0) | 2024.04.03 |
Jetpack Compose - Room, DataStore Practice (0) | 2024.03.29 |
Jetpack Compose - DataStore<Preferences> (0) | 2024.03.20 |
Jetpack Compose - Room Practice (0) | 2024.03.19 |