iOS Widget 开发-13:Live Activity 实战详解

Live Activity(实时活动)是 iOS 16 引入的一项强大功能,用于在锁屏和灵动岛上展示实时更新的信息。与普通 Widget 不同,Live Activity 支持秒级更新、支持远程推送触发更新,是外卖配送、赛事比分、计时器等时效性场景的理想选择。

本篇将带你从零开始构建一个完整的 Live Activity。


1. Live Activity 与传统 Widget 的区别

维度 传统 Widget Live Activity
框架 WidgetKit ActivityKit + WidgetKit(视图部分)
更新频率 分钟级(最低约 15 分钟) 秒级
触发方式 Timeline 自动调度 App 主动推送 / APNs 远程推送
生命周期 由系统管理 由 App 显式启动和结束
展示位置 主屏、锁屏、待机等 灵动岛、锁屏横幅
数据流向 被动拉取(Timeline) 主动推送(update)
是否有状态 无状态(每次重新构建 Timeline) 有状态(activityState:active/ended)

2. 创建 Live Activity

Step 1:添加 Live Activity Target

创建 Widget Extension 时勾选 Include Live Activity,或在已有 Target 中添加 Live Activity 文件。

Step 2:定义 ActivityAttributes

swift 复制代码
import ActivityKit

struct DeliveryAttributes: ActivityAttributes {
    // 静态数据(活动创建时确定,不可变)
    public struct ContentState: Codable, Hashable {
        // 动态数据(可更新)
        var statusText: String      // "商家正在备货"
        var progress: Double        // 0.0 ~ 1.0
        var estimatedMinutes: Int   // 预计剩余分钟数
        var driverName: String?
        var driverPhoto: String?
    }

    // 静态属性
    var orderNumber: String
    var restaurantName: String
    var totalAmount: String
}

Step 3:构建灵动岛视图

swift 复制代码
import WidgetKit
import SwiftUI

struct DeliveryActivityWidget: Widget {
    var body: some WidgetConfiguration {
        ActivityConfiguration(for: DeliveryAttributes.self) { context in
            // 锁屏展示
            VStack(alignment: .leading, spacing: 8) {
                HStack {
                    Image(systemName: "takeoutbag.and.cup.and.straw.fill")
                        .font(.title)
                    VStack(alignment: .leading) {
                        Text(context.attributes.restaurantName)
                            .font(.headline)
                        Text("订单号: \(context.attributes.orderNumber)")
                            .font(.caption)
                            .foregroundColor(.secondary)
                    }
                }

                ProgressView(value: context.state.progress) {
                    Text(context.state.statusText)
                        .font(.caption)
                }

                HStack {
                    Label("预计 \(context.state.estimatedMinutes) 分钟", systemImage: "clock")
                        .font(.caption)
                        .foregroundColor(.secondary)

                    Spacer()

                    if let driverName = context.state.driverName {
                        Label(driverName, systemImage: "person.circle")
                            .font(.caption)
                            .foregroundColor(.secondary)
                    }
                }
            }
            .padding()
            .activityBackgroundTint(Color.black.opacity(0.2))
            .activitySystemActionForegroundColor(.white)
        } dynamicIsland: { context in
            // 灵动岛展示
            DynamicIsland {
                // 展开区域
                DynamicIslandExpandedRegion(.leading) {
                    Label(context.state.statusText, systemImage: "takeoutbag.and.cup.and.straw.fill")
                        .font(.caption)
                }
                DynamicIslandExpandedRegion(.trailing) {
                    Label("\(context.state.estimatedMinutes)分钟", systemImage: "clock")
                        .font(.caption)
                }
                DynamicIslandExpandedRegion(.bottom) {
                    ProgressView(value: context.state.progress)
                        .tint(.green)
                }
            } compactLeading: {
                // 灵动岛左侧紧凑视图
                Image(systemName: "takeoutbag.and.cup.and.straw.fill")
                    .font(.caption)
            } compactTrailing: {
                // 灵动岛右侧紧凑视图
                Text("\(context.state.estimatedMinutes)min")
                    .font(.caption2)
            } minimal: {
                // 极小模式
                Image(systemName: "takeoutbag.and.cup.and.straw.fill")
            }
        }
    }
}

3. 启动与管理 Live Activity

启动 Activity

swift 复制代码
import ActivityKit

func startDeliveryActivity(order: Order) throws {
    let attributes = DeliveryAttributes(
        orderNumber: order.number,
        restaurantName: order.restaurantName,
        totalAmount: order.totalAmount
    )

    let contentState = DeliveryAttributes.ContentState(
        statusText: "商家已接单",
        progress: 0.1,
        estimatedMinutes: 30,
        driverName: nil,
        driverPhoto: nil
    )

    let activity = try Activity<DeliveryAttributes>.request(
        attributes: attributes,
        contentState: contentState,
        pushType: .token  // 如果需要远程推送更新,设置 pushType
    )

    // 保存 push token(用于远程推送更新)
    Task {
        for await pushToken in activity.pushTokenUpdates {
            let tokenString = pushToken.map { String(format: "%02x", $0) }.joined()
            // 发送 token 到你的服务器
            await sendTokenToServer(tokenString)
        }
    }
}

更新 Activity

swift 复制代码
func updateDeliveryStatus(activity: Activity<DeliveryAttributes>, newStatus: DeliveryAttributes.ContentState) async {
    await activity.update(using: newStatus)
}

结束 Activity

