Android/이론 학습

[Android] WorkManager 로 복잡한 백그라운드 작업을 쉽게 해결한다고? (기본 사용법과 예시코드 포함)

노소래 2022. 2. 23. 14:03

 

[공부하게된 계기]

안드로이드 공부를 시작하고 스레드를 이용한 적은 있지만 앱 프로세스가 죽어서도 백그라운드에서 작업하게 할 일이 없었다.

하지만 최근 타이머 관련 오픈소스 작업을 시도하며, 그리고 과제전형 문제를 풀면서도 백그라운드 작업을 공부하게 되었는데 백그라운드 관련 라이브러리 중에서 최근 가장 핫한 WorkManager 를 공부해야겠다고 생각했다.  


[그래서 WorkManager 뭘 하는 건가?]

백그라운드 세 가지 작업종류의 persistent work 를 다루는데 추천되는 라이브러리이다.

백그라운드 작업의 종류에 관해서는 내가 전에 써놓은 아래링크를 참고하면 된다.

 

[Android] 안드로이드의 Background 작업

Background 작업이 필요할 때! UI 스레드로 실행이 오래걸리는 작업을 진행한다면 UI 블로킹으로 좋지 못한 UX 를 제공할 것이다. 따라서 bitmap 을 디코딩한다든지, 네트워크 요청이나 저장소에 접근

nosorae.tistory.com

persistent 하다는 말은 work 가 스케줄되면 앱이 재시작하거나 시스템이 재부팅해도 지속된다는 것이다.

즉 앱이 종료되거나 기기가 다시 시작되어도 실행예정이고, 지연 가능한 비동기 작업을 쉽게 예약해주는 것이 WorkManager 이다.

Android 에서 권장되는 작업 스케줄러로, 지연 간으한 작업을 실행하도록 보장해준다.


[장점]

예전에는 위 기능을 개발자가 API 레벨에 따라 다르게 구현해주어야해서 성가셨는데 (API 23 이상은 JobScheduler, 미만은 BroadcastReceiver + AlarmManager)

WorkManager 가 이를 내부적으로 처리해준다는 장점이 있다.


[언제 쓸까?]

 


[WorkManager 의 특징]

  • Work constraints 로 work 가 기기 상태에 따라 최적의 조건에서 실행되도록 정의할 수 있다.
  • work 가 한 번만 실행되게 할 수도 있고, 반복적으로 실행되게 할 수도 있다.
  • work 는 태그될 수 있어서, work 를 특정짓고 대체가능하게 스케줄 할 수 있고, 취소할 수있다.
  • 스케줄된 work 는 내부적으로 SQLite database 와 WorkManager 에서 관리된다.
  • 동시 작업 실해을 포함한 복잡한 작업 요청 체이닝
  • 한 작업 요청의 출력이 다음 작업 요청의 입력으로 사용할 수 있음
  • Google Play 서비스 사용 여부와 관계없이 작동
  • LiveData 지원
  • JobScheduler 및 AlarmManager 등 몇 가지 API 위에 있다.
  • exponentail backoff policy 를 포함한, 유연한 재시도 정책을 제공하고 

[지속적인 작업 유형 세 가지]

  • Immediate: 즉시 시작하고, 한 번만, 신속하게 처리될 수 있는 일
    OneTimeWorkRequest 와 Worker 로 접근할 수 있고
    신속 작업 처리의 경우 setExpedited 를 호출한다. 
  • Long Running : 10분 이상 더 오래 실행될 수 있는 작업
    모든 WorkRequst 또는 Worker 로 접근할 수 있다.
    알림을 처리하고 싶다면 setForeground() 를 호출한다.
  • Defferable : 나중에 시작하여 주기적으로 실행될 수 있는 예약된 작업
    PeriodicWorkRequest 및 Worker 로 접근할 수 있다.


[기본 사용법]

  • Worker 클래스 를 상속한 클래스를 만든다. 
  • 해야할 일을 doWork 를 오버라이딩 하여 작성한다.
  • WorkManager.getInstance(context) 로 싱글톤 인스턴스를 찾는다.
  • .enqueue 를 호출한다. .enqueue(~Request.from(WorkerName::class.java) 와 같이 리퀘스트 인자를 넣어준다.
  •  다른 설정을 하고 싶다면 ~RequestBuilder<> 의 지네릭스 타입에 WorkerName 을 넣고, Builder 패턴으로 ~RequestBuilder<> 의 메소드를 연달아 호출한 후, 마지막에 .build 로 객체를 생성해준 다음 WorkManager 객체의 인스턴스의 enqueue 인자로 전달하면 된다.
  • 예를들어  WorkRequest 에 입력을 추가할 수 있는데 Data 를 ~RequestBuilder 에 넣어주는 메소드를 호출해서 넣어주는 방법이 있다. Data 도 마찬가지로 builder 패턴으로 만들면된다.
	// 위 내용에 대한 예시코드
 
 
 	// ViewModel 클래스
   	...
    
    private fun timerWorkerTest() {
        workManager.enqueue(setDataRequestTest())
    }

    private fun setDataRequestTest() =
        OneTimeWorkRequestBuilder<TestWorker>()
            .setInputData(createDataTest())
            .build()

    private fun createDataTest() =
        Data
            .Builder()
            .putString(KEY_TEST_WORKER_DATA, "시간이 다 되었어요.")
            .build()
	...

// Worker 클래스
 class TestWorker(context: Context, params: WorkerParameters): Worker(context, params) {
 
    override fun doWork(): Result {
        val text = inputData.getString(KEY_TEST_WORKER_DATA)
        Log.d("TimerWorker", "TimerWorker 호출됨 : $text") // 로그가 잘 찍힘을 확인했다.
        return Result.success()
    }
    
}

 

  • Result.success 메소드의 인자로 Data 객체를 넣어줌으로서 출력데이터를 추가할 수 있다.
  • 또한 WorkRequest 를 체인으로 실행하도록 하고 싶으면 WorkManager.enqueue 메소드가 아니라 WorkManager.beginWith 와 then 메소드를 활용해줘야한다. 이가 가능한 이유는 beginWith 와 then 모두 WorkContinuation 인스턴스를 반환하기 때문이다.
   // ViewModel

	...
   private fun timerWorkerChainTest() {
        workManager
            .beginWith(
                OneTimeWorkRequestBuilder<TestWorker>()
                    .setInputData(createDataByStringTest("1"))
                    .build()
            )
            .then(OneTimeWorkRequest.from(TestWorker::class.java)) // Data 전달 없음 주의
            .then(OneTimeWorkRequest.from(TestWorker::class.java)) // 이전 Worker 의 output data 를 사용한다.
            .enqueue() // 실질적인 work 의 시작은 enqueue
    }

    private fun createDataByStringTest(input: String) =
        Data
            .Builder()
            .putString(KEY_TEST_WORKER_DATA, input)
            .build()
            
	...
    
    
    // Worker
    
    class TestWorker(context: Context, params: WorkerParameters): Worker(context, params) {
    override fun doWork(): Result {
		// Worker 에 집중하기 위해 try catch 문 생략
		val number = inputData.getString(KEY_TEST_WORKER_DATA)?.toInt()
        Log.d("TestWorker", "TestWorker 호출됨 : $number")
        val outputData = workDataOf(KEY_TEST_WORKER_DATA to "${number!! + 1}")
        return Result.success(outputData)
    }
}

그럼 아래와 같이 로그가 찍힌다.

 TestWorker 호출됨 : 1
 TestWorker 호출됨 : 2
 TestWorker 호출됨 : 3

 

  • 같은 체인을 여러번 사용할 때 한 번에 하나의 체인만 실행시키고 싶다면 beginUniqueWork 메소드를 사용한다. d인자는 체인 이름, ExistingWorkPolicy enum 값 그리고 beginWith 에 넣어주었던 Request 이다.
  • ~RequestBuilder<>() 의 build() 전에 addTag(string) 을 넣어줌으로서 태그를 달 수 있다. 그리고 그 chain 을 구분할 수 있다. 
  • WorkManager 객체의 getWorkInfoByIdLiveData, getWorkInfosForUniqueWorkLiveData, getWorkInfosByTagLiveData 메소드를 통해 LiveData<WorkInfo> or LiveData<List<WorkInfo>> 객체를 가져올 수 있고 observe 할 수 있다. (WorkInfo 는 WorkRequest 의 현재 상태에 대한 자세한 정보를 담고 있는 객체이다. work 의 현재상태를 알 수 있고, WorkRequest 가 끝났을 때 그 Work 로부터의 output data 를 얻을 수 있다.  
    workInfo.state.isFinished 로 끝난지 알 수 있다. 그리고 workInfo.outputData.getString 으로 데이터를 가져오는 방법이 있다.
  • 아래는 재미로 찍어본 로그 코드와 그 결과이다.
// ViewModel

...

 	internal val workInfosByTag: LiveData<List<WorkInfo>>
    internal val workInfosByChainName: LiveData<List<WorkInfo>>

    companion object {
        const val WORK_CHAIN_NAME = "work_chain_name"
        const val WORK_INFO_BY_TAG = "workInfo by tag"
    }

    init {
        getCoins()
        //timerWorkerChainTest()
        workInfosByChainName = workManager.getWorkInfosForUniqueWorkLiveData(WORK_CHAIN_NAME)
        workInfosByTag = workManager.getWorkInfosByTagLiveData(WORK_INFO_BY_TAG)
    }

    fun timerWorkerChainTest() {
        workManager
            .beginUniqueWork(
                WORK_CHAIN_NAME,
                ExistingWorkPolicy.REPLACE,
                OneTimeWorkRequestBuilder<TestWorker>()
                    .addTag(WORK_INFO_BY_TAG)
                    .setInputData(createDataByStringTest("1"))
                    .build()
            )
            .then(
                OneTimeWorkRequestBuilder<TestWorker>()
                    .addTag(WORK_INFO_BY_TAG)
                    .build()
            )
            .then(
                OneTimeWorkRequestBuilder<TestWorker>()
                    .addTag(WORK_INFO_BY_TAG)
                    .build()
            ) // 반복문으로 쓸 수도 있음! 보여주기식이라 비효율적이게 보일 수 있다.
            .enqueue() // 실질적인 work 의 시작은 enqueue
    }

    private fun createDataByStringTest(input: String) =
        Data
            .Builder()
            .putString(KEY_TEST_WORKER_DATA, input)
            .build()



...


// Activity onCreate 
...
		viewModel.workInfosByTag.observe(this) { listOfWorkInfo ->
            Log.e(LOG_TAG_TEST_WORKER, "workInfosByTag :\n ${listOfWorkInfo.joinToString("\n") { "$it" }}")
        }

        viewModel.workInfosByChainName.observe(this) { listOfWorkInfo ->
            Log.e(LOG_TAG_TEST_WORKER, "workInfosByChainName :\n ${listOfWorkInfo.joinToString("\n") { "$it" }}")
        }

        binding.btEnqueueWork.setOnClickListener {
            viewModel.timerWorkerChainTest()
        }
...



//Worker

class TestWorker(context: Context, params: WorkerParameters): Worker(context, params) {
    override fun doWork(): Result {
        sleep(5000L) // 로그를 천천히 보려고 넣었다.
        val number = inputData.getString(KEY_TEST_WORKER_DATA)?.toInt()
        Log.e(LOG_TAG_TEST_WORKER, "TestWorker 호출됨 : $number")
        val outputData = workDataOf(KEY_TEST_WORKER_DATA to "${number!! + 1}")
        return Result.success(outputData)
    }
}
E/log tag test worker: workInfosByTag :
     WorkInfo{mId='2d776695-af9e-4d55-9608-dc20c9a43478', mState=RUNNING, mOutputData=Data {}, mTags=[workInfo by tag, com.nosorae.labs.clean_architecture.hilt.background.work_manager.work.TestWorker], mProgress=Data {}}
    WorkInfo{mId='8ce586c3-c04a-4156-af9b-e0ac6e976ea4', mState=BLOCKED, mOutputData=Data {}, mTags=[workInfo by tag, com.nosorae.labs.clean_architecture.hilt.background.work_manager.work.TestWorker], mProgress=Data {}}
    WorkInfo{mId='c02b2a21-4df6-49a0-bcc7-e6e2bd365605', mState=BLOCKED, mOutputData=Data {}, mTags=[workInfo by tag, com.nosorae.labs.clean_architecture.hilt.background.work_manager.work.TestWorker], mProgress=Data {}}
E/log tag test worker: workInfosByChainName :
     WorkInfo{mId='2d776695-af9e-4d55-9608-dc20c9a43478', mState=RUNNING, mOutputData=Data {}, mTags=[workInfo by tag, com.nosorae.labs.clean_architecture.hilt.background.work_manager.work.TestWorker], mProgress=Data {}}
    WorkInfo{mId='8ce586c3-c04a-4156-af9b-e0ac6e976ea4', mState=BLOCKED, mOutputData=Data {}, mTags=[workInfo by tag, com.nosorae.labs.clean_architecture.hilt.background.work_manager.work.TestWorker], mProgress=Data {}}
    WorkInfo{mId='c02b2a21-4df6-49a0-bcc7-e6e2bd365605', mState=BLOCKED, mOutputData=Data {}, mTags=[workInfo by tag, com.nosorae.labs.clean_architecture.hilt.background.work_manager.work.TestWorker], mProgress=Data {}}
E/log tag test worker: TestWorker 호출됨 : 1
I/WM-WorkerWrapper: Worker result SUCCESS for Work [ id=2d776695-af9e-4d55-9608-dc20c9a43478, tags={ workInfo by tag, com.nosorae.labs.clean_architecture.hilt.background.work_manager.work.TestWorker } ]
I/WM-WorkerWrapper: Setting status to enqueued for c02b2a21-4df6-49a0-bcc7-e6e2bd365605
E/log tag test worker: workInfosByTag :
     WorkInfo{mId='2d776695-af9e-4d55-9608-dc20c9a43478', mState=SUCCEEDED, mOutputData=Data {key_timer_worker_data : 2, }, mTags=[workInfo by tag, com.nosorae.labs.clean_architecture.hilt.background.work_manager.work.TestWorker], mProgress=Data {}}
    WorkInfo{mId='8ce586c3-c04a-4156-af9b-e0ac6e976ea4', mState=BLOCKED, mOutputData=Data {}, mTags=[workInfo by tag, com.nosorae.labs.clean_architecture.hilt.background.work_manager.work.TestWorker], mProgress=Data {}}
    WorkInfo{mId='c02b2a21-4df6-49a0-bcc7-e6e2bd365605', mState=ENQUEUED, mOutputData=Data {}, mTags=[workInfo by tag, com.nosorae.labs.clean_architecture.hilt.background.work_manager.work.TestWorker], mProgress=Data {}}
E/log tag test worker: workInfosByChainName :
     WorkInfo{mId='2d776695-af9e-4d55-9608-dc20c9a43478', mState=SUCCEEDED, mOutputData=Data {key_timer_worker_data : 2, }, mTags=[workInfo by tag, com.nosorae.labs.clean_architecture.hilt.background.work_manager.work.TestWorker], mProgress=Data {}}
    WorkInfo{mId='8ce586c3-c04a-4156-af9b-e0ac6e976ea4', mState=BLOCKED, mOutputData=Data {}, mTags=[workInfo by tag, com.nosorae.labs.clean_architecture.hilt.background.work_manager.work.TestWorker], mProgress=Data {}}
    WorkInfo{mId='c02b2a21-4df6-49a0-bcc7-e6e2bd365605', mState=RUNNING, mOutputData=Data {}, mTags=[workInfo by tag, com.nosorae.labs.clean_architecture.hilt.background.work_manager.work.TestWorker], mProgress=Data {}}
E/log tag test worker: workInfosByTag :
     WorkInfo{mId='2d776695-af9e-4d55-9608-dc20c9a43478', mState=SUCCEEDED, mOutputData=Data {key_timer_worker_data : 2, }, mTags=[workInfo by tag, com.nosorae.labs.clean_architecture.hilt.background.work_manager.work.TestWorker], mProgress=Data {}}
    WorkInfo{mId='8ce586c3-c04a-4156-af9b-e0ac6e976ea4', mState=BLOCKED, mOutputData=Data {}, mTags=[workInfo by tag, com.nosorae.labs.clean_architecture.hilt.background.work_manager.work.TestWorker], mProgress=Data {}}
    WorkInfo{mId='c02b2a21-4df6-49a0-bcc7-e6e2bd365605', mState=RUNNING, mOutputData=Data {}, mTags=[workInfo by tag, com.nosorae.labs.clean_architecture.hilt.background.work_manager.work.TestWorker], mProgress=Data {}}
E/log tag test worker: TestWorker 호출됨 : 2
I/WM-WorkerWrapper: Worker result SUCCESS for Work [ id=c02b2a21-4df6-49a0-bcc7-e6e2bd365605, tags={ workInfo by tag, com.nosorae.labs.clean_architecture.hilt.background.work_manager.work.TestWorker } ]
I/WM-WorkerWrapper: Setting status to enqueued for 8ce586c3-c04a-4156-af9b-e0ac6e976ea4
E/log tag test worker: workInfosByTag :
     WorkInfo{mId='2d776695-af9e-4d55-9608-dc20c9a43478', mState=SUCCEEDED, mOutputData=Data {key_timer_worker_data : 2, }, mTags=[workInfo by tag, com.nosorae.labs.clean_architecture.hilt.background.work_manager.work.TestWorker], mProgress=Data {}}
    WorkInfo{mId='8ce586c3-c04a-4156-af9b-e0ac6e976ea4', mState=ENQUEUED, mOutputData=Data {}, mTags=[workInfo by tag, com.nosorae.labs.clean_architecture.hilt.background.work_manager.work.TestWorker], mProgress=Data {}}
    WorkInfo{mId='c02b2a21-4df6-49a0-bcc7-e6e2bd365605', mState=SUCCEEDED, mOutputData=Data {key_timer_worker_data : 3, }, mTags=[workInfo by tag, com.nosorae.labs.clean_architecture.hilt.background.work_manager.work.TestWorker], mProgress=Data {}}
E/log tag test worker: workInfosByChainName :
     WorkInfo{mId='2d776695-af9e-4d55-9608-dc20c9a43478', mState=SUCCEEDED, mOutputData=Data {key_timer_worker_data : 2, }, mTags=[workInfo by tag, com.nosorae.labs.clean_architecture.hilt.background.work_manager.work.TestWorker], mProgress=Data {}}
    WorkInfo{mId='8ce586c3-c04a-4156-af9b-e0ac6e976ea4', mState=RUNNING, mOutputData=Data {}, mTags=[workInfo by tag, com.nosorae.labs.clean_architecture.hilt.background.work_manager.work.TestWorker], mProgress=Data {}}
    WorkInfo{mId='c02b2a21-4df6-49a0-bcc7-e6e2bd365605', mState=SUCCEEDED, mOutputData=Data {key_timer_worker_data : 3, }, mTags=[workInfo by tag, com.nosorae.labs.clean_architecture.hilt.background.work_manager.work.TestWorker], mProgress=Data {}}
E/log tag test worker: workInfosByTag :
     WorkInfo{mId='2d776695-af9e-4d55-9608-dc20c9a43478', mState=SUCCEEDED, mOutputData=Data {key_timer_worker_data : 2, }, mTags=[workInfo by tag, com.nosorae.labs.clean_architecture.hilt.background.work_manager.work.TestWorker], mProgress=Data {}}
    WorkInfo{mId='8ce586c3-c04a-4156-af9b-e0ac6e976ea4', mState=RUNNING, mOutputData=Data {}, mTags=[workInfo by tag, com.nosorae.labs.clean_architecture.hilt.background.work_manager.work.TestWorker], mProgress=Data {}}
    WorkInfo{mId='c02b2a21-4df6-49a0-bcc7-e6e2bd365605', mState=SUCCEEDED, mOutputData=Data {key_timer_worker_data : 3, }, mTags=[workInfo by tag, com.nosorae.labs.clean_architecture.hilt.background.work_manager.work.TestWorker], mProgress=Data {}}
E/log tag test worker: TestWorker 호출됨 : 3
I/WM-WorkerWrapper: Worker result SUCCESS for Work [ id=8ce586c3-c04a-4156-af9b-e0ac6e976ea4, tags={ workInfo by tag, com.nosorae.labs.clean_architecture.hilt.background.work_manager.work.TestWorker } ]
E/log tag test worker: workInfosByTag :
     WorkInfo{mId='2d776695-af9e-4d55-9608-dc20c9a43478', mState=SUCCEEDED, mOutputData=Data {key_timer_worker_data : 2, }, mTags=[workInfo by tag, com.nosorae.labs.clean_architecture.hilt.background.work_manager.work.TestWorker], mProgress=Data {}}
    WorkInfo{mId='8ce586c3-c04a-4156-af9b-e0ac6e976ea4', mState=SUCCEEDED, mOutputData=Data {key_timer_worker_data : 4, }, mTags=[workInfo by tag, com.nosorae.labs.clean_architecture.hilt.background.work_manager.work.TestWorker], mProgress=Data {}}
    WorkInfo{mId='c02b2a21-4df6-49a0-bcc7-e6e2bd365605', mState=SUCCEEDED, mOutputData=Data {key_timer_worker_data : 3, }, mTags=[workInfo by tag, com.nosorae.labs.clean_architecture.hilt.background.work_manager.work.TestWorker], mProgress=Data {}}
E/log tag test worker: workInfosByChainName :
     WorkInfo{mId='2d776695-af9e-4d55-9608-dc20c9a43478', mState=SUCCEEDED, mOutputData=Data {key_timer_worker_data : 2, }, mTags=[workInfo by tag, com.nosorae.labs.clean_architecture.hilt.background.work_manager.work.TestWorker], mProgress=Data {}}
    WorkInfo{mId='8ce586c3-c04a-4156-af9b-e0ac6e976ea4', mState=SUCCEEDED, mOutputData=Data {key_timer_worker_data : 4, }, mTags=[workInfo by tag, com.nosorae.labs.clean_architecture.hilt.background.work_manager.work.TestWorker], mProgress=Data {}}
    WorkInfo{mId='c02b2a21-4df6-49a0-bcc7-e6e2bd365605', mState=SUCCEEDED, mOutputData=Data {key_timer_worker_data : 3, }, mTags=[workInfo by tag, com.nosorae.labs.clean_architecture.hilt.background.work_manager.work.TestWorker], mProgress=Data {}}
  • Work 를 취소하는 것은 WorkInfo 를 담은 LiveData 를 가져오는 것과 마찬가지로 id or tag or unique chain name 으로 할 수 있다.  WorkManager.cancel~
  • Constraints 를 만들고 적용하는 법
    Constraints.Builder() 의 set~ 메소드를 호출하고 build() 해준다.
    그 후 ~RequestBuilder setConstraint 에 앞서 만든 Constraints 를 넣어주면 된다.

 

 

[Reference]​

 

WorkManager

WorkManager 사용법과 원리

medium.com

 

WorkManager를 사용한 백그라운드 작업 - Kotlin  |  Android 개발자  |  Android Developers

WorkManager는 예외적인 사례와 호환성 문제를 처리합니다. 또한 쿼리 가능하고 재사용할 수 있으며 체이닝할 수 있는 작업을 만들 수 있습니다. WorkManager는 Android에서 권장되는 작업 스케줄러입니

developer.android.google.cn

CodeLabs