워크매니저의 멀티 프로세스
feat. Leakcanary가 WorkManager 다중 프로세스를 활용하는 방법
이 글은 아래 링크를 번역한 글입니다
https://py.hashnode.dev/workmanager-multi-process-for-libraries
WorkManager
라는 라이브러리는 원격 프로세스 작업을 스케줄링해서 사용할 수 있다는 걸 알고 있습니까? 앞으로의 내용은 WorkManager을 이용해서 멀티 프로세스를 하는 법을 설명할 것입니다.
LeakCanary는 메모리 누수를 잡아주는 라이브러리
또한, LeakCanary2.8에서는 포그라운드 서비스에 의존하는 걸 멈추고 WorkManager
를 이용해서 힙 분석을 합니다. 호스팅 앱(개인이나 기업들을 대상으로 모바일 서비스를 제공하는데 필요한 앱이나 관리시스템, 서버·네트워크 등을 통합 서비스)의 메모리 pressure을 제한하기 위해 LeakCanary는 별도의 프로세스로 분석을 실행할 수 있도록 지원합니다.
라이브러리는 가능한 경우 consumer에게 종속성을 강제로 낮추지 않아야 합니다.
먼저 dependencies
에 아래와 같은 코드를 추가합니다.
https://developer.android.com/studio/build/dependencies?hl=ko
종속성을 compileOnly로 추가해서 Maven Central에 게시된 pom.xml에 나타나는 의존성 없이 WorkManager API에 대한 코드를 작성할 수 있습니다.
(complieOnly
는 컴파일 클래스 경로에만 종속 항목을 추가한다는 뜻으로, 이 방법은 중요하지 않은 일시적인 종속 항목을 추가하지 않으므로 최종 APK의 크기를 줄이는 데 도움이 됩니다. 여기서 Maven Central pom.xml은 Maven Server Repository로 보통 android lib를 가져올 때 이를 통한다고 이해하면 쉽습니다.)
dependencies {
// Optional dependencies
// Note: using the Java artifact because the Kotlin one bundles coroutines.
compileOnly 'androidx.work:work-runtime:2.7.0'
compileOnly 'androidx.work:work-multiprocess:2.7.0'
}
그리고 나서 WorkManager
클래스에 대한 런타임을 확인하면 됩니다.
val workManagerInClasspath by lazy {
try {
Class.forName("androidx.work.WorkManager")
true
} catch (ignored: Throwable) {
false
}
}
WorkManager
를 사용하지 않고 백그라운드 스레드로 구현한다면 아래와 같습니다.
class MyWorkScheduler(private val application: Application) {
val workManagerInClasspath = // ...
val backgroundHandler by lazy {
val handlerThread = HandlerThread("Background worker thread")
handlerThread.start()
Handler(handlerThread.looper)
}
fun enqueueWork() {
if (workManagerInClasspath) {
enqueueOnWorkManager()
} else {
enqueueOnBackgroundThread()
}
}
private fun enqueueOnWorkManager() {
val request = OneTimeWorkRequest.Builder(MyWorker::class.java)
.build()
WorkManager.getInstance(application).enqueue(request)
}
private fun enqueueOnBackgroundThread() {
backgroundHandler.post {
TODO("perform the work")
}
}
}
class MyWorker(
appContext: Context,
workerParams: WorkerParameters
) : Worker(appContext, workerParams) {
override fun doWork(): Result {
TODO("perform the work")
return Result.success()
}
}
지금까지 우리는 WorkManager 코드를 표준적으로 사용하고 있습니다. WorkManager Configuration은 한 번만 설정할 수 있기 때문에 의도적으로 설정을 회피하고 있으며, 개발자들이 WorkManager를 자신의 목적대로 사용할 수 있습니다.
WorkManager 2.7.0은 안드로이드 12를 위해 도입된 빠른 작업의 개념을 도입했습니다. 새로운 API를 활용하는 것이 이상적이지만 종속성 업그레이드를 강제하는 것은 원치 않으므로 런타임 검사를 하나 더 추가하겠습니다.
class MyWorkScheduler {
// ...
// setExpedited() requires WorkManager 2.7.0+
private val workManagerSupportsExpeditedRequests by lazy {
try {
Class.forName("androidx.work.OutOfQuotaPolicy")
true
} catch (ignored: Throwable) {
false
}
}
private fun enqueueOnWorkManager() {
val request = OneTimeWorkRequest.Builder(MyWorker::class.java).apply {
if (workManagerSupportsExpeditedRequests) {
setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
}
}.build()
WorkManager.getInstance(application).enqueue(request)
}
}
참고로,
OneTimeWorkRequest
은 상태변경 (unset expedited, remove a tag...)을 취소할 수 없습니다.
메인 앱 프로세스(e.g com.example
) 에서 작업을 예약하고 해당 작업은 별도의 프로세스(e.g com.example:mywork
)에서 실행되어야 합니다.
먼저 :mywork
프로세스에서 실행될 RemoteWorkerService를 등록합니다.
class MyRemoteWorkerService : RemoteWorkerService()
참고: 구성 요소 이름이 앱마다 고유하기 때문에 RemoteWorkerService 하위 클래스를 등록합니다. 따라서 사용 중인 앱이 RemoteWorkerService를 매니페스트에 이미 등록한 경우 충돌을 방지할 수 있습니다. RemoteWorkerService 클래스는 추상적이어야 합니다.
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example">
<application>
<service
android:name=".MyRemoteWorkerService"
android:exported="false"
android:process=":mywork" />
</manifest>
원격 프로세스는 동일한 APK를 사용하므로 원격 작업자는 원격 작업자가 아닌 이전의 작업자와 상당히 동일해야 합니다.
class MyRemoteWorker(
appContext: Context,
workerParams: WorkerParameters
) : RemoteWorker(appContext, workerParams) {
override fun doWork(): Result {
TODO("perform the work")
return Result.success()
}
}
안타깝게도 RemoteWorker
클래스는 존재하지 않고 RemoteListenableWorker
만 있습니다. 그래도 Worker가 ListenableWorker를 확장하는 방법과 마찬가지로 RemoteWorker를 확장하는 RemoteWorker를 만들 수 있습니다.
abstract class RemoteWorker(
context: Context,
workerParams: WorkerParameters
) : RemoteListenableWorker(context, workerParams) {
abstract fun doWork(): Result
override fun startRemoteWork(): ListenableFuture<Result> {
val future = SettableFuture.create<Result>()
backgroundExecutor.execute {
try {
val result = doWork()
future.set(result)
} catch (throwable: Throwable) {
future.setException(throwable)
}
}
return future
}
}
왜 Worker가 제공되는지 모르겠지만 RemoteWorker는 제공되지 않습니다. API를 차단하면 취소가 지원되지 않는 구현이 발생하기 때문일 수 있습니다(여기서와 마찬가지로). 또 다른 점은 원격 구현과 비원격 구현의 차이가 거의 없기 때문에 작업을 예약할 때 단일 작업자 클래스를 정의하고 실행할 위치를 결정할 수 있으면 좋겠다는 것입니다.
원격 작업 예약은 작업 요청의 일부로 원격 서비스에 대한 구성 요소 이름을 제공해야 한다는 점을 제외하면 거의 동일합니다.
class MyWorkScheduler {
// ...
private val remoteWorkerServiceInClasspath by lazy {
try {
Class.forName("androidx.work.multiprocess.RemoteWorkerService")
true
} catch (ignored: Throwable) {
false
}
}
fun enqueueWork() {
if (remoteWorkerServiceInClasspath) {
enqueueOnWorkManagerRemote()
} else if (workManagerInClasspath) {
enqueueOnWorkManager()
} else {
enqueueOnBackgroundThread()
}
}
private fun enqueueOnWorkManagerRemote() {
val request = OneTimeWorkRequest.Builder(MyRemoteWorker::class.java).apply {
putString(ARGUMENT_PACKAGE_NAME, application.packageName)
putString(ARGUMENT_CLASS_NAME, "com.example.MyRemoteWorkerService")
if (workManagerSupportsExpeditedRequests) {
setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
}
}.build()
WorkManager.getInstance(application).enqueue(request)
}
}
물론 아래와 같은 예외가 발생할 수 있습니다.
java.lang.IllegalStateException: WorkManager is not initialized properly.
You have explicitly disabled WorkManagerInitializer in your manifest,
have not manually called WorkManager#initialize at this point, and your
Application does not implement Configuration.Provider.
at androidx.work.impl.WorkManagerImpl.getInstance(WorkManagerImpl.java:158)
at androidx.work.multiprocess.ListenableWorkerImpl.<init>(ListenableWorkerImpl.java:72)
at androidx.work.multiprocess.RemoteWorkerService.onCreate(RemoteWorkerService.java:37)
at android.app.ActivityThread.handleCreateService(ActivityThread.java:4487)
at android.app.ActivityThread.access$1700(ActivityThread.java:247)
at android.app.ActivityThread$H.handleMessage(ActivityThread.java:2072)
at android.os.Handler.dispatchMessage(Handler.java:106)
at android.os.Looper.loopOnce(Looper.java:201)
at android.os.Looper.loop(Looper.java:288)
at android.app.ActivityThread.main(ActivityThread.java:7839)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:548)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:1003)
초기화에 대한 예외사항은 왜 나오는 걸까요?
예를 들어, RemoteWorkerService.onCreate()
가 com.example:mywork
프로세스에서 실행될 때 WorkManager는 이미 초기화되어 있어야 합니다. 메인 프로세스에서는 androidx startup library가 자동으로 수행합니다. 그러나 startup initializers는 다른 프로세스에서 실행되지 않습니다
라이브러리 개발자로서 응용프로그램 클래스에 액세스할 수 없으므로 구성을 구현할 수 없습니다. WorkManager.initialize()를 호출하거나 호출합니다.
WorkManager를 init하는 다른 방법을 찾을 수 있지만 WorkManager 초기화는 단 한 번만 발생할 수 있으므로 개발자가 사용자 지정 WorkManager 초기화를 설정한다면 나의 init는 그들의 init와 충돌할 것이다. 안타깝게도 WorkManager.isInitialized() 또는 WorkManager.getInstanceAndInitIfNotDoneYet() API가 없습니다.
아래처럼 수정해서 만들어야합니다.
class MyRemoteWorkerService : RemoteWorkerService() {
override fun onCreate() {
if (!isWorkManagerInitialized()) {
WorkManager.initialize(
applicationContext,
Configuration.Builder().build()
)
}
super.onCreate()
}
private fun isWorkManagerInitialized() = try {
WorkManager.getInstance(applicationContext)
true
} catch (ignored: Throwable) {
false
}
}
하지만 위처럼 원격 작업을 예약할 때 :my work
프로세스가 시작되고, 작업이 시작되고, 작업이 즉시 취소되고, 다시 예약되는 순으로 실행된다는 것을 금새 알아차릴 수 있습니다.
WorkManager 라이브러리 코드를 두 개의 병렬 프로세스(😰)로 디버깅해보니 결국 WorkManager가 초기화되면 모든 작업을 취소하고 init에서 다시 예약하는 ForceStopRunnable
을 실행한다는 것을 알게 됐습니다.
ForceStopRunnable
이 실행되지 않도록 하는 한 가지 방법은 [Configuration.Builder.setDefaultProcessName](https://developer.android.com/reference/androidx/work/Configuration.Builder#setDefaultProcessName(java.lang.String))
을 기본 앱 프로세스 이름으로 설정하는 것입니다. 그러나 개발자가 그들의 application class
에 workmanager configuration
은 설정하고 DefaultProcessName
을 설정하지 않은 경우 ForceStopRunnable
이 실행되므로 이 설정을 변경할 수 없습니다.
따라서 초기화되지 않은 경우만 해결할 수 있습니다.
class MyRemoteWorkerService : RemoteWorkerService() {
override fun onCreate() {
if (!isWorkManagerInitialized()) {
WorkManager.initialize(
applicationContext,
Configuration.Builder()
.setDefaultProcessName(applicationContext.packageName)
.build()
)
} else {
// If the developer didn't set setDefaultProcessName in the
// Configuration.Builder then the work will be rescheduled once
// when :mywork starts and there's nothing we can do about it.
}
super.onCreate()
}
// ...
}
WorkManager는 Application Context가 Configuration을 구현하는 경우 처음 사용할 때 자동으로 초기화됩니다. 그러나 애플리케이션 클래스가 Application Context여야 한다는 내용이 없습니다. Context API에서 가장 성가신 부분 중 하나이지만, 이번에는 이를 이용할 수 있습니다.
즉, RemoteWorkerService.getApplicationContext()
가 Configuration.Provider
를 상속받은 가짜 앱 컨텍스트를 반환하도록 하는 것입니다.
class MyRemoteWorkerService : RemoteWorkerService() {
class FakeAppContextConfigurationProvider(base: Context)
: ContextWrapper(base), Configuration.Provider {
// service.applicationContext.applicationContext still returns this
override fun getApplicationContext() = this
override fun getWorkManagerConfiguration() = Configuration.Builder()
.setDefaultProcessName(packageName)
.build()
}
private val fakeAppContext by lazy {
FakeAppContextConfigurationProvider(super.getApplicationContext())
}
override fun getApplicationContext(): Context {
return fakeAppContext
}
}
이는 개발자가 application class 단에서 Configuration.Builder
을 상속받아 구현했는지에 대한 사실에 관계없이 이전에 짠 코드(=hack)보다 낫습니다. 왜냐하면 우리가 사용하고 있는 process에 어떤 configuration을 쓸지 결정할 수 있기 때문입니다.
한마디로WorkManager.initialize
를 직접 호출해서 쓸 수 있는 유일한 방법입니다.
라이브러리에서 WorkManager 다중 프로세스를 제대로 작동시키는 것은 간단하지 않으며 몇 가지 꼼수가 필요하지만(본문에서는 hack이라고 표현하는데 흔히 묘수,지름길이라고 표현한다), 이는 놀라운 일이 아닙니다. 안드로이드는 지금까지 라이브러리를 염두하고 API를 구축하는데 매우 서툴렀으며, 단일 애플리케이션 클래스는 항상 다중 프로세스 앱의 버그 원인이었기 때문입니다.
AndroidX WorkManager 및 startup 라이브러리가 올바른 방향으로 가고 있다고 생각합니다.
https://developers-kr.googleblog.com/2021/02/workmanager-2-5-0-stable-released.html
workmanager multi process에 대해서는 2.5.0부터 들어갔으며 공식 문서에서 remote worker와 setprocessname에 대한 이야기가 짧게 나옵니다. 궁금한 분들은 이 글 말고도 다른 레퍼런스를 같이 찾아보며 공부하는 게 이해하기 쉬워보입니다.
해당 글로 처음부터 끝까지 이해하기엔 무리로 보이네요.
*참고: setProcessName의 매개변수에 앱 패키지 이름, 콜론, 호스트 프로세스 이름으로 구성된 전체 프로세스 이름(예: com.example:remote)을 전달해야 합니다.
work-multiprocess를 사용할 때 작업 요청을 관리하기 위해 WorkManager 대신 RemoteWorkManager 를 사용하길 원하실 겁니다.
RemoteWorkManager는 자동으로 작업을 큐에 넣도록 지정된 프로세스까지 항상 연결하며, 이를 통해 호출 프로세스에서 새 WorkManager를 우연히 초기화하지 않도록 보장합니다.
프로세스 내 스케줄러는 똑같은 지정된 프로세스에서도 실행됩니다.
https://developer.android.com/jetpack/androidx/releases/work?hl=ko