effidevFlutter · Cloudflare-Edge · Cloud-Kostenoptimierung
Deutsch

Flutter Dynamic Island & Live Activities: iOS/Android

Flutter Dynamic Island and iOS Live Activities architecture guide

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)
  1. Activity starten: Beim Start einer Lieferung oder eines Timers ruft die Flutter-App über einen MethodChannel das Swift ActivityKit auf.
  2. Push-Token abrufen: Auf iOS-Seite wird ein zugewiesener pushToken für die jeweilige Live Activity generiert und an den Backend-Server (Cloudflare Workers / Firebase) gesendet.
  3. 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

  1. Runner.xcworkspace in Xcode öffnen
  2. File > New > Target wählen
  3. Widget Extension auswä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

  1. Keine App-internen Hintergrund-Timer verwenden: Wenn die App in den Hintergrund wechselt und der Status über Timer.periodic im 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.
  2. Stale Date festlegen: Beim Start von ActivityKit sollte ein staleDate konfiguriert werden, sodass das Betriebssystem das Widget automatisch bereinigt, wenn länger als 1 Stunde keine Aktualisierung erfolgt ist.
  3. 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:

  1. 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.
  2. Maximierte Akkueffizienz: Durch die Anbindung von SwiftUI-Widgets auf Betriebssystemebene von iOS statt komplexer App-Hintergrundlogik werden Geräteleistung und Akku optimal geschont.
  3. 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.