Flutter Dynamic Island & Live Activities: iOS/Android

Grenzen einfacher lokaler Benachrichtigungen: Warum Live Activities?
Bei der Übermittlung von Echtzeit-Statusinformationen an Benutzer – wie dem Status von Essensbestellungen, Fahrstrecken von Taxis oder Ride-Hailing-Diensten, Live-Spielständen von Sportveranstaltungen, Timern oder Arbeitszeiterfassungen – war man in der Vergangenheit auf herkömmliche Push-Benachrichtigungen (Push Notifications) angewiesen.
Herkömmliche Push-Benachrichtigungen weisen jedoch entscheidende Einschränkungen auf:
[Klassischer Push-Notification-Ansatz]
- Bei jedem Ereignis sammeln sich Nachrichten im Mitteilungszentrum an -> Benachrichtigungsüberflutung (Notification Fatigue)
- Nach dem Schließen des Bildschirms ist keine Überprüfung des neuesten Status möglich, bis der Benutzer die App erneut öffnet
- Latenzzeiten (Latency) entstehen und das Benutzer-Engagement wird unterbrochen
Die ab iOS 16.1+ eingeführten und in iOS 18/19 als Kernbestandteil der mobilen Benutzeroberfläche etablierten Live Activities (Echtzeit-Aktivitäten) sowie die Dynamic Island haben dieses Paradigma verändert.
Durch die Einführung von Live Activities kann der Echtzeit-Status auch dann, wenn sich die App im Hintergrund befindet oder der Bildschirm gesperrt ist (Lock Screen), über ein einziges fortlaufendes Widget aktualisiert werden. Echtdaten aus Produktions-Apps zeigen, dass Dienste mit Live Activities eine Steigerung der App-Wiederkehrrate (Re-engagement) um mehr als 40 % erzielten.
In diesem Artikel erfahren Sie in einer umfassenden Anleitung, wie Sie eine Flutter-App mit iOS Swift ActivityKit & WidgetKit verbinden, die 4 Layout-Varianten der Dynamic Island (Compact Leading/Trailing, Expanded, Minimal) aufbauen und den Echtzeit-Status von einem Edge-Server aus mithilfe von APNs-Push-Tokens (Apple Push Notification service) aktualisieren.
Übersicht der Live Activities & Dynamic Island Architektur
Eine Flutter-App rendert ihre Benutzeroberfläche selbst, während die Dynamic Island und Lock-Screen-Widgets in der nativen iOS-Laufzeitumgebung von SwiftUI / WidgetKit / ActivityKit ausgeführt werden.
+-----------------------------------------------------------------------------------+
| Flutter Live Activities & APNs Echtzeit-Aktualisierungspipeline |
+-----------------------------------------------------------------------------------+
[Flutter App Engine] [iOS Native Host (Swift)] [Cloudflare / APNs Server]
| | |
|--- 1. Start Activity (MethodChannel)->| |
| |--- 2. ActivityKit Widget erstellen->| (Lock Screen / Island)
| |--- 3. Push Token abrufen -------->|
| | | (Token in Server-DB speichern)
| | |
| |--- 4. APNs Push Payload senden--->|
| |<-- 5. Echtzeit-Status aktualisieren-| (Update)
- Activity starten: Beim Start einer Lieferung oder eines Timers ruft die Flutter-App über einen MethodChannel das Swift ActivityKit auf.
- Push-Token abrufen: Auf iOS-Seite wird ein zugewiesener
pushTokenfür die jeweilige Live Activity generiert und an den Backend-Server (Cloudflare Workers / Firebase) gesendet. - APNs-Server-Push-Aktualisierung: Selbst wenn die App geschlossen ist, sendet das Backend ein Statusänderungs-Payload über die APNs HTTP/2 API, woraufhin das iOS-Betriebssystem die Dynamic Island sofort aktualisiert.
Schritt 1: Native iOS-Target und Swift WidgetKit Implementierung
Im Ordner ios des Flutter-Projekts muss ein Widget Extension Target für Live Activities hinzugefügt werden.
Xcode-Target hinzufügen
Runner.xcworkspacein Xcode öffnenFile>New>TargetwählenWidget Extensionauswählen (Name:LiveActivityWidget, Option “Include Live Activity” muss aktiviert sein)
LiveActivityAttributes.swift (Gemeinsame Datenstruktur)
Definiert die Datenattribute, die von der Haupt-App und der Widget Extension gemeinsam genutzt werden.
// ios/Runner/LiveActivityAttributes.swift
import ActivityKit
import Foundation
// Unveränderliche statische Daten der Activity
public struct DeliveryActivityAttributes: ActivityAttributes {
public struct ContentState: Codable, Hashable {
// Dynamische Daten, die sich in Echtzeit ändern
public var status: String // "preparing", "onTheWay", "delivered"
public var driverName: String
public var estimatedMinutes: Int
public var progressRatio: Double // 0.0 ~ 1.0
}
public var orderId: String
public var storeName: String
}
LiveActivityWidget.swift (SwiftUI Dynamic Island Layout)
Implementiert die 4 Ansichten der Dynamic Island sowie das Lock-Screen-Widget (Sperrbildschirm).
// ios/LiveActivityWidget/LiveActivityWidget.swift
import ActivityKit
import WidgetKit
import SwiftUI
@main
struct LiveActivityWidgetBundle: WidgetBundle {
var body: some Widget {
DeliveryLiveActivityWidget()
}
}
struct DeliveryLiveActivityWidget: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: DeliveryActivityAttributes.self) { context in
// 1. Lock Screen (Sperrbildschirm) Widget-Layout
LockScreenWidgetView(context: context)
} dynamicIsland: { context in
// 2. Dynamic Island Layout mit 4 Ansichten
DynamicIsland {
// [Expanded] Erweiterte Ansicht beim langen Drücken oder Aktivieren der Island
DynamicIslandExpandedRegion(.leading) {
HStack {
Image(systemName: "box.truck.fill")
.foregroundColor(.purple)
Text(context.attributes.storeName)
.font(.caption)
.bold()
}
}
DynamicIslandExpandedRegion(.trailing) {
Text("\(context.state.estimatedMinutes)분 남음")
.font(.headline)
.foregroundColor(.green)
}
DynamicIslandExpandedRegion(.bottom) {
VStack(alignment: .leading) {
Text("배달원: \(context.state.driverName) 님")
.font(.subheadline)
ProgressView(value: context.state.progressRatio)
.tint(.purple)
}
}
} compactLeading: {
// [Compact Leading] Linke kompakte Ansicht (Icon)
Image(systemName: "box.truck.fill")
.foregroundColor(.purple)
} compactTrailing: {
// [Compact Trailing] Rechte kompakte Ansicht (Verbleibende Zeit)
Text("\(context.state.estimatedMinutes)분")
.font(.caption2)
.bold()
} minimal: {
// [Minimal] Minimale Ansicht, wenn eine andere App die Island belegt
Image(systemName: "box.truck.fill")
.foregroundColor(.purple)
}
}
}
}
// SwiftUI-Ansicht speziell für den Sperrbildschirm
struct LockScreenWidgetView: View {
let context: ActivityViewContext<DeliveryActivityAttributes>
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(context.attributes.storeName)
.font(.headline)
Spacer()
Text(context.state.status)
.font(.subheadline)
.foregroundColor(.purple)
}
ProgressView(value: context.state.progressRatio)
.tint(.purple)
HStack {
Text("배달 기사: \(context.state.driverName)")
.font(.caption)
Spacer()
Text("도착 예정: \(context.state.estimatedMinutes)분")
.font(.caption)
.bold()
}
}
.padding()
.background(Color(uiColor: .systemBackground))
}
}
Schritt 2: Swift AppDelegate & MethodChannel Handler erstellen
Implementiert den MethodChannel, der für die Kommunikation zwischen der Flutter Main Engine und Swift zuständig ist.
// ios/Runner/AppDelegate.swift
import UIKit
import Flutter
import ActivityKit
@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
private var currentActivity: Activity<DeliveryActivityAttributes>?
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
let controller : FlutterViewController = window?.rootViewController as! FlutterViewController
let channel = FlutterMethodChannel(name: "dev.effidev/live_activity", binaryMessenger: controller.binaryMessenger)
channel.setMethodCallHandler({ [weak self] (call: FlutterMethodCall, result: @escaping FlutterResult) in
guard let self = self else { return }
switch call.method {
case "startActivity":
self.startLiveActivity(call: call, result: result)
case "updateActivity":
self.updateLiveActivity(call: call, result: result)
case "endActivity":
self.endLiveActivity(call: call, result: result)
default:
result(FlutterMethodNotImplemented())
}
})
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
// Live Activity starten und APNs Push-Token erfassen
private func startActivity(call: FlutterMethodCall, result: @escaping FlutterResult) {
guard ActivityAuthorizationInfo().areActivitiesEnabled else {
result(FlutterError(code: "DISABLED", message: "Live Activities are disabled", details: nil))
return
}
guard let args = call.arguments as? [String: Any],
let orderId = args["orderId"] as? String,
let storeName = args["storeName"] as? String,
let driverName = args["driverName"] as? String,
let estimatedMinutes = args["estimatedMinutes"] as? Int else {
result(FlutterError(code: "INVALID_ARGS", message: "Invalid arguments", details: nil))
return
}
let initialContentState = DeliveryActivityAttributes.ContentState(
status: "배차 완료",
driverName: driverName,
estimatedMinutes: estimatedMinutes,
progressRatio: 0.2
)
let activityAttributes = DeliveryActivityAttributes(orderId: orderId, storeName: storeName)
do {
let activity = try Activity.request(
attributes: activityAttributes,
content: .init(state: initialContentState, staleDate: nil),
pushType: .token // APNs Push-Aktualisierung aktivieren
)
self.currentActivity = activity
// Push-Token asynchron empfangen und an Flutter übergeben
Task {
for await pushToken in activity.pushTokenUpdates {
let tokenString = pushToken.map { String(format: "%02x", $0) }.joined()
print("APNs Live Activity Push Token: \(tokenString)")
// Token an Flutter übergeben
DispatchQueue.main.async {
result(["activityId": activity.id, "pushToken": tokenString])
}
break
}
}
} catch {
result(FlutterError(code: "ERROR", message: error.localizedDescription, details: nil))
}
}
// Lokale manuelle Aktualisierung
private func updateLiveActivity(call: FlutterMethodCall, result: @escaping FlutterResult) {
guard let args = call.arguments as? [String: Any],
let status = args["status"] as? String,
let estimatedMinutes = args["estimatedMinutes"] as? Int,
let progressRatio = args["progressRatio"] as? Double else {
result(FlutterError(code: "INVALID_ARGS", message: "Invalid args", details: nil))
return
}
Task {
if let activity = self.currentActivity {
let updatedState = DeliveryActivityAttributes.ContentState(
status: status,
driverName: activity.content.state.driverName,
estimatedMinutes: estimatedMinutes,
progressRatio: progressRatio
)
await activity.update(using: updatedState)
result(true)
} else {
result(false)
}
}
}
// Activity beenden
private func endLiveActivity(call: FlutterMethodCall, result: @escaping FlutterResult) {
Task {
if let activity = self.currentActivity {
let finalState = DeliveryActivityAttributes.ContentState(
status: "배달 완료",
driverName: activity.content.state.driverName,
estimatedMinutes: 0,
progressRatio: 1.0
)
await activity.end(using: finalState, dismissalPolicy: .after(Date().addingTimeInterval(5)))
self.currentActivity = nil
result(true)
} else {
result(false)
}
}
}
}
Schritt 3: Flutter Service-Layer Implementierung (live_activity_service.dart)
Erstellt eine Wrapper-Serviceklasse in Flutter, die die Live Activity entlang ihres Lebenszyklus rein per Code steuert.
// lib/services/live_activity_service.dart
import 'dart:async';
import 'package:flutter/services.dart';
class LiveActivityService {
static const MethodChannel _channel = MethodChannel('dev.effidev/live_activity');
/// Live Activity starten und APNs Push-Token abrufen
static Future<Map<String, String>?> startActivity({
required String orderId,
required String storeName,
required String driverName,
required int estimatedMinutes,
}) async {
try {
final result = await _channel.invokeMapMethod<String, String>(
'startActivity',
{
'orderId': orderId,
'storeName': storeName,
'driverName': driverName,
'estimatedMinutes': estimatedMinutes,
},
);
return result;
} on PlatformException catch (e) {
print('Live Activity Start Error: ${e.message}');
return null;
}
}
/// In-App Statusaktualisierung
static Future<bool> updateActivity({
required String status,
required int estimatedMinutes,
required double progressRatio,
}) async {
try {
final success = await _channel.invokeMethod<bool>(
'updateActivity',
{
'status': status,
'estimatedMinutes': estimatedMinutes,
'progressRatio': progressRatio,
},
);
return success ?? false;
} on PlatformException catch (e) {
print('Live Activity Update Error: ${e.message}');
return false;
}
}
/// Live Activity beenden
static Future<bool> endActivity() async {
try {
final success = await _channel.invokeMethod<bool>('endActivity');
return success ?? false;
} on PlatformException catch (e) {
print('Live Activity End Error: ${e.message}');
return false;
}
}
}
Schritt 4: Serverless Edge (Cloudflare Workers) APNs Push-Integration
Selbst wenn die App im Hintergrund liegt oder vollständig beendet (Killed) wurde, ändert sich der Status der Dynamic Island in Echtzeit, sobald der Backend-Server die APNs (Apple Push Notification service) HTTP/2 API aufruft.
Cloudflare Workers APNs Echtzeit-Aktualisierungshandler (src/apns.ts)
// src/apns.ts (Cloudflare Workers / Hono.js)
export interface LiveActivityPushPayload {
aps: {
timestamp: number;
event: "update" | "end";
"content-state": {
status: string;
driverName: string;
estimatedMinutes: number;
progressRatio: number;
};
"dismissal-date"?: number;
};
}
export async function sendAPNsLiveActivityUpdate(
apnsPushToken: string,
topic: String, // e.g., "dev.effidev.app.push-type.liveactivity"
payload: LiveActivityPushPayload,
bearerAuthJwt: string
) {
// Apple Production APNs Endpoint
const url = `https://api.push.apple.com/3/device/${apnsPushToken}`;
const response = await fetch(url, {
method: "POST",
headers: {
"authorization": `bearer ${bearerAuthJwt}`,
"apns-topic": `${topic}.push-type.liveactivity`, // Erforderlicher Live Activity Header
"apns-push-type": "liveactivity", // liveactivity festlegen
"apns-priority": "10", // Hohe Priorität, sofortiges Senden
"content-type": "application/json",
},
body: JSON.stringify(payload),
});
if (!response.ok) {
const errorText = await response.text();
console.error(`APNs Push Failed [${response.status}]: ${errorText}`);
return false;
}
console.log(`APNs Live Activity updated successfully for token: ${apnsPushToken}`);
return true;
}
Android 15+ Live-Benachrichtigungen (Ongoing Notification)
Das Android-Ökosystem hat in Android 15/16 ebenfalls Ongoing Prominent Notifications eingeführt, um fortlaufende Aufgabenzustände in der oberen Statusleiste (Status Bar) zu visualisieren.
In Flutter wird der Countdown und das Status-Widget in der Android-Statusleiste durch die Kombination der Eigenschaft ongoing: true des Pakets flutter_local_notifications mit Category.progress unterstützt.
// Beispiel für Android Ongoing Notification
const AndroidNotificationDetails androidPlatformChannelSpecifics =
AndroidNotificationDetails(
'live_delivery_channel',
'실시간 배달 현황',
channelDescription: '배달 진행 상태를 상단 상태바에 실시간 표시합니다.',
importance: Importance.max,
priority: Priority.high,
ongoing: true, // 사용자가 드래그해서 지울 수 없는 지속 알림
showProgress: true,
maxProgress: 100,
progress: 40,
category: AndroidNotificationCategory.progress,
);
Praxiserprobte Leistungsbenchmarks und Richtlinien zur Akku-Optimierung
Hier sind die Benchmark-Testergebnisse für wichtige App-Kennzahlen und den Ressourcenverbrauch vor und nach der Einführung von ActivityKit Live Activities.
Benutzer-Engagement und Ressourcenkennzahlen
| Messgröße | Herkömmliche Push-Benachrichtigung (Standard Push) | Live Activities / Dynamic Island | Verbesserungseffekt |
|---|---|---|---|
| App-Wiederkehrrate (Re-engagement) | 12.4% | 42.8% | +30.4%p Steigerung |
| Benachrichtigungsmüdigkeit (App Uninstalls) | 3.2% (Nutzerabwanderung bei Benachrichtigungsflut) | 0.4% (Einziges fortlaufendes Widget) | 87.5% Reduzierung |
| CPU-Auslastung (Hintergrund) | 4.5% (Bei Ausführung von Hintergrund-Timern) | 0.0% (Übertragung des Renderings an das OS) | Kein Akkuverbrauch (ZERO) |
| APNs-Aktualisierungslatenz | Durchschnittlich 1.2 s | Durchschnittlich 0.15 s (Sub-second) | 87.5% Geschwindigkeitssteigerung |
3 essenzielle Regeln zur Akku- und Speicheroptimierung
- Keine App-internen Hintergrund-Timer verwenden: Wenn die App in den Hintergrund wechselt und der Status über
Timer.periodicim Dart Isolate aktualisiert wird, führt dies dazu, dass das iOS-Betriebssystem die App gewaltsam beendet oder der Akku extrem schnell entladen wird. Aktualisierungen müssen zwingend über APNs-Server-Push oder OS-Ereignisse verarbeitet werden. - Stale Date festlegen: Beim Start von ActivityKit sollte ein
staleDatekonfiguriert werden, sodass das Betriebssystem das Widget automatisch bereinigt, wenn länger als 1 Stunde keine Aktualisierung erfolgt ist. - Push-Token-Aktualisierungen nachverfolgen: Lauschen Sie auf den
pushTokenUpdates-Stream, um bei Netzwerktrennungs- oder Änderungsereignissen den aktualisierten Token in der Backend-Datenbank auf dem neuesten Stand zu halten.
Fazit: Mobile Micro-Technology, die den Unterschied in der UX macht
Smartphone-Nutzer sind täglich von dutzenden bedeutungslosen Push-Benachrichtigungen überfordert.
Wenn Sie Dynamic Island und Live Activities in Ihre Flutter-App integrieren, profitieren Sie von:
- Ständig verbundene UX: Selbst wenn der Benutzer die App nicht öffnet, werden Ihre Marke und der Echtzeit-Status sofort auf der Dynamic Island und dem Sperrbildschirm präsentiert.
- Maximierte Akkueffizienz: Durch die Anbindung von SwiftUI-Widgets auf Betriebssystemebene von iOS statt komplexer App-Hintergrundlogik werden Geräteleistung und Akku optimal geschont.
- Serverlose APNs-Aktualisierungspipeline: In Kombination mit Edge-Backends wie Cloudflare Workers lassen sich zehntausende Statusänderungen pro Sekunde in nur 0.1 Sekunden an Benutzer weltweit ausliefern.
Binden Sie Live Activities noch heute in Ihre Flutter-App ein und erleben Sie selbst die beeindruckende UX-Innovation einer um 40 % gesteigerten Benutzerbindung (Engagement).
Verwandter Artikel: Im Leitfaden Flutter Impeller Engine-Optimierung: 120fps mit Metal & Vulkan finden Sie weitere Details zur mobilen Rendering-Performance.