CoroutineWorker in Android
CoroutineWorker verbindet WorkManager mit suspend-Funktionen. Du lernst, wie Hintergrundarbeit abbrechbar und sauber strukturiert bleibt.
Ein CoroutineWorker ist deine Brücke zwischen WorkManager und Kotlin Coroutines. Du verwendest ihn, wenn eine Aufgabe zuverlässig im Hintergrund laufen soll und dabei moderne suspend-APIs nutzt, etwa für Netzwerkzugriffe, Datenbankoperationen oder Synchronisationen. Wichtig ist dabei nicht nur, dass der Code asynchron wirkt, sondern dass er kontrolliert, abbrechbar und passend zur App-Architektur ausgeführt wird.
Was ist das?
CoroutineWorker ist eine Basisklasse aus WorkManager für Hintergrundarbeit, die direkt mit Kotlin Coroutines arbeitet. Statt eine blockierende Methode zu überschreiben, implementierst du suspend fun doWork(): Result. Dadurch kannst du innerhalb des Workers normale suspend-Funktionen aufrufen, ohne eigene Threads zu starten oder Callbacks zu verschachteln.
Das Problem, das CoroutineWorker löst, ist typisch für echte Android-Apps: Manche Aufgaben sollen nicht nur laufen, solange ein Screen sichtbar ist. Eine App muss vielleicht Daten mit einem Server synchronisieren, alte Cache-Einträge entfernen, Uploads nachholen oder lokale Änderungen später übertragen. Diese Arbeit passt nicht in eine Compose-Funktion, nicht in eine Activity und meist auch nicht direkt in ein ViewModel, weil sie länger leben kann als die aktuelle UI.
WorkManager kümmert sich um Planung, Bedingungen und Wiederholungen. Du kannst Arbeit zum Beispiel nur bei Netzwerkverbindung starten lassen oder sie später erneut versuchen. CoroutineWorker ergänzt dieses Modell um coroutine-freundlichen Code. Das bedeutet: Deine eigentliche Arbeit kann in Repositorys, Datenquellen oder Use Cases liegen und als suspend-Funktion angeboten werden. Der Worker ruft diese Funktion auf und übersetzt das Ergebnis in Result.success(), Result.retry() oder Result.failure().
Für dein mentales Modell ist eine Trennung wichtig: WorkManager entscheidet, wann eine Aufgabe laufen darf. CoroutineWorker beschreibt, was dann passieren soll. Coroutines liefern die Ausführungsform für suspendierbare Arbeit und kooperative Abbrüche. Diese drei Rollen solltest du nicht vermischen. Wenn du im Worker planst, ausführst, Netzwerkfehler interpretierst, Daten transformierst und UI-Zustände berechnest, wird der Code schnell schwer testbar.
Im modernen Android-Kontext passt CoroutineWorker gut zu Jetpack-Architektur. Compose zeigt Zustände an, ViewModels koordinieren UI-nahe Abläufe, Repositorys verwalten Datenzugriff, und WorkManager übernimmt langlebige Hintergrundarbeit. Ein Worker ist also kein verstecktes ViewModel und kein Ersatz für deine Datenarchitektur. Er ist ein Einstiegspunkt für Arbeit, die auch dann sinnvoll ist, wenn kein bestimmter Screen aktiv ist.
Wie funktioniert es?
Ein CoroutineWorker bekommt einen Context und WorkerParameters. Du überschreibst doWork() und gibst ein Result zurück. Diese Methode ist suspend, daher kannst du darin direkt Funktionen wie repository.sync() aufrufen, wenn diese ebenfalls suspendierbar sind. WorkManager startet den Worker, verwaltet seine Ausführung und kann ihn abbrechen, wenn Bedingungen nicht mehr passen, die Arbeit gestoppt wird oder das System Ressourcen sparen muss.
Der wichtigste Punkt ist Cancellation. In Coroutines bedeutet Abbruch normalerweise nicht, dass ein Thread hart beendet wird. Stattdessen wird die Coroutine als abgebrochen markiert. Suspend-Funktionen aus Coroutine- und Jetpack-APIs reagieren darauf in der Regel kooperativ. Wenn du lange Schleifen, eigene Wartezeiten oder blockierende Bibliotheken verwendest, musst du selbst darauf achten, dass der Code regelmäßig abbrechbar bleibt.
Ein guter CoroutineWorker macht deshalb keine endlosen blockierenden Aufrufe. Er ruft suspendierbare APIs auf, nutzt Timeouts bewusst und lässt CancellationException nicht als normalen Fehler verschwinden. Eine häufige Stolperfalle ist ein breites catch (Exception), das jeden Fehler in Result.retry() umwandelt. Damit fängst du auch Abbrüche ab, wenn du nicht aufpasst. Das kann dazu führen, dass eine gestoppte Arbeit direkt wieder eingeplant wird oder dass Logs falsche Fehlersignale zeigen.
Auch Dispatcher-Fragen gehören zum Grundverständnis. CoroutineWorker führt doWork() bereits in einem Coroutine-Kontext aus. Trotzdem kann es sinnvoll sein, in tieferen Schichten mit withContext(Dispatchers.IO) zu arbeiten, wenn dort blockierende I/O-Operationen kapselt werden. Die bessere Regel ist: Der Worker entscheidet nicht überall selbst über Threads. Die Schicht, die eine blockierende Operation besitzt, sollte auch den passenden Dispatcher wählen. So bleibt der Worker schlank.
WorkManager-Ergebnisse sind bewusst grob. Result.success() heißt: Die Aufgabe ist erledigt. Result.retry() heißt: Der Fehler ist wahrscheinlich vorübergehend, etwa wegen Netzwerkproblemen. Result.failure() heißt: Ein erneuter Versuch mit denselben Eingaben wird voraussichtlich nicht helfen, zum Beispiel bei ungültigen Parametern. Diese Unterscheidung ist wichtiger als viele Anfänger denken, weil sie beeinflusst, ob WorkManager Backoff-Regeln und erneute Ausführungen nutzt.
Wenn du Daten aus dem Worker heraus meldest, solltest du ebenfalls sauber bleiben. Fortschritt kannst du über WorkManager-Progress setzen. Dauerhafte Daten gehören in Datenbank oder Repository. UI-Events gehören nicht in den Worker. Compose beobachtet später Zustände aus ViewModel, Repository oder WorkManager-Status, aber der Worker sollte nicht versuchen, direkt UI zu steuern. Das hält deine App auch dann stabil, wenn der Worker läuft, während keine Oberfläche geöffnet ist.
Flow ist in diesem Zusammenhang nützlich, aber mit Vorsicht zu verwenden. Ein Worker ist für begrenzte Arbeit gedacht. Einen unendlichen Flow in doWork() dauerhaft zu sammeln, ist fast immer ein Warnsignal. Wenn du einen Flow nutzt, dann meist begrenzt: etwa mit first(), take(n) oder einer klaren Bedingung. Für kontinuierliche Beobachtung ist ein ViewModel oder eine andere Lifecycle-nahe Schicht oft passender.
In der Praxis
Stell dir vor, deine App speichert lokale Änderungen offline und soll sie später zum Server senden. Die UI schreibt Daten in eine lokale Datenbank. Ein Repository kennt die Synchronisationslogik. Der CoroutineWorker startet diese Logik, wenn WorkManager ihn ausführt. Dadurch bleibt die UI schnell, und die Hintergrundarbeit hat einen klaren Ort.
Ein einfaches Beispiel kann so aussehen:
class SyncWorker(
appContext: Context,
workerParams: WorkerParameters,
private val syncRepository: SyncRepository
) : CoroutineWorker(appContext, workerParams) {
override suspend fun doWork(): Result {
return try {
syncRepository.syncPendingChanges()
Result.success()
} catch (e: CancellationException) {
throw e
} catch (e: IOException) {
Result.retry()
} catch (e: IllegalArgumentException) {
Result.failure()
}
}
}
Das Beispiel zeigt mehrere wichtige Entscheidungen. Die eigentliche Synchronisation liegt nicht im Worker, sondern im SyncRepository. Der Worker übersetzt nur technische Ergebnisse in WorkManager-Ergebnisse. Netzwerkfehler führen zu retry(), weil ein erneuter Versuch später sinnvoll sein kann. Fehlerhafte Eingaben führen zu failure(), weil Wiederholen den Fehler nicht automatisch behebt. CancellationException wird erneut geworfen, damit Abbruch korrekt behandelt wird.
In echten Projekten nutzt du für Dependency Injection häufig Hilt oder eine andere DI-Lösung. Dann muss der Worker passend integriert werden, damit WorkManager ihn mit Abhängigkeiten erstellen kann. Das ist ein Architekturdetail, ändert aber nicht die Kernregel: Der Worker bleibt ein Orchestrator, keine Sammelstelle für Geschäftslogik.
Das Einplanen kann zum Beispiel über eine einmalige WorkRequest passieren:
val request = OneTimeWorkRequestBuilder<SyncWorker>()
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
)
.setBackoffCriteria(
BackoffPolicy.EXPONENTIAL,
30,
TimeUnit.SECONDS
)
.build()
WorkManager.getInstance(context)
.enqueueUniqueWork(
"sync-pending-changes",
ExistingWorkPolicy.KEEP,
request
)
Hier siehst du die andere Hälfte des Modells. Die WorkRequest beschreibt, unter welchen Bedingungen die Arbeit laufen soll. Netzwerk wird vorausgesetzt. Backoff verhindert, dass wiederholte Fehler zu aggressiven Neustarts führen. enqueueUniqueWork() verhindert, dass mehrere identische Sync-Jobs parallel gestartet werden. Gerade diese Parallelität ist eine typische Fehlerquelle: Wenn du mehrere Worker dieselben lokalen Daten synchronisieren lässt, bekommst du leicht doppelte Uploads, Race Conditions oder verwirrende Serverzustände.
Eine praktische Entscheidungsregel lautet: Verwende CoroutineWorker, wenn die Aufgabe länger als ein UI-Ablauf leben darf, zuverlässig geplant werden soll und deine Logik bereits suspendierbar ist. Verwende ihn nicht, um kurzfristige UI-Aktionen auszulagern, nur weil eine Funktion suspend ist. Ein Button-Klick, der Daten lädt und direkt einen Screen aktualisiert, gehört normalerweise ins ViewModel. Eine spätere Synchronisation, die auch nach App-Neustart laufen soll, gehört eher zu WorkManager.
Eine weitere Stolperfalle ist unklare Fehlerklassifikation. Viele Teams geben bei jedem Fehler retry() zurück. Das wirkt zunächst robust, führt aber zu unnötiger Arbeit und schwer lesbaren Fehlerbildern. Prüfe bewusst: Ist der Fehler vorübergehend? Dann retry(). Ist die Eingabe falsch, ein Token dauerhaft ungültig oder ein lokaler Datensatz kaputt? Dann brauchst du vielleicht failure() plus eine saubere Markierung im Repository, damit die App den Zustand später anzeigen oder beheben kann.
Auch Tests sind hier realistisch möglich. Du kannst die Synchronisationslogik im Repository separat mit Coroutine-Testwerkzeugen prüfen. Für den Worker testest du dann vor allem die Übersetzung: Wenn das Repository erfolgreich ist, kommt success(). Wenn es eine IOException wirft, kommt retry(). Wenn es einen Validierungsfehler wirft, kommt failure(). In Code-Reviews solltest du besonders auf drei Dinge achten: Wird Cancellation korrekt weitergereicht? Gibt es blockierende Aufrufe ohne saubere Kapselung? Kann derselbe Worker mehrfach gleichzeitig dieselben Daten verändern?
Beim Debuggen helfen klare Logs an den Grenzen, nicht in jeder Zeile. Logge Start, Ende, Retry-Grund und fachlich relevante IDs, aber keine sensiblen Daten. Prüfe außerdem in den App-Einstellungen oder über WorkManager-Inspection-Werkzeuge, ob Jobs wirklich eingeplant, ausgeführt oder abgebrochen werden. Wenn ein Worker nie läuft, liegt das Problem oft nicht in doWork(), sondern in Constraints, eindeutigen Work-Namen oder einer falschen Annahme über den App-Zustand.
Fazit
CoroutineWorker ist ein sauberer Baustein für suspendierbare Hintergrundarbeit mit WorkManager, wenn du ihn bewusst klein hältst und Cancellation respektierst. Übe das Konzept, indem du eine kleine Sync-Aufgabe baust, absichtlich Netzwerkfehler simulierst, den Retry-Pfad prüfst und im Test sicherstellst, dass CancellationException nicht verschluckt wird. Im Code-Review solltest du danach fragen, ob die Geschäftslogik in der passenden Schicht liegt und ob success(), retry() und failure() wirklich die Bedeutung des jeweiligen Ergebnisses treffen.