Android Coden
Android 7 min lesen

Broadcast Receiver und asynchrone Arbeit

Broadcast Receiver reagieren auf Systemereignisse. Du lernst, wann goAsync reicht und wann du Arbeit delegierst.

Broadcast Receiver helfen deiner App, auf Ereignisse außerhalb eines offenen Screens zu reagieren: ein Alarm läuft ab, der Gerätestart ist abgeschlossen, die Zeitzone ändert sich oder deine App sendet intern ein eigenes Signal. Entscheidend ist dabei die Grenze: Ein Receiver ist kein Ort für lange Hintergrundarbeit. Er soll ein Ereignis annehmen, knapp prüfen und dann Arbeit an eine passende Komponente weitergeben.

Was ist das?

Ein Broadcast Receiver ist eine Android-Komponente, die einen Intent empfängt. Dieser Intent beschreibt ein Ereignis. Das kann vom System kommen, von einer anderen App oder aus deiner eigenen App. Der Receiver bekommt dafür die Methode onReceive(context, intent). Diese Methode ist bewusst kurz gedacht. Android ruft sie auf, erwartet eine schnelle Antwort und kann den Prozess danach wieder beenden.

Das mentale Modell ist: Der Receiver ist ein Türkontakt, keine Werkstatt. Er merkt, dass etwas passiert ist, entscheidet, ob deine App reagieren muss, und startet dann die nächste geeignete Aktion. Wenn du dort Netzwerkzugriffe, Datenbankmigrationen oder lange Schleifen ausführst, blockierst du einen Systempfad. Das führt zu schlechter Performance, verpassten Abschlüssen oder im schlimmsten Fall zu ANRs.

Asynchrone Arbeit kommt ins Spiel, weil reale Apps selten nur eine Variable setzen. Vielleicht willst du nach einem Systemereignis lokale Daten aktualisieren, einen Sync anstoßen oder eine Benachrichtigung vorbereiten. Kotlin Coroutines sind dafür in Android sehr nützlich, aber im Receiver gelten besondere Grenzen. Eine Coroutine macht Arbeit nicht automatisch geeignet für onReceive. Sie verschiebt nur, wie Arbeit ausgeführt wird. Der Lebenszyklus des Receivers bleibt kurz.

goAsync() ist die Brücke für kleine asynchrone Anschlussarbeit. Der Aufruf gibt dir ein PendingResult. Damit sagst du Android: Die Rückmeldung ist noch nicht fertig, aber sie kommt gleich. Du musst danach unbedingt finish() aufrufen. Trotzdem ist goAsync() kein Freibrief für minutenlange Jobs. Es eignet sich für knappe Operationen, zum Beispiel ein kleines Schreiben in eine lokale Datenbank, eine Entscheidung anhand gespeicherter Einstellungen oder das Planen eines robusteren Hintergrundjobs.

In modernen Android-Apps liegt die eigentliche Fachlogik meist nicht im Receiver. Sie gehört in die Data Layer, also Repositorys, Datenquellen und Synchronisationsbausteine. Eine Compose-Oberfläche beobachtet später Zustände über StateFlow, Flow oder ViewModel-State. Der Receiver ist dabei nur ein Eingang von außen. Er sollte deine Architektur nicht umgehen.

Wie funktioniert es?

Ein Receiver kann statisch im Manifest oder dynamisch im Code registriert werden. Bei vielen System-Broadcasts gelten Einschränkungen, weil Android Akku, Speicher und Startverhalten schützen muss. Für dich ist weniger wichtig, jede einzelne Einschränkung auswendig zu kennen. Wichtiger ist die Regel: Rechne damit, dass ein Receiver unter knappen Bedingungen läuft und sein Zeitfenster begrenzt ist.

onReceive() läuft normalerweise auf dem Main Thread. Das ist absichtlich streng. Android möchte verhindern, dass Broadcasts lange blockieren. Sobald onReceive() zurückkehrt, darf das System den Prozess so behandeln, als wäre der Receiver fertig. Startest du darin nur eine Coroutine in einem beliebigen Scope und kehrst zurück, hast du keinen verlässlichen Besitz mehr an dieser Arbeit. Sie kann zwar manchmal weiterlaufen, aber darauf solltest du keine App-Logik bauen.

Mit goAsync() verlängerst du den Abschluss des Broadcasts. Du erhältst ein PendingResult, führst kurze Arbeit asynchron aus und rufst danach finish() auf. Typischerweise verwendest du dafür einen kontrollierten Coroutine-Scope, zum Beispiel einen Scope mit SupervisorJob() und einem Dispatcher für I/O-Arbeit. Wichtig ist, dass Fehler abgefangen werden. Ein unbehandelter Fehler darf nicht dazu führen, dass finish() nie ausgeführt wird. Deshalb gehört finish() in einen finally-Block.

Die Grenzen bleiben trotzdem eng. Ein Receiver ist nicht der richtige Ort für einen vollständigen Offline-First-Sync. Wenn die Arbeit robust sein muss, nach Prozessende weiterlaufen soll oder Bedingungen wie Netzwerk und Akku beachten muss, delegierst du sie. In vielen Apps ist WorkManager dafür die passende Wahl. Der Receiver erstellt dann nur eine Work-Anfrage. Die eigentliche Arbeit liegt in einem Worker, der wiederum ein Repository verwendet.

Diese Trennung passt gut zu Kotlin, Flow und Jetpack-Architektur. Der Receiver kennt nur das Ereignis und ruft eine kleine Schnittstelle auf. Das Repository entscheidet, ob Daten veraltet sind, ob ein Sync nötig ist oder ob nur ein lokaler Marker gesetzt wird. Ein ViewModel stellt den UI-Zustand später als Flow bereit. Compose sammelt diesen Zustand und rendert ihn. So bleibt der Receiver klein, testbar und unabhängig von deiner UI.

