WWDC Quick Look 💓 By SwiftGGTeam
Live Activities essentials

Live Activities essentials

观看原视频

Highlight

iOS 27 让 Live Activities 从竖屏锁屏扩展到横屏灵动岛、StandBy、Apple Watch 和 CarPlay,开发者通过 ActivityKit 的数据模型分离、WidgetKit 的多尺寸视图配置以及 LiveActivityIntent 的交互能力,可以用一套代码在所有设备上提供实时信息更新。

核心内容

想象你正在用外卖 App 点了一杯咖啡。下单后你想知道什么时候能做好,但不想反复打开 App 查看。这就是 Live Activities 要解决的问题。

在 iOS 27 之前,Live Activities 只出现在竖屏锁屏和灵动岛。横屏打游戏、看视频时,这些信息就消失了。StandBy 模式下充电的 iPhone 也看不到进度。Apple Watch 和 CarPlay 更是完全无缘。

iOS 27 改变了这一切。横屏状态下灵动岛依然显示 Live Activities,只是宽度被压缩。StandBy 模式会把锁屏视图放大到 200% 展示。同一个 Live Activity 还会自动同步到 Apple Watch 的 Smart Stack、macOS 菜单栏和 CarPlay 仪表盘。

这意味着开发者不能只写一套 UI 就完事。横屏时宽度受限,StandBy 时背景需要铺满,Apple Watch 和 CarPlay 的空间极小。Apple 没有让开发者硬编码尺寸,而是提供了一组环境变量来判断当前展示场景,让同一套视图根据环境自动调整内容。

另一个变化是交互。以前用户点击 Live Activity 只能跳回 App。现在可以通过 LiveActivityIntent 直接在锁屏或灵动岛里完成轻量操作,比如给咖啡订单打分,全程不需要打开 App。

详细内容

数据模型:静态与动态分离

04:16

Live Activities 要求把数据分成两类:永远不会变的放在 ActivityAttributes 里,会随时间变化的放在 ContentState 里。这种分离让系统可以高效地推送更新,只传变化的部分。

以咖啡订单为例:

import ActivityKit
import Foundation

public struct DrinkOrderAttributes: ActivityAttributes {
    let shopName: String
    let drink: Drink
    let orderID: UUID

    public struct ContentState: Codable, Hashable {
        var phase: DrinkOrder.Phase = .waiting
        var estimatedReadyDate: Date
        var rating: DrinkOrder.Rating?
    }
}

关键点:

  • shopNamedrinkorderID 是静态数据,下单后不会变
  • phaseestimatedReadyDaterating 是动态数据,会随订单进度更新
  • ContentState 必须遵循 CodableHashable,因为系统要序列化后跨进程传递

多尺寸视图配置

05:35

Live Activity 的界面用 WidgetKit 构建。在 Widget Extension 里提供一个 ActivityConfiguration,定义各个展示位置的视图:

import ActivityKit
import SwiftUI
import WidgetKit

struct DrinkOrderLiveActivity: Widget {
    var body: some WidgetConfiguration {
        ActivityConfiguration(for: DrinkOrderAttributes.self) { context in
            ActivityView(context: context)
        } dynamicIsland: { context in
            DynamicIsland {
                DynamicIslandExpandedRegion(.leading) {
                    ExpandedLeadingView(context: context)
                }
                DynamicIslandExpandedRegion(.center) {
                    ExpandedCenterView(context: context)
                }
                DynamicIslandExpandedRegion(.trailing) {
                    ExpandedTrailingView(context: context)
                }
                DynamicIslandExpandedRegion(.bottom) {
                    ExpandedBottomView(context: context)
                }
            } compactLeading: {
                CompactLeadingView(context: context)
            } compactTrailing: {
                CompactTrailingView(context: context)
            } minimal: {
                MinimalView(context: context)
            }
        }
    }
}

关键点:

  • ActivityConfiguration 通过泛型参数 DrinkOrderAttributes.self 绑定数据模型
  • content 闭包返回锁屏和 StandBy 上显示的视图
  • dynamicIsland 闭包定义灵动岛的三种尺寸:expanded(长按展开)、compact(两侧小条)、minimal(多个活动并存时的最小圆点)
  • 每个视图通过 context 参数读取当前的状态数据

启动与更新

07:43

启动 Live Activity 前,先检查用户是否授权:

func launchLiveActivity(order: DrinkOrder) throws {
    guard ActivityAuthorizationInfo().areActivitiesEnabled else { return }

    let attributes = DrinkOrderAttributes(
        shopName: "Coffee Shop",
        drink: order.drink,
        orderID: order.id
    )

    let estimatedReadyDate = Date.now + (15 * 60)
    let contentState = DrinkOrderAttributes.ContentState(
        phase: .waiting,
        estimatedReadyDate: estimatedReadyDate
    )

    let activityContent = ActivityContent(state: contentState, staleDate: nil)
    let activity = try Activity.request(attributes: attributes, content: activityContent)
}

更新时调用 update 方法:

await activity.update(
    ActivityContent(
        state: DrinkOrderAttributes.ContentState(
            phase: .preparing,
            estimatedReadyDate: estimatedReadyDate
        ),
        staleDate: nil
    )
)

关键点:

  • areActivitiesEnabled 检查用户是否在设置中关闭了 Live Activities
  • staleDate 用于标记内容何时过期,过期后系统会显示过期状态提示用户
  • 更新可以通过前台代码直接调用,也可以通过推送通知在后台完成
  • 推送更新有两种策略:广播频道(适合大规模并发场景,如赛事直播)和单设备推送(适合个性化场景)

横屏灵动岛的宽度适配

10:33

iOS 27 灵动岛在横屏时宽度受限,需要检测环境并切换内容:

struct CompactTrailingView: View {
    @Environment(\.isDynamicIslandLimitedInWidth) var isDynamicIslandLimitedInWidth
    var context: ActivityViewContext<DrinkOrderAttributes>

    var body: some View {
        if isDynamicIslandLimitedInWidth {
            StepProgressIconView(context: context)
        } else if context.state.phase.showsTimer {
            EstimatedReadyView(context: context, font: .system(.body).monospacedDigit())
                .multilineTextAlignment(.trailing)
                .frame(maxWidth: maximumTimerLabelWidth)
        } else {
            OrderPhaseLabelView(context: context, font: .caption2.bold(), color: .brown)
                .multilineTextAlignment(.trailing)
        }
    }
}

关键点:

  • isDynamicIslandLimitedInWidth 只在横屏时返回 true
  • 宽度受限时应该把文本替换为图标,避免布局溢出
  • 竖屏时空间充足,可以显示倒计时或状态标签

StandBy 背景适配

11:34

StandBy 模式下锁屏视图被放大到 200%,原来的渐变背景会让内容显得很小。通过环境变量判断是否需要自己绘制背景:

struct ActivityView: View {
    @Environment(\.showsWidgetContainerBackground) var showsWidgetContainerBackground
    var context: ActivityViewContext<DrinkOrderAttributes>

    var body: some View {
        DetailView(context: context)
            .background {
                if showsWidgetContainerBackground {
                    LinearGradient.barista
                }
            }
            .activityBackgroundTint(.espresso)
    }
}

关键点:

  • showsWidgetContainerBackground 在锁屏时为 true,在 StandBy 时为 false
  • 锁屏上保留渐变背景,StandBy 下用 .activityBackgroundTint 设置全屏底色
  • 这样 StandBy 模式下 Live Activity 会铺满整个屏幕区域

Apple Watch 和 CarPlay 的 small 适配

12:30

Apple Watch 和 CarPlay 的空间极小,需要声明支持 .small 活动家族并提供精简视图:

struct DrinkOrderLiveActivity: Widget {
    var body: some WidgetConfiguration {
        ActivityConfiguration(for: DrinkOrderAttributes.self) { context in
            ActivityView(context: context)
        } dynamicIsland: { context in
            // ... Dynamic Island 配置
        }
        .supplementalActivityFamilies([.small])
    }
}

在视图内部根据 activityFamily 切换布局:

struct ActivityView: View {
    @Environment(\.showsWidgetContainerBackground) var showsWidgetContainerBackground
    @Environment(\.activityFamily) var activityFamily
    var context: ActivityViewContext<DrinkOrderAttributes>

    var body: some View {
        contentView
            .background {
                if showsWidgetContainerBackground {
                    LinearGradient.barista
                }
            }
            .activityBackgroundTint(.espresso)
    }

    @ViewBuilder
    var contentView: some View {
        if activityFamily == .small {
            SmallView(context: context)
        } else {
            DetailView(context: context)
        }
    }
}

关键点:

  • .supplementalActivityFamilies([.small]) 声明支持小尺寸设备
  • activityFamily.small 时代表 Apple Watch 或 CarPlay
  • 小尺寸视图必须极度精简,只保留最核心的一个数字或状态图标
  • iPhone 上的 Live Activity 会自动转发到 Apple Watch 和 CarPlay,不需要额外代码

通过 App Intents 添加交互

13:36

让 Live Activity 从”只能看”变成”能操作”,需要定义一个遵循 LiveActivityIntent 的 Intent:

struct RateDrinkIntent: LiveActivityIntent {
    static var title: LocalizedStringResource = "Rate Drink"

    @Parameter(title: "Order ID")
    var orderID: String

    @Parameter(title: "Positive")
    var isPositive: Bool

    func perform() async throws -> some IntentResult {
        await updateLocalDatastore(rating: isPositive ? .great : .poor, dismissPolicy: .after(.now + 15))
        return .result()
    }
}

在视图中把按钮和 Intent 关联:

struct RatingButtons: View {
    var context: ActivityViewContext<DrinkOrderAttributes>

    var body: some View {
        HStack(spacing: 12) {
            Button(intent: RateDrinkIntent(
                orderID: context.attributes.orderID.uuidString, isPositive: false)) {
                Label("Not Good", systemImage: "hand.thumbsdown.fill")
            }
            .buttonStyle(RatingButtonStyle(color: .red))

            Button(intent: RateDrinkIntent(
                orderID: context.attributes.orderID.uuidString, isPositive: true)) {
                Label("Great", systemImage: "hand.thumbsup.fill")
            }
            .buttonStyle(RatingButtonStyle(color: .green))
        }
    }
}

关键点:

  • LiveActivityIntent 继承自 App Intents 框架,执行环境在后台
  • perform() 方法里可以更新本地数据库、发送网络请求,但不能做 UI 弹窗
  • dismissPolicy: .after(.now + 15) 让 Live Activity 在操作完成后 15 秒自动消失
  • 按钮通过 Button(intent:) 初始化器直接绑定 Intent,点击后系统静默执行

核心启发

做一个外卖进度追踪器

做什么:用户下单后,Live Activity 实时显示骑手位置、预计送达时间。横屏打游戏时也能在灵动岛看到进度。

为什么值得做:用户不需要反复打开 App 查看订单状态,实时信息常驻在锁屏和灵动岛。横屏状态下宽度受限时把地图缩略图换成配送中图标,确保信息始终可见。

怎么开始:定义 ActivityAttributesContentState 数据模型,创建 ActivityConfiguration,用 isDynamicIslandLimitedInWidth 在横屏时切换为图标展示。

做一个赛事比分速览

做什么:体育 App 在球赛进行时推送比分变化,Apple Watch 上通过 .small 家族只显示比分和剩余时间。

为什么值得做:几万人同时看同一场比赛,用广播频道推送更新避免 APNs 限流。多设备同步让用户在任何场景下都能快速获取比分,不需要打开 App。

怎么开始:用 Activity.request 启动 Live Activity,配置广播频道推送,声明 .supplementalActivityFamilies([.small]) 支持 Apple Watch 和 CarPlay。

做一个番茄钟专注助手

做什么:用户开始专注后,Live Activity 显示剩余时间。StandBy 模式下全屏显示倒计时。专注结束时让用户直接在锁屏上标记”完成”或”延长 5 分钟”。

为什么值得做:专注计时需要持续可见但不应打断当前工作。Live Activity 在锁屏和 StandBy 上常驻显示,通过 LiveActivityIntent 用户无需解锁就能操作。

怎么开始:创建遵循 LiveActivityIntent 的 Intent,在 perform() 中更新计时器状态,用 Button(intent:) 绑定到锁屏界面按钮。

做一个航班信息卡片

做什么:登机前 Live Activity 显示登机口、登机时间。起飞后自动更新为行李转盘信息。

为什么值得做:航班信息变化频繁,用户需要在关键时刻快速查看。用 staleDate 标记航班计划时间,如果延误超过该时间,UI 自动显示过期提示,避免用户看到过时的登机口信息。

怎么开始:启动时传入 ActivityContent(state:staleDate:),航班状态变化时调用 update 推送新状态,设置合适的 staleDate 标记信息有效期。

做一个智能家居快捷面板

做什么:空调、净化器等设备的状态通过 Live Activity 常驻显示。用户可以在锁屏上直接调节温度或开关设备。

为什么值得做:频繁查看和操作智能家居设备时,每次打开 App 都很繁琐。Live Activity 让状态常驻,结合 LiveActivityIntent 实现锁屏直接操作。

怎么开始:定义 LiveActivityIntent,在 perform() 中调用 HomeKit API 或自有后端,用 Button(intent:) 在 Live Activity 视图中绑定操作按钮。

关联 Session

评论

GitHub Issues · utterances