swift 复制代码
func endDelivery(activity: Activity<DeliveryAttributes>) async {
    let finalState = DeliveryAttributes.ContentState(
        statusText: "已送达",
        progress: 1.0,
        estimatedMinutes: 0,
        driverName: "张师傅",
        driverPhoto: nil
    )

    await activity.end(using: finalState, dismissalPolicy: .after(Date().addingTimeInterval(3600)))
    // dismissalPolicy: .default(.after(x)) 在 x 时间后自动移除
    //                     .immediate 立即移除
}

4. 通过 APNs 远程推送更新

Live Activity 最大的优势之一是支持通过 APNs 远程推送更新,适合服务器驱动的实时场景。

推送 Payload 格式

json 复制代码
{
    "aps": {
        "timestamp": 1680000000,
        "event": "update",
        "content-state": {
            "statusText": "骑手正在配送中",
            "progress": 0.5,
            "estimatedMinutes": 15,
            "driverName": "李师傅"
        }
    }
}

注意事项

  • 推送 token 与普通推送 token 不同,需通过 pushTokenUpdates 获取
  • 推送频率受系统限制,建议不要过于频繁(APNs 会对高频推送进行节流)
  • content-state 必须与 ContentState 结构完全匹配

5. 监听 Activity 状态变化

swift 复制代码
// 监听所有活跃的 Activity
func monitorActivities() {
    Task {
        for await activity in Activity<DeliveryAttributes>.activityUpdates {
            print("Activity 状态: \(activity.activityState)")
        }
    }
}

// 监听特定 Activity 的状态
func monitorActivity(_ activity: Activity<DeliveryAttributes>) {
    Task {
        for await state in activity.activityStateUpdates {
            switch state {
            case .active:
                print("Activity 活跃中")
            case .ended:
                print("Activity 已结束")
            case .dismissed:
                print("Activity 已被移除")
            @unknown default:
                break
            }
        }
    }
}

6. 完整实战:倒计时 Live Activity

swift 复制代码
// Attributes
struct TimerAttributes: ActivityAttributes {
    public struct ContentState: Codable, Hashable {
        var targetDate: Date
        var label: String
    }

    var timerName: String
}

// 启动倒计时
func startTimerActivity(name: String, targetDate: Date) throws {
    let attributes = TimerAttributes(timerName: name)
    let state = TimerAttributes.ContentState(targetDate: targetDate, label: name)

    let activity = try Activity<TimerAttributes>.request(
        attributes: attributes,
        contentState: state,
        pushType: nil
    )
}

// Widget 视图(锁屏展示倒计时)
@main
struct TimerActivityWidget: Widget {
    var body: some WidgetConfiguration {
        ActivityConfiguration(for: TimerAttributes.self) { context in
            VStack(spacing: 4) {
                Text(context.attributes.timerName)
                    .font(.headline)
                Text(context.state.targetDate, style: .timer)
                    .font(.system(size: 48, weight: .bold, design: .monospaced))
                    .multilineTextAlignment(.center)
            }
            .padding()
        } dynamicIsland: { context in
            DynamicIsland {
                DynamicIslandExpandedRegion(.center) {
                    Text(context.state.targetDate, style: .timer)
                        .font(.title)
                }
            } compactLeading: {
                Image(systemName: "timer")
            } compactTrailing: {
                Text(context.state.targetDate, style: .timer)
                    .font(.caption2)
            } minimal: {
                Image(systemName: "timer")
            }
        }
    }
}

7. 限制与注意事项

限制 说明
最大活跃数 每个 App 通常限制为 5 个活跃 Activity
数据大小 ContentState 需要尽量小(避免大 JSON)
远程推送更新频率 过高频率会被 APNs 节流
Activity 持续时间 最长 8 小时(结束后可继续展示 4 小时)
需用户授权 Live Activity 需要在系统设置中授权才能展示
不支持交互按钮 灵动岛展开视图不支持 Button(intent:)

小结

  • Live Activity 适合需要频繁更新的时效性信息展示
  • 通过 ActivityAttributes + ContentState 定义数据结构
  • 支持本地更新和 APNs 远程推送更新
  • 灵动岛有三种展示模式:Compact、Expanded、Minimal
  • 注意推送频率限制和活跃数量限制

最后,希望这篇文章能帮到有需要的朋友,如果觉得有帮助,点个赞、加个关注,笔者也会继续努力输出更多优质内容。

上一篇iOS Widget 开发-12:Widget 深度链接与导航
下一篇iOS Widget 开发-14:iOS 18 控制中心组件开发

相关推荐
星星电灯猴37 分钟前
全面解决Charles抓取HTTPS请求响应中文乱码问题的方法与技巧
后端·ios
人月神话-Lee1 小时前
【WWDC】Core AI:iOS 端侧大模型新纪元
人工智能·ios·ai·swift·wwdc·core ai
2501_916007472 小时前
iOS 开发工具选择指南 从编辑器、编译器到自动化构建
ide·vscode·ios·objective-c·个人开发·swift·敏捷流程
库奇噜啦呼2 小时前
【iOS】源码学习-YYModel源码学习
学习·ios·cocoa
风华圆舞3 小时前
一个 Flutter 项目同时保留 Android、iOS、HarmonyOS 支持的实践
android·flutter·ios
2501_915921433 小时前
uni-app 上架 iOS 的完整流程(无需依赖 Mac)
android·macos·ios·小程序·uni-app·iphone·webview
Fatbobman(东坡肘子)3 小时前
WWDC 2026 初印象:符合预期,但更务实 -- 肘子的 Swift 周报 #139
人工智能·macos·ios·swiftui·swift·wwdc
for_ever_love__17 小时前
UI学习:UICollectionView瀑布流
学习·ui·ios·objective-c·cocoa