Eine hilfreiche Entscheidungsregel lautet: Wenn du die Arbeit in einem Satz als “kurz prüfen und planen” beschreiben kannst, darf sie nahe am Receiver liegen. Wenn du “laden”, “synchronisieren”, “wiederholen”, “hochladen”, “mehrere Tabellen aktualisieren” oder “Netzwerkfehler behandeln” sagst, gehört sie nicht in den Receiver selbst.

In der Praxis

Stell dir vor, deine App verwaltet offline verfügbare Notizen. Nach dem Gerätestart soll sie prüfen, ob ein Sync geplant werden muss. Der Receiver soll nicht selbst synchronisieren. Er liest höchstens eine kleine Information und delegiert dann an eine Komponente, die Hintergrundarbeit sauber plant.

Ein schlanker Receiver kann so aussehen:

class BootCompletedReceiver : BroadcastReceiver() {

    override fun onReceive(context: Context, intent: Intent) {
        if (intent.action != Intent.ACTION_BOOT_COMPLETED) return

        val pendingResult = goAsync()

        CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
            try {
                val appContext = context.applicationContext
                SyncScheduler.enqueueNoteSync(appContext)
            } catch (throwable: Throwable) {
                Log.e("BootCompletedReceiver", "Sync konnte nicht geplant werden", throwable)
            } finally {
                pendingResult.finish()
            }
        }
    }
}

Das Beispiel zeigt bewusst keine große Fachlogik. SyncScheduler.enqueueNoteSync(appContext) wäre eine kleine Fassade, die zum Beispiel WorkManager verwendet. Der Receiver prüft nur den Intent, wechselt für die kurze Anschlussarbeit auf Dispatchers.IO, plant Arbeit und beendet den PendingResult zuverlässig.

Der zugehörige Scheduler könnte in einer echten App so aufgebaut sein:

object SyncScheduler {

    fun enqueueNoteSync(context: Context) {
        val request = OneTimeWorkRequestBuilder<NoteSyncWorker>()
            .setConstraints(
                Constraints.Builder()
                    .setRequiredNetworkType(NetworkType.CONNECTED)
                    .build()
            )
            .build()

        WorkManager.getInstance(context)
            .enqueueUniqueWork(
                "note-sync",
                ExistingWorkPolicy.KEEP,
                request
            )
    }
}

Hier liegt die Grenze klar: Der Receiver startet nicht die Netzwerkanfrage. Er plant einen Worker mit Netzwerkbedingung. Der Worker kann dann Repositorys verwenden, Fehler behandeln und mit der Data Layer zusammenarbeiten. Wenn deine UI später anzeigen soll, ob Daten synchronisiert wurden, passiert das nicht über den Receiver, sondern über gespeicherte Daten und beobachtbare Zustände. Ein Repository kann etwa einen Flow<SyncState> bereitstellen, den ein ViewModel in UI-State übersetzt.

Die typische Stolperfalle ist ein vergessener Abschluss. Wenn du goAsync() nutzt und bei einem Fehler finish() nicht aufrufst, bleibt der Broadcast aus Sicht des Systems offen, bis Android eingreift. Darum gehört finish() in finally. Eine zweite Stolperfalle ist ein falsch gewählter Coroutine-Scope. GlobalScope wirkt bequem, macht aber Besitz und Fehlerbehandlung unklar. Besser ist ein begrenzter Scope für die kurze Arbeit oder noch besser: sofort an eine langlebigere, dafür vorgesehene API delegieren.

Eine dritte Stolperfalle betrifft Abhängigkeiten. Viele Lernende greifen im Receiver direkt auf UI-nahe Klassen zu oder bauen dort schnell eine Service-Locator-Lösung. Das rächt sich beim Testen. Halte die Abhängigkeit klein: Der Receiver kennt eine Scheduler-Schnittstelle oder eine sehr dünne Fassade. Die echte Logik sitzt in Klassen, die du isoliert testen kannst.

Im Code-Review kannst du gezielt prüfen:

  • Dauert onReceive() nur kurz?
  • Wird bei goAsync() immer finish() erreicht?
  • Gibt es Netzwerk- oder Datenbankarbeit direkt im Receiver?
  • Wird längere Arbeit an WorkManager, Repositorys oder eine passende Hintergrund-API delegiert?
  • Ist die Arbeit auch dann korrekt, wenn der Prozess nach dem Receiver beendet wird?

Zum Üben kannst du einen kleinen Receiver schreiben, der auf ein eigenes App-Broadcast reagiert und nur einen WorkManager-Job plant. Setze Breakpoints in onReceive(), im Scheduler und im Worker. Dann siehst du klar, welche Komponente wann läuft. Ergänze danach einen Test für den Scheduler: Wird bei gleichem Namen wirklich kein doppelter Sync geplant? So trainierst du nicht nur API-Wissen, sondern auch die Architekturgrenze.

Fazit

Broadcast Receiver sind nützlich, wenn deine App auf Ereignisse reagieren soll, während kein Screen aktiv ist. Ihr Nutzen hängt aber davon ab, dass du ihre Grenzen respektierst: kurz reagieren, sauber abschließen und längere Arbeit delegieren. Prüfe deinen nächsten Receiver mit Debugger, Tests oder Code-Review genau an dieser Stelle. Wenn du erklären kannst, warum die eigentliche Arbeit außerhalb des Receivers liegt und warum finish() sicher erreicht wird, hast du das wichtigste Praxisprinzip verstanden.

Quellen (5)
Redaktion

Geschrieben von

Redaktion

Das Redaktionsteam recherchiert und schreibt Artikel zu aktuellen Themen rund um Tech, Lifestyle und Ratgeber.