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 控制中心组件开发

相关推荐
天桥吴彦祖20 小时前
判断iOS如何监听手机屏幕是否锁屏
ios
东坡肘子1 天前
SPI 加入 Apple,Swift 迈向自举 -- 肘子的 Swift 周报 #142
人工智能·swiftui·swift
敲代码的鱼2 天前
PDF 预览与签名批注写回 支持安卓 iOS 鸿蒙 UTS插件
android·前端·ios
时光足迹2 天前
uni-app 视频通话实战:康复师与患者视频问诊的 6 个致命 Bug 与解决方案
android·ios·uni-app
时光足迹2 天前
JPush UniApp UTS 插件完全参考手册:API、事件与厂商通道一网打尽
vue.js·ios·uni-app
时光足迹2 天前
极光推送全攻略(下):uni-app 代码实现与 iOS 排查实战
vue.js·ios·uni-app
时光足迹2 天前
极光推送全攻略(上):被iOS证书折磨了三天,我写了一份前端也能看懂的避坑指南
前端·ios·uni-app
编程范式3 天前
SwiftUI 中图片如何适配可用空间
ios
songgeb5 天前
启发式 UI 自动化:从线性剧本到每步读屏决策
ios·测试
东坡肘子8 天前
Swift 还让你 Excited 吗?-- 肘子的 Swift 周报 #141
人工智能·swiftui·swift