【SwiftUI提高练中学】第13课 实时活动与灵动岛:ActivityKit 实战

本课目标:

入门第15课做过小组件,第17课做过通知。本课让 App 的信息出现在锁屏、灵动岛和待机显示上:学习 ActivityKit、ActivityAttributes、ActivityContent、动态岛紧凑/最小/展开三种形态,并用推送更新实时活动。完成后,你的"我的书库"能在锁屏显示阅读进度,在灵动岛显示计时。

一、核心概念

· ActivityKit:实时活动框架,iOS 16.1+。

· ActivityAttributes:实时活动的数据描述,分静态属性和动态内容。

· ActivityContent:活动内容,包含状态和过期时间。

· Activity:活动实例,用 request 启动,用 update 更新,用 end 结束。

· 灵动岛三态:紧凑(compact)、最小(minimal)、展开(expanded)。

· 锁屏视图:ActivityConfiguration 的锁屏部分。

· 推送更新:用 APNs 远程更新实时活动。

· 待机显示:iOS 17 横屏充电时显示。

· Live Activity 限制:最多同时 8 小时活跃,之后系统回收。

· 动态岛适配:必须同时支持紧凑、最小、展开三种形态。

二、准备工作

  1. Info.plist 添加 NSSupportsLiveActivities 为 YES。
  2. Widget Extension 中创建实时活动。
  3. 主 App Target 引入 ActivityKit。
  4. 真机测试,模拟器部分支持但推送更新需要真机。

三、定义活动属性

swift 复制代码
import ActivityKit
import Foundation

// ActivityAttributes:描述实时活动的数据结构
struct ReadingActivityAttributes: ActivityAttributes {
    // 静态属性:活动期间不变
    let bookTitle: String
    let bookAuthor: String

    // ContentState:动态内容,活动期间会变化
    public struct ContentState: Codable, Hashable {
        var currentPage: Int      // 当前页
        var totalPages: Int       // 总页数
        var isPaused: Bool        // 是否暂停

        // 计算属性:进度百分比
        var progress: Double {
            guard totalPages > 0 else { return 0 }
            return Double(currentPage) / Double(totalPages)
        }
    }
}

解读:静态属性在活动创建时确定,动态内容随状态更新。ContentState 必须 Codable 和 Hashable,因为要序列化和比较。

四、启动实时活动

swift 复制代码
import ActivityKit
import SwiftUI

// 阅读进度管理器
@Observable
final class ReadingActivityManager {
    static let shared = ReadingActivityManager()

    // 当前活动
    private var activity: Activity<ReadingActivityAttributes>?

    // 是否支持实时活动
    var isSupported: Bool {
        ActivityAuthorizationInfo().areActivitiesEnabled
    }

    // 开始阅读活动
    func start(book: Book) throws {
        // 检查支持
        guard isSupported else {
            throw ActivityError.notSupported
        }

        // 如果已有活动,先结束
        Task { await end() }

        // 创建初始状态
        let state = ReadingActivityAttributes.ContentState(
            currentPage: 1,
            totalPages: 300,
            isPaused: false
        )

        // 创建内容,设置过期时间
        let content = ActivityContent(
            state: state,
            staleDate: Date().addingTimeInterval(60 * 60) // 1 小时后过期
        )

        // 启动活动
        activity = try Activity.request(
            attributes: ReadingActivityAttributes(
                bookTitle: book.title,
                bookAuthor: book.author
            ),
            content: content,
            pushType: .token // 获取推送 token,支持远程更新
        )
    }

    // 更新进度
    func update(currentPage: Int, totalPages: Int, isPaused: Bool) async {
        let state = ReadingActivityAttributes.ContentState(
            currentPage: currentPage,
            totalPages: totalPages,
            isPaused: isPaused
        )

        let content = ActivityContent(
            state: state,
            staleDate: Date().addingTimeInterval(60 * 60)
        )

        // 更新活动
        await activity?.update(content)
    }

    // 结束活动
    func end() async {
        let finalState = ReadingActivityAttributes.ContentState(
            currentPage: 0,
            totalPages: 0,
            isPaused: false
        )
        let content = ActivityContent(state: finalState, staleDate: nil)

        // 结束并关闭
        await activity?.end(content, dismissalPolicy: .immediate)
        activity = nil
    }

    enum ActivityError: Error {
        case notSupported
    }
}

解读:pushType: .token 让活动可以接收远程推送更新;staleDate 让系统知道内容何时过期;dismissalPolicy: .immediate 立即移除。

五、锁屏与灵动岛视图

swift 复制代码
import WidgetKit
import SwiftUI
import ActivityKit

// 实时活动配置
struct ReadingActivityWidget: Widget {
    var body: some WidgetConfiguration {
        ActivityConfiguration(for: ReadingActivityAttributes.self) { context in
            // 锁屏视图
            LockScreenReadingView(context: context)
                .activityBackgroundTint(.black.opacity(0.8))
                .activitySystemActionForegroundColor(.white)

        } dynamicIsland: { context in
            // 灵动岛
            DynamicIsland {
                // 展开视图:长按后显示
                DynamicIslandExpandedRegion(.leading) {
                    Image(systemName: "book.fill")
                        .foregroundStyle(.blue)
                        .font(.title2)
                }

                DynamicIslandExpandedRegion(.trailing) {
                    Text("\(Int(context.state.progress * 100))%")
                        .font(.title2)
                        .bold()
                        .foregroundStyle(.blue)
                }

                DynamicIslandExpandedRegion(.center) {
                    Text(context.attributes.bookTitle)
                        .font(.headline)
                        .lineLimit(1)
                }

                DynamicIslandExpandedRegion(.bottom) {
                    VStack(spacing: 4) {
                        ProgressView(value: context.state.progress)
                            .tint(.blue)

                        HStack {
                            Text("第 \(context.state.currentPage) 页")
                            Spacer()
                            Text("共 \(context.state.totalPages) 页")
                        }
                        .font(.caption)
                        .foregroundStyle(.secondary)
                    }
                }

            } compactLeading: {
                // 紧凑左侧:小图标
                Image(systemName: context.state.isPaused ? "pause.fill" : "book.fill")
                    .foregroundStyle(.blue)

            } compactTrailing: {
                // 紧凑右侧:进度
                Text("\(Int(context.state.progress * 100))%")
                    .font(.caption2)
                    .bold()
                    .foregroundStyle(.blue)

            } minimal: {
                // 最小视图:只显示一个元素
                Image(systemName: context.state.isPaused ? "pause.fill" : "book.fill")
                    .foregroundStyle(.blue)
            }
        }
    }
}

// 锁屏视图
struct LockScreenReadingView: View {
    let context: ActivityViewContext<ReadingActivityAttributes>

    var body: some View {
        VStack(spacing: 12) {
            HStack {
                Image(systemName: "book.fill")
                    .foregroundStyle(.blue)

                VStack(alignment: .leading, spacing: 2) {
                    Text(context.attributes.bookTitle)
                        .font(.headline)
                        .lineLimit(1)
                    Text(context.attributes.bookAuthor)
                        .font(.caption)
                        .foregroundStyle(.secondary)
                }

                Spacer()

                Text("\(Int(context.state.progress * 100))%")
                    .font(.title3)
                    .bold()
                    .foregroundStyle(.blue)
            }

            ProgressView(value: context.state.progress)
                .tint(.blue)

            HStack {
                Text("第 \(context.state.currentPage) 页")
                Spacer()
                Text("共 \(context.state.totalPages) 页")
            }
            .font(.caption)
            .foregroundStyle(.secondary)
        }
        .padding()
    }
}

解读:ActivityConfiguration 同时定义锁屏和灵动岛外观;灵动岛必须提供 compactLeading、compactTrailing、minimal 三种紧凑形态,以及展开区域的四个位置。

六、在 App 中使用

swift 复制代码
import SwiftUI

struct ReadingView: View {
    let book: Book
    @State private var currentPage = 1
    @State private var totalPages = 300
    @State private var isPaused = false
    @State private var manager = ReadingActivityManager.shared

    var body: some View {
        VStack(spacing: 20) {
            Text(book.title).font(.title).bold()
            Text(book.author).foregroundStyle(.secondary)

            ProgressView(value: Double(currentPage), total: Double(totalPages))
                .tint(.blue)

            Text("第 \(currentPage) / \(totalPages) 页")
                .font(.headline)

            HStack(spacing: 20) {
                Button("上一页") {
                    currentPage = max(1, currentPage - 1)
                    Task { await manager.update(currentPage: currentPage, totalPages: totalPages, isPaused: isPaused) }
                }
                .buttonStyle(.bordered)

                Button(isPaused ? "继续" : "暂停") {
                    isPaused.toggle()
                    Task { await manager.update(currentPage: currentPage, totalPages: totalPages, isPaused: isPaused) }
                }
                .buttonStyle(.borderedProminent)

                Button("下一页") {
                    currentPage = min(totalPages, currentPage + 1)
                    Task { await manager.update(currentPage: currentPage, totalPages: totalPages, isPaused: isPaused) }
                }
                .buttonStyle(.bordered)
            }

            Button("开始实时活动") {
                try? manager.start(book: book)
            }
            .buttonStyle(.borderedProminent)

            Button("结束实时活动") {
                Task { await manager.end() }
            }
            .buttonStyle(.bordered)
        }
        .padding()
    }
}

七、推送更新实时活动

  1. 获取推送 token
swift 复制代码
// 在启动活动后监听 token
func observePushToken() {
    Task {
        for await tokenData in activity?.pushTokenUpdates ?? .never {
            let token = tokenData.map { String(format: "%02x", $0) }.joined()
            print("推送 token: \(token)")
            // 把 token 上传到你的服务器
        }
    }
}
  1. 服务器推送

APNs 推送 payload:

json 复制代码
{
  "aps": {
    "timestamp": 1234567890,
    "event": "update",
    "content-state": {
      "currentPage": 50,
      "totalPages": 300,
      "isPaused": false
    },
    "alert": {
      "title": "阅读进度",
      "body": "已读到第 50 页"
    }
  }
}

推送头:

· apns-topic: .push-type.liveactivity

· apns-push-type: liveactivity

· apns-priority: 10

解读:content-state 必须与 ContentState 结构一致;event 可以是 update 或 end。

八、待机显示适配

swift 复制代码
// 锁屏视图在待机显示下会自动放大
// 通过 padding 和字号适配
struct StandByReadingView: View {
    let context: ActivityViewContext<ReadingActivityAttributes>

    var body: some View {
        VStack(spacing: 16) {
            Image(systemName: "book.fill")
                .font(.system(size: 40))
                .foregroundStyle(.blue)

            Text(context.attributes.bookTitle)
                .font(.title3)
                .bold()

            ProgressView(value: context.state.progress)
                .tint(.blue)

            Text("\(Int(context.state.progress * 100))%")
                .font(.headline)
        }
        .padding()
    }
}

解读:待机显示下锁屏视图会被放大,设计时留出足够边距,字号稍大。

九、习题、答案与解读

选择题

题1

题目:声明实时活动数据结构使用?

A. ActivityAttributes B. Widget C. AppIntent D. TimelineEntry

要求:选出正确选项。

答案:A

解读:ActivityAttributes 包含静态属性和 ContentState。

题2

题目:启动实时活动使用?

A. Activity.request B. Activity.start C. Activity.begin D. Activity.create

要求:选出正确选项。

答案:A

解读:Activity.request(attributes:content:pushType:) 启动活动。

题3

题目:灵动岛必须提供哪三种紧凑形态?

A. compactLeading、compactTrailing、minimal B. small、medium、large C. leading、center、trailing D. top、middle、bottom

要求:选出正确选项。

答案:A

解读:灵动岛紧凑态分左右和最小三种。

题4

题目:结束实时活动使用?

A. activity.end B. activity.stop C. activity.finish D. activity.close

要求:选出正确选项。

答案:A

解读:await activity.end(content, dismissalPolicy:)。

题5

题目:实时活动最长活跃时间约为?

A. 8 小时 B. 24 小时 C. 1 小时 D. 永久

要求:选出正确选项。

答案:A

解读:系统最多保留 8 小时,之后回收。

判断题

题1

题目:实时活动需要 NSSupportsLiveActivities 为 YES。

要求:判断对错。

答案:对

解读:Info.plist 必须声明支持。

题2

题目:ContentState 必须遵循 Codable 和 Hashable。

要求:判断对错。

答案:对

解读:需要序列化和比较。

题3

题目:实时活动可以在模拟器上完整测试推送更新。

要求:判断对错。

答案:错

解读:推送更新需要真机测试。

题4

题目:pushType: .token 让活动支持远程推送更新。

要求:判断对错。

答案:对

解读:获取 token 后可远程更新。

题5

题目:锁屏视图和灵动岛视图必须完全一样。

要求:判断对错。

答案:错

解读:灵动岛有紧凑和展开形态,锁屏是完整视图。

填空题

题1

题目:实时活动数据结构使用 ______。

要求:填入空白处。

答案:ActivityAttributes

解读:包含静态属性和 ContentState。

题2

题目:启动活动使用 Activity.______。

要求:填入空白处。

答案:request

解读:Activity.request(attributes:content:pushType:)。

题3

题目:更新活动使用 activity.______。

要求:填入空白处。

答案:update

解读:await activity.update(content)。

题4

题目:结束活动使用 activity.______。

要求:填入空白处。

答案:end

解读:await activity.end(content, dismissalPolicy:)。

题5

题目:Info.plist 键是 ______。

要求:填入空白处。

答案:NSSupportsLiveActivities

解读:设为 YES。

改错题

题1

题目:

swift 复制代码
struct MyActivity: ActivityAttributes {
    let title: String
}

要求:找出缺少的部分。

答案:

swift 复制代码
struct MyActivity: ActivityAttributes {
    let title: String

    public struct ContentState: Codable, Hashable {
        var progress: Double
    }
}

解读:ActivityAttributes 必须包含 ContentState。

题2

题目:

swift 复制代码
let content = ActivityContent(state: state)

要求:找出潜在问题并改正。

答案:

swift 复制代码
let content = ActivityContent(
    state: state,
    staleDate: Date().addingTimeInterval(60 * 60)
)

解读:设置 staleDate 让系统知道内容何时过期,避免显示过期数据。

题3

题目:

swift 复制代码
ActivityConfiguration(for: MyActivity.self) { context in
    LockScreenView(context: context)
} dynamicIsland: { context in
    DynamicIsland {
        DynamicIslandExpandedRegion(.center) {
            Text("内容")
        }
    } compactLeading: {
        Text("L")
    } compactTrailing: {
        Text("R")
    } minimal: {
        Text("M")
    }
}

要求:找出缺少的部分。

答案:缺少展开区域的 .leading、.trailing、.bottom,但这不是错误;关键是确保紧凑和最小视图都有内容。代码本身正确。

解读:展开区域可以只用一个 .center,但建议补全以提供更好体验。

代码预测题

题目:

swift 复制代码
let state = ReadingActivityAttributes.ContentState(
    currentPage: 150,
    totalPages: 300,
    isPaused: false
)
let progress = state.progress

要求:progress 是多少?

答案:0.5。

解读:150 / 300 = 0.5。

编程题

题1

题目:定义一个"下载进度"的实时活动。

要求:静态属性为文件名,动态内容为已下载字节和总字节。

参考代码:

swift 复制代码
struct DownloadActivityAttributes: ActivityAttributes {
    let fileName: String

    public struct ContentState: Codable, Hashable {
        var downloadedBytes: Int64
        var totalBytes: Int64

        var progress: Double {
            guard totalBytes > 0 else { return 0 }
            return Double(downloadedBytes) / Double(totalBytes)
        }
    }
}

解读:静态属性在活动期间不变,动态内容随下载更新。

题2

题目:为下载活动写锁屏视图。

要求:显示文件名、进度条和百分比。

参考代码:

swift 复制代码
struct DownloadLockScreenView: View {
    let context: ActivityViewContext<DownloadActivityAttributes>

    var body: some View {
        VStack(spacing: 8) {
            HStack {
                Image(systemName: "arrow.down.circle.fill")
                    .foregroundStyle(.blue)
                Text(context.attributes.fileName)
                    .font(.headline)
                    .lineLimit(1)
                Spacer()
                Text("\(Int(context.state.progress * 100))%")
                    .bold()
                    .foregroundStyle(.blue)
            }

            ProgressView(value: context.state.progress)
                .tint(.blue)
        }
        .padding()
    }
}

解读:简洁清晰,锁屏上信息一目了然。

题3

题目:挑战------给灵动岛做三种紧凑形态。

要求:左侧图标,右侧百分比,最小视图只显示图标。

参考代码:

swift 复制代码
} compactLeading: {
    Image(systemName: "arrow.down.circle.fill")
        .foregroundStyle(.blue)
} compactTrailing: {
    Text("\(Int(context.state.progress * 100))%")
        .font(.caption2)
        .bold()
        .foregroundStyle(.blue)
} minimal: {
    Image(systemName: "arrow.down.circle.fill")
        .foregroundStyle(.blue)
}

解读:紧凑态信息要极简,最小态只留一个元素。

开放思考题

题1

题目:实时活动和普通通知有什么区别?

要求:简要回答。

参考:实时活动持续显示在锁屏和灵动岛,随状态更新;通知是一次性提醒。

解读:实时活动适合持续任务,如阅读、下载、运动。

题2

题目:为什么实时活动有 8 小时限制?

要求:简要回答。

参考:避免长期占用系统资源,保持锁屏和灵动岛整洁。

解读:适合有限时长的任务,不适合永久显示。

题3

题目:灵动岛三种紧凑形态为什么都要设计?

要求:简要回答。

参考:系统根据当前状态选择显示哪种,缺失会导致体验不完整。

解读:多个活动同时运行时,系统只显示最小形态。

十、全课知识点总结

· ActivityKit:实时活动框架,iOS 16.1+。

· ActivityAttributes:活动数据结构,包含静态属性和 ContentState。

· ContentState:动态内容,必须 Codable 和 Hashable。

· Activity.request:启动活动,pushType: .token 支持远程更新。

· ActivityContent:活动内容,包含状态和 staleDate。

· activity.update:更新活动状态。

· activity.end:结束活动,dismissalPolicy 控制移除时机。

· ActivityConfiguration:定义锁屏和灵动岛外观。

· 灵动岛三态:compactLeading、compactTrailing、minimal,加上展开区域的四个位置。

· 锁屏视图:完整信息展示,支持待机显示放大。

· 推送更新:APNs payload 的 content-state 必须与 ContentState 一致。

· Info.plist 需要 NSSupportsLiveActivities 为 YES。

· 最多 8 小时活跃,推送更新需真机测试。

本课达标标准:

能定义 ActivityAttributes 和 ContentState,能启动、更新和结束实时活动,能写锁屏视图和灵动岛四种形态,能获取推送 token 并理解 APNs payload,能给"我的书库"加上阅读进度实时活动。

相关推荐
欣欣之王来了2 小时前
Python入门:什么是Python以及为什么选择它
学习·架构·面向对象·项目·python教程
dadaobusi2 小时前
pcie原子操作和nvme原子操作
学习
sunshine22 girl2 小时前
Java学习五 面向对象高级5 内部类1
java·学习
每天题库5 小时前
架子工脚手架工题库:安全操作考点与模拟题练习指南
学习·安全·考试·题库·考证
yangmu32035 小时前
Codex 提示词优化教程:用四要素把模糊需求变成可执行任务
人工智能·学习
无敌贵点大王6 小时前
RTThread学习记录13——关于要使用一个外设,RTThread与cubemx到底要怎么配合?
c语言·stm32·学习·rtthread
phltxy6 小时前
C 语言动态内存管理:从申请空间到安全释放
c语言·开发语言·ui
可乐鸡翅yeah_7 小时前
M3U8 防盗链 URL‑Token 签名,新手开发实操避坑
开发语言·javascript·ios·音视频·safari
喜欢打篮球的普通人8 小时前
MiniMind 学习笔记(十二):Pretrain 实操——从版本梳理到 8GB 显卡上的真实训练
人工智能·笔记·学习