本课目标:
入门第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 小时活跃,之后系统回收。
· 动态岛适配:必须同时支持紧凑、最小、展开三种形态。
二、准备工作
- Info.plist 添加 NSSupportsLiveActivities 为 YES。
- Widget Extension 中创建实时活动。
- 主 App Target 引入 ActivityKit。
- 真机测试,模拟器部分支持但推送更新需要真机。
三、定义活动属性
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()
}
}
七、推送更新实时活动
- 获取推送 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 上传到你的服务器
}
}
}
- 服务器推送
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,能给"我的书库"加上阅读进度实时活动。