Jetpack 또는 AAC(Android Architecture Component)의 WorkManager
는 background task 구현, 스케쥴 등을 쉽게 처리할 수 있도록 만든 기능입니다.
기존에 JobScheduler
가 이와 비슷한 역할을 하였는데요. 편의기능을 더하여 WorkManager를 만들었습니다.
WorkManager는 내부적으로 JobScheduler를 사용하며(API 23 이상) JobScheduler를 지원하지 않는 단말(API 14~22)은 AlarmManager 또는 BroadcastReceiver를 사용하도록 구현되었습니다.
워크매니저는 다음과 같은 기능들을 제공합니다.
- Job 스케쥴링 : Job을 스케쥴링할 수 있습니다.
- Job의 상태 모니터링: 실시간으로 처리되는지 상태를 알 수 있습니다.
- Constraint(제약): 원하는 조건에 Job이 동작하도록 제약을 줄 수 있습니다.
- Chaining Task: 여러 Job을 우리가 정한 순서대로 실행되도록 할 수 있습니다.
이번 글에서는 샘플을 만들어보면서 WorkManager의 기능과 구현방식을 알아보겠습니다.
완성된 샘플은 GitHub에 있습니다.
WorkManager 주요 클래스
워크매니저를 이해하려면 먼저 4개의 주요 클래스를 이해해야 합니다.
- Worker : 백그라운드에서 수행될 태스크를 의미합니다. 추상 클래스인 Worker를 상속한 클래스를 구현하고 동작할 태스크를 구현해야 합니다.
- WorkRequest : WorkManager에 수행할 태스크를 요청할 때 사용되는 클래스입니다. 수행할 Worker를 등록해야 하고, 한번만 실행할 것인지 주기적으로 실행할 것인지 설정할 수 있습니다.
또한, 충전 중일 때만 동작하도록 제약을 걸 수 있습니다.
- WorkManager : 이 객체에 WorkRequest들이 추가되며, 설정에 맞게 태스크를 동작시킵니다.
- WorkInfo : WorkManager에 추가된 태스크들의 상태를 나타내는 클래스입니다. Enqueue, Running, Success, Fail 등의 태스크의 현재 상태를 알 수 있습니다.
WorkManager는 WorkInfo를 LiveData로 제공하기 때문에 옵저버를 붙여 상태를 감시할 수 있습니다.
WorkManager는 개별 태스크를 실행할 수도 있고, 여러개의 태스크를 순차적으로(Chain) 실행할 수도 있습니다. 태스크A의 결과를 입력값으로 태스크B를 실행하도록 처리할 수 있습니다. 또는 아래 그림처럼 복잡한 태스크 실행 관계를 정의할 수 있습니다.
태스크가 실행되는 주기와 조건도(constraint) 설정할 수 있습니다. 예를들어, 태스크가 하루에 한번, 사용자가 폰을 사용하지 않을 때 처리하고 싶다면 이렇게 주기와 제약 조건을 설정할 수 있습니다.
WorkManager의 주요 클래스와 컨셉을 모두 설명하였습니다. 간단한 코드로 WorkManager가 어떻게 동작하는지, 어떻게 구현할 수 있는지 알아보겠습니다.
의존성
gradle의 dependencies에 사용하시는 언어에 맞는 의존성을 추가하시면 됩니다.
implementation "android.arch.work:work-runtime-ktx:1.0.0-beta01" // for kotlin
implementation "android.arch.work:work-runtime:1.0.0-beta01" // for java
Worker
Worker는 태스크가 할 일이 구현된 클래스입니다.
추상 클래스인 Worker 클래스를 상속하여 구현합니다.
doWork
를 오버라이드하여 이 곳에 할 일을 구현하면 됩니다.
아래 코드는 숫자를 제곱하고 5초 뒤에 Result.success
를 리턴하는 내용입니다.
class SimpleWorker(context : Context, params : WorkerParameters)
: Worker(context, params) {
override fun doWork(): Result {
val number = 10
val result = number * number
SystemClock.sleep(5000)
Log.d("SimpleWorker", "SimpleWorker finished: $result")
return Result.success()
}
}
Result
로 리턴할 수 있는 객체는 3가지입니다.
- Result.success() : 작업이 성공적으로 완료되었음을 의미합니다
- Result.failure() : 작업이 실패로 끝났고, 다시 시작하지 않아도 됨을 의미합니다
- Result.retry() : 작업이 실패로 끝났고, 다시 시작해야 함을 의미합니다
Request
Request
는 WorkManager
에 할 일을 요청하는 기본 객체입니다.
Request에는 실행할 Worker의 정보가 있고,
언제, 어떻게 태스크가 실행되어야 하는지 등의 정보가 있습니다.
한번만 실행해야 하는 태스크는 OneTimeWorkRequest.Builder
를 통해 Request 객체를 만들 수 있습니다.
val simpleRequest = OneTimeWorkRequest.Builder(SimpleWorker::class.java).build()
주기적으로 실행되는 태스크는 PeriodicWorkRequest.Builder
로 Request를 만들 수 있습니다.
PeriodicWorkRequest에는 얼마 간격으로 태스트가 실행되어야하는지 interval 시간을 입력해야 합니다.
val periodicRequest = PeriodicWorkRequest.Builder(SimpleWorker::class.java, 12, TimeUnit.HOURS).build()
WorkManager
WorkManager는 Request에 명시된 대로 태스크를 실행시킵니다. 위에서 생성한 Worker와 Request를 WorkManager에 등록하여 실행시켜보겠습니다.
먼저 MainActivity
에 다음과 같이 버튼을 3개 생성합니다.
simpleWorkStatusText
는 태스크의 상태를 보여주고, startSimpleWorkerBtn
는 태스트 실행,
cancelSimpleWorkerBtn
는 태스크를 취소하는 버튼입니다.
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:gravity="center"
tools:context=".MainActivity">
<TextView
android:id="@+id/simpleWorkStatusText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="20sp"
android:text="work status:"/>
<Button
android:id="@+id/startSimpleWorkerBtn"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Start work"/>
<Button
android:id="@+id/cancelSimpleWorkerBtn"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Cancel work"/>
</LinearLayout>
startSimpleWorkerBtn
를 눌렀을 때 Request를 생성하고 WorkManager에 추가하도록 하였습니다.
WorkManager.beginWith
는 처음 실행할 태스크에 대한 Request를 인자로 받습니다.
enqueue
가 호출되면 작업이 추가됩니다.
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
startSimpleWorkerBtn.setOnClickListener {
val simpleRequest = OneTimeWorkRequest.Builder(SimpleWorker::class.java)
.build()
val workManager = WorkManager.getInstance()
workManager.beginWith(simpleRequest)
.enqueue()
}
}
}
버튼을 눌러보면 상태를 출력하지 않기 때문에 특별히 달라진 것이 없습니다만, Worker에 넣은 로그는 출력되었습니다. WorkManager가 Worker를 실행했음을 알 수 있습니다.
2018-12-23 19:56:33.770 6851-6913/com.jsandroid.myapplication D/SimpleWorker: SimpleWorker finished: 100
WorkInfo
WorkInfo
는 태스크의 상태를 나타내는 클래스입니다.
WorkManager는 LiveData로 WorkInfo를 제공해주기 때문에 옵저버를 붙여 상태를 모니터링 할 수 있습니다.
getWorkInfoByIdLiveData
로 Request의 id를 넘겨주면 LiveData
startSimpleWorkerBtn.setOnClickListener {
val simpleRequest = OneTimeWorkRequest.Builder(SimpleWorker::class.java)
.build()
val workManager = WorkManager.getInstance()
workManager.beginWith(simpleRequest)
.enqueue()
val status = workManager.getWorkInfoByIdLiveData(simpleRequest.id)
status.observe(this, Observer<WorkInfo> { info ->
val workFinished = info!!.state?.isFinished
simpleWorkStatusText.text = when (info.state) {
WorkInfo.State.SUCCEEDED,
WorkInfo.State.FAILED-> {
"work status: ${info.state}, finished: $workFinished"
}
else -> {
"work status: ${info.state}, finished: $workFinished"
}
}
})
}
WorkInfo.state
는 태스크의 상태를 나타내며 아래와 같은 상태들이 있습니다.
- ENQUEUED
- RUNNING
- SUCCEEDED
- FAILED
- BLOCKED
- CANCELLED
WorkInfo.state.isFinished
는 태스크의 상태가 SUCCEEDED, FAILED, CANCELLED 들 중 하나의 상태인지를 알려주는 함수입니다.
public boolean isFinished() {
return (this == SUCCEEDED || this == FAILED || this == CANCELLED);
}
앱을 실행해보면 태스크의 상태가 변할 때마다 UI가 업데이트 되는 것을 볼 수 있습니다.
Input data와 Output data
위에서 구현한 앱은 Worker에서 하드코딩된 숫자를 제곱하고, 태스크의 상태를 화면에 출력합니다. 하지만 Worker가 계산한 결과는 UI에 출력하지 않았습니다.
Worker에 인자로 숫자를(Input data) 전달하고, 그 숫자를 제곱하여 결과를(Output data) 화면에 출력하도록 변경하겠습니다.
인자와 결과 값은 Data 클래스로 전달됩니다.
Data 클래스는 Data.Builder
로 생성할 수 있습니다.
생성된 데이터 객체에 putInt
로 5를 넣었습니다.
이 Data 객체를 인자로 전달하려면 Request 객체에 setInputData
로 생성한 객체를 넣어주면 됩니다.
val inputData = Data.Builder()
.putInt(SimpleWorker.EXTRA_NUMBER, 5)
.build()
val simpleRequest = OneTimeWorkRequest.Builder(SimpleWorker::class.java)
.setInputData(inputData)
.build()
이렇게 전달된 인자는 Worker 객체에서 받을 수 있습니다.
inputData.getInt
를 이용하면 인자에 입력된 숫자를 가져올 수 있습니다.
number
변수에 인자로 받은 숫자를 설정하도록 하였습니다.
class SimpleWorker(context : Context, params : WorkerParameters)
: Worker(context, params) {
companion object {
const val EXTRA_NUMBER = "EXTRA_NUMBER"
const val EXTRA_RESULT = "EXTRA_RESULT"
}
override fun doWork(): Result {
val number = inputData.getInt(EXTRA_NUMBER, 0)
val result = number * number
SystemClock.sleep(5000)
Log.d("SimpleWorker", "SimpleWorker finished: $result")
return Result.success()
}
}
실행해보면 100이 아니라 25가 출력됩니다. 입력 값 5가 잘 전달되었고 이 숫자로 제곱을 한 것을 볼 수 있었습니다.
2018-12-23 20:21:36.973 8006-8038/com.jsandroid.myapplication D/SimpleWorker: SimpleWorker finished: 25
이제 결과를 MainActivity에 전달하여 UI에 출력해야 합니다.
결과도 Data 객체를 통해 전달합니다.
아래 코드를 보시면 Data.Builder
로 객체를 생성하고, Result.success(outputData)
로 결과 데이터를 인자로 넘겨주었습니다.
이렇게 전달하면 MainActivity는 WorkInfo를 통해서 결과 값을 받을 수 있습니다.
(Result.failure
도 동일하게 인자로 결과를 전달할 수 있습니다)
override fun doWork(): Result {
val number = inputData.getInt(EXTRA_NUMBER, 0)
val result = number * number
SystemClock.sleep(5000)
Log.d("SimpleWorker", "SimpleWorker finished: $result")
val outputData = Data.Builder()
.putInt(SimpleWorker.EXTRA_RESULT, result)
.build()
return Result.success(outputData)
}
UI에 결과를 출력하기 위해 옵저버 코드를 조금 변경하였습니다.
info.outputData.getInt
는 Worker에서 전달한 OutputData를 가져오는 코드입니다.
이 값을 문자로 출력하도록 문자열에 result: ${result}
를 추가하였습니다.
val status = workManager.getWorkInfoByIdLiveData(simpleRequest.id)
status.observe(this, Observer<WorkInfo> { info ->
val workFinished = info!!.state?.isFinished
val result = info?.outputData?.getInt(SimpleWorker.EXTRA_RESULT, 0)
simpleWorkStatusText.text = when (info.state) {
WorkInfo.State.SUCCEEDED,
WorkInfo.State.FAILED-> {
"work status: ${info.state}, result: ${result}, finished: $workFinished"
}
else -> {
"work status: ${info.state}, finished: $workFinished"
}
}
})
앱을 실행해보면 결과가 잘 출력이 됩니다.
Cancel
실행 중인 태스크를 종료해야 할 때도 있습니다.
WorkManager를 통해 모든 태스크를 취소하거나 또는 특정 ID나 TAG를 갖고 있는 태스크를 취소할 수 있습니다.
Request를 보시면 addTag(WORK_TAG)
로 Request에 특정 TAG를 붙일 수 있습니다.
그리고 simpleRequest.id
로 Request의 ID를 알 수 있습니다.
취소하는 코드를 보시면 cancelAllWorkByTag
, cancelWorkById
, cancelAllWork
등을 사용할 수 있습니다.
cancelSimpleWorkerBtn.setOnClickListener {
val workManager = WorkManager.getInstance()
workManager.cancelAllWorkByTag(WORK_TAG)
// workManager.cancelWorkById(simpleRequest.id)
// workManager.cancelAllWork()
}
startSimpleWorkerBtn.setOnClickListener {
val inputData = Data.Builder()
.putInt(SimpleWorker.EXTRA_NUMBER, 5)
.build()
val simpleRequest = OneTimeWorkRequest.Builder(SimpleWorker::class.java)
.setInputData(inputData)
.addTag(WORK_TAG)
.build()
val workManager = WorkManager.getInstance()
workManager.beginWith(simpleRequest)
.enqueue()
....
태스크 실행 중에 취소버튼을 누르면 태스크가 취소되고 결과에 CANCELLED로 출력됩니다.
Constraint(제약)
지금까지 구현한 태스크는 바로 실행되었습니다. 여기에 제약을 줄 수 있습니다. 만약 어떤 태스크는 처리할 양이 많기 때문에 사용자가 폰을 사용하지 않았을 때 동작했으면 할 때가 있습니다.
사용자가 잠을 잘 때 태스크가 동작했으면 더욱 좋겠네요. 하지만 잠을 잔다는 제약을 설정할 수는 없습니다. 대신 비슷하게 폰이 IDLE(대기)상태일 때, 충전 중일 때 동작하도록 할 수 있습니다.
아래 코드처럼 Constraints.Builder
로 Constraint객체를 만들 수 있습니다.
여기에 제약조건들을 추가할 수 있습니다.
여기서는 setRequiresDeviceIdle
, setRequiresCharging
로 두개의 제약조건을 걸었습니다.
Constraint도 setConstraints
로 Request에 추가할 수 있습니다.
val constraints = Constraints.Builder()
.setRequiresDeviceIdle(true)
.setRequiresCharging(true)
.build()
val simpleRequest = OneTimeWorkRequest.Builder(SimpleWorker::class.java)
.setInputData(inputData)
.addTag(WORK_TAG)
.setConstraints(constraints)
.build()
폰에 충전 중이 아닐 때 실행 버튼을 누르면 태스크가 ENQUEUED 상태에서 동작하지 않습니다. 이 때 폰에 충전 케이블을 꼽아도 바로 태스크가 RUNNING으로 변경되지 않는데요. WorkManager가 조건을 체크하는 주기가 있어서 조건이 충족된다고 바로 실행되지 않기 때문입니다.
Chaining Task
WorkManager는 여러 태스크를 순차적으로 실행할 수 있도록 기능을 제공합니다.
예를들어 simpleRequest
가 먼저 실행되고 simple2Request
가 실행되도록 순서를 설정할 수 있습니다.
가장 먼저 실행되는 태스크는 WorkManager.beginWith
로 설정하고, 그 다음 실행되는 태스크는 WorkManager.then
으로 설정할 수 있습니다.
val inputData = Data.Builder()
.putInt(SimpleWorker.EXTRA_NUMBER, 5)
.build()
val simpleRequest = OneTimeWorkRequest.Builder(SimpleWorker::class.java)
.setInputData(inputData)
.addTag(WORK_TAG)
.setConstraints(constraints)
.build()
val simple2Request = OneTimeWorkRequest.Builder(Simple2Worker::class.java)
.addTag(WORK_TAG)
.setConstraints(constraints)
.build()
workManager
.beginWith(simpleRequest)
.then(simple2Request)
.enqueue()
또한, 첫번째 태스크에서 실행한 결과를 두번째 태스크의 입력 값으로 전달할 수 있습니다.
SimpleWorker의 코드를 보면 결과를 OutputData으로 출력하였습니다.
Simple2Worker에서는 InputData에서 getInt(EXTRA_RESULT, 0)
로 데이터를 가져오는데,
이것은 SimpleWorker에서 전달된 OutputData입니다.
class SimpleWorker(context : Context, params : WorkerParameters)
: Worker(context, params) {
companion object {
const val EXTRA_NUMBER = "EXTRA_NUMBER"
const val EXTRA_RESULT = "EXTRA_RESULT"
}
override fun doWork(): Result {
val number = inputData.getInt(EXTRA_NUMBER, 0)
val result = number * number
SystemClock.sleep(5000)
Log.d("SimpleWorker", "SimpleWorker finished: $result")
val outputData = Data.Builder()
.putInt(SimpleWorker.EXTRA_RESULT, result)
.build()
return Result.success(outputData)
}
}
class Simple2Worker(context : Context, params : WorkerParameters)
: Worker(context, params) {
companion object {
const val EXTRA_NUMBER = "EXTRA_NUMBER"
const val EXTRA_RESULT = "EXTRA_RESULT"
}
override fun doWork(): Result {
val number = inputData.getInt(EXTRA_RESULT, 0)
val result = number * 2
SystemClock.sleep(5000)
Log.d("Simple2Worker", "Simple2Worker finished: $result")
val outputData = Data.Builder()
.putInt(Simple2Worker.EXTRA_RESULT, result)
.build()
return Result.success(outputData)
}
}
첫번째 태스크에 입력 값은 5이고 5*5 = 25
로 계산됩니다.
이 값이 두번째 태스크의 입력 값으로 전달되어 25 * 2 = 50
으로 계산될 것입니다.
태스크2의 상태를 UI로 출력하기 위해 MainActivity의 activity_main.xml
에 simpleWork2StatusText를 추가하였습니다.
<TextView
android:id="@+id/simpleWork2StatusText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="20sp"
android:text="work status:"/>
그리고 각각의 상태를 출력하도록 MainActivity에 코드를 추가하였습니다.
val status = workManager.getWorkInfoByIdLiveData(simpleRequest.id)
status.observe(this, Observer<WorkInfo> { info ->
val workFinished = info!!.state?.isFinished
val result = info?.outputData?.getInt(SimpleWorker.EXTRA_RESULT, 0)
simpleWorkStatusText.text = when (info.state) {
WorkInfo.State.SUCCEEDED,
WorkInfo.State.FAILED-> {
"work status: ${info.state}, result: ${result}, finished: $workFinished"
}
else -> {
"work status: ${info.state}, finished: $workFinished"
}
}
})
val status2 = workManager.getWorkInfoByIdLiveData(simple2Request.id)
status2.observe(this, Observer { info ->
val result = info?.outputData?.getInt(SimpleWorker.EXTRA_RESULT, 0)
simpleWork2StatusText.text = when (info.state) {
WorkInfo.State.SUCCEEDED,
WorkInfo.State.FAILED-> {
"work status: ${info.state}, result: $result"
}
else -> {
"work status: ${info.state}"
}
}
})
앱을 실행하면 의도한대로 결과가 출력된 것을 볼 수 있습니다.
좀 더 복잡한 Chaining Task
아래 그림처럼 좀 더 복잡한 Chaining 태스크도 설정할 수 있습니다. TASK4는 TASK1,2,3이 모두 완료되어야 실행됩니다. TASK5,6은 TASK4가 처리되어야 실행됩니다.
코드로 표현해보면, 다음과 같이 WorkManager에 작업을 추가하면 됩니다.
WorkManager.getInstance()
.beginWith(TASK1, TASK2, TASK3)
.then(TASK4)
.then(TASK5, TASK6)
.enqueue();
정리
WorkManager에 대해서 알아보았습니다. 최근 Alpha에서 Beta로 변경되었고, 코드도 조금 변경되었습니다. 아직 정식버전이 출시되지 않았기 때문에 자잘한 버그가 있을 수 있고, 앞으로 코드가 변경될 가능성이 있습니다. 이 부분을 염두해두시고 자신의 앱에 적용하시려면 충분한 테스트가 필요할 것 같습니다.
참고
- 이 글에서 만든 샘플은 GitHub에 있습니다.
- WorkManager - Android developer
- WorkManager tutorial - Raywenderlich
Related Posts
- Android 14 - 사진/동영상 파일, 일부 접근 권한 소개
- Android - adb push, pull로 파일 복사, 다운로드
- Android 14 - 암시적 인텐트 변경사항 및 문제 해결
- Jetpack Compose - Row와 Column
- Android 13, AOSP 오픈소스 다운로드 및 빌드
- Android 13 - 세분화된 미디어 파일 권한
- Android 13에서 Notification 권한 요청, 알림 띄우기
- Android 13에서 'Access blocked: ComponentInfo' 에러 해결
- 에러 해결: android gradle plugin requires java 11 to run. you are currently using java 1.8.
- 안드로이드 - 코루틴과 Retrofit으로 비동기 통신 예제
- 안드로이드 - 코루틴으로 URL 이미지 불러오기
- Android - 진동, Vibrator, VibrationEffect 예제
- Some problems were found with the configuration of task 에러 수정
- Query method parameters should either be a type that can be converted into a database column or a List
- 우분투에서 Android 12 오픈소스 다운로드 및 빌드
- Android - ViewModel을 생성하는 방법
- Android - Transformations.map(), switchMap() 차이점
- Android - Transformations.distinctUntilChanged() 소개
- Android - TabLayout 구현 방법 (+ ViewPager2)
- Android - 휴대폰 전화번호 가져오는 방법
- Android 12 - Splash Screens 알아보기
- Android 12 - Incremental Install (Play as you Download) 소개
- Android - adb 명령어로 bugreport 로그 파일 추출
- Android - adb 명령어로 App 데이터 삭제
- Android - adb 명령어로 앱 비활성화, 활성화
- Android - adb 명령어로 특정 패키지의 PID 찾기
- Android - adb 명령어로 퍼미션 Grant 또는 Revoke
- Android - adb 명령어로 apk 설치, 삭제
- Android - adb 명령어로 특정 패키지의 프로세스 종료
- Android - adb 명령어로 screen capture 저장
- Android - adb 명령어로 System 앱 삭제, 설치
- Android - adb 명령어로 settings value 확인, 변경
- Android 12 - IntentFilter의 exported 명시적 선언
- Android - adb 명령어로 공장초기화(Factory reset)
- Android - adb logcat 명령어로 로그 출력