HOME > android > jetpack

Android Jetpack - WorkManager 소개 및 구현 방법

JSFollow22 Dec 2018

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를 실행하도록 처리할 수 있습니다. 또는 아래 그림처럼 복잡한 태스크 실행 관계를 정의할 수 있습니다.

android jetpack workmanager

태스크가 실행되는 주기와 조건도(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

RequestWorkManager에 할 일을 요청하는 기본 객체입니다. 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

android jetpack workmanager

WorkInfo

WorkInfo는 태스크의 상태를 나타내는 클래스입니다. WorkManager는 LiveData로 WorkInfo를 제공해주기 때문에 옵저버를 붙여 상태를 모니터링 할 수 있습니다.

getWorkInfoByIdLiveData로 Request의 id를 넘겨주면 LiveData를 리턴받을 수 있습니다. 여기에 옵저버를 붙여 아래 코드처럼 사용할 수 있습니다. (LiveData에 대해서 잘 모르신다면 먼저 Android Jetpack 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가 업데이트 되는 것을 볼 수 있습니다.

android jetpack workmanager

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"
        }
    }
})

앱을 실행해보면 결과가 잘 출력이 됩니다.

android jetpack workmanager

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로 출력됩니다.

android jetpack workmanager

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가 조건을 체크하는 주기가 있어서 조건이 충족된다고 바로 실행되지 않기 때문입니다.

android jetpack workmanager

Chaining Task

WorkManager는 여러 태스크를 순차적으로 실행할 수 있도록 기능을 제공합니다. 예를들어 simpleRequest가 먼저 실행되고 simple2Request가 실행되도록 순서를 설정할 수 있습니다.

android jetpack workmanager

가장 먼저 실행되는 태스크는 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}"
        }
    }
})

앱을 실행하면 의도한대로 결과가 출력된 것을 볼 수 있습니다.

android jetpack workmanager

좀 더 복잡한 Chaining Task

아래 그림처럼 좀 더 복잡한 Chaining 태스크도 설정할 수 있습니다. TASK4는 TASK1,2,3이 모두 완료되어야 실행됩니다. TASK5,6은 TASK4가 처리되어야 실행됩니다.

android jetpack workmanager

코드로 표현해보면, 다음과 같이 WorkManager에 작업을 추가하면 됩니다.

WorkManager.getInstance()
      .beginWith(TASK1, TASK2, TASK3)
      .then(TASK4)
      .then(TASK5, TASK6)
      .enqueue();

정리

WorkManager에 대해서 알아보았습니다. 최근 Alpha에서 Beta로 변경되었고, 코드도 조금 변경되었습니다. 아직 정식버전이 출시되지 않았기 때문에 자잘한 버그가 있을 수 있고, 앞으로 코드가 변경될 가능성이 있습니다. 이 부분을 염두해두시고 자신의 앱에 적용하시려면 충분한 테스트가 필요할 것 같습니다.

참고