mcpField
A
axiom-extensions-widgets-ref

axiom-extensions-widgets-ref

github.com/charleswiltgen开发1.2k185 次安装今天更新
代码仓库

关于 axiom-extensions-widgets-ref

为 Apple 平台开发者提供 widgets 与 App Extensions 的 API 参考,涵盖配置、时间线、交互和 Live Activities 等主题。适用于实现和维护各类系统组件的 Swift 开发。

主要功能

  1. 01标准与可配置 widgets
  2. 02交互式 widgets 与 App Intents
  3. 03时间线和刷新策略
  4. 04Live Activities 与 Dynamic Island
  5. 05扩展共享数据与生命周期

适用场景

  1. 01实现主屏幕或锁定屏幕 widget
  2. 02创建实时活动
  3. 03在应用与扩展间共享数据

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/charleswiltgen/axiom --skill axiom-extensions-widgets-ref

安装后在 Claude Code 中输入/axiom-extensions-widgets-ref确认已加载。

详细介绍

扩展与小组件 API 参考

概述

本技能提供 Apple 小组件和扩展生态系统的全面 API 参考:

  • 标准小组件(iOS 14+)— 主屏幕、锁定屏幕、待机模式小组件
  • 交互式小组件(iOS 17+)— 使用 App Intents 的按钮和开关
  • 实时活动(iOS 16.1+)— 锁定屏幕和灵动岛上的实时更新
  • 控制中心小组件(iOS 18+)— 系统范围的快捷控制
  • Liquid Glass 小组件(iOS 26+)— 强调色渲染、玻璃效果、容器背景
  • visionOS 小组件(visionOS 2+)— 挂载样式、纹理、距离感知
  • App 扩展 — 共享数据、生命周期、权利

小组件是由系统在时间线上渲染的 SwiftUI 归档快照。扩展是与应用捆绑的沙盒可执行文件。

何时使用此技能

✅ 在以下情况下使用此技能:

  • 实现任何类型的小组件(主屏幕、锁定屏幕、待机模式)
  • 为持续进行的事件创建实时活动
  • 构建控制中心控件
  • 在应用和扩展之间共享数据
  • 了解小组件时间线和刷新策略
  • 将小组件与 App Intents 集成
  • 在小组件中采用 Liquid Glass 渲染
  • 支持 watchOS 或 visionOS 小组件
  • 实现 visionOS 挂载样式、纹理或距离感知

❌ 以下情况不要使用此技能:

  • 纯粹的 App Intents 问题(使用 app-intents-ref 技能)
  • SwiftUI 布局问题(使用 swiftui-layout 技能)
  • 性能优化(使用 swiftui-performance 技能)
  • 调试崩溃(使用 xcode-debugging 技能)

相关技能

  • app-intents-ref — 交互式小组件和配置的 App Intents
  • swift-concurrency — 用于加载小组件数据的 Async/await 模式
  • swiftui-performance — 优化小组件渲染
  • swiftui-layout — 复杂的小组件布局
  • extensions-widgets — 包含反模式和调试内容的专项技能

关键术语

  • 时间线(Timeline) — 定义何时显示何种内容的一系列条目;系统会在指定时间显示条目
  • TimelineProvider — 提供时间线条目的协议(占位符、快照、时间线生成)
  • TimelineEntry — 包含小组件数据和显示日期的结构体
  • 时间线预算(Timeline Budget) — 时间线重新加载的每日限额(40-70 次)
  • 预算豁免(Budget-Exempt) — 不计入预算的重新加载(由用户发起、应用进入前台、由系统发起)
  • 小组件系列(Widget Family) — 尺寸/形状(systemSmall、systemMedium、accessoryCircular 等)
  • App Groups — 用于在应用和扩展之间共享数据容器的权利
  • ActivityAttributes — 静态数据(设置一次)+ 动态 ContentState(在生命周期内更新)
  • ContentState — ActivityAttributes 中会变化的部分;总大小必须小于 4KB
  • 灵动岛(Dynamic Island) — iPhone 14 Pro+ 上的实时活动显示;具有紧凑、最小和展开尺寸
  • ControlWidget — iOS 18+ 中适用于控制中心、锁定屏幕和操作按钮的小组件
  • 辅助活动系列(Supplemental Activity Families) — 支持在 Apple Watch 或 CarPlay 上显示实时活动

第 1 部分:标准小组件(iOS 14+)

小组件配置类型

StaticConfiguration

适用于不需要用户配置的小组件。

@main
struct MyWidget: Widget {
    let kind: String = "MyWidget"

    var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: Provider()) { entry in
            MyWidgetEntryView(entry: entry)
        }
        .configurationDisplayName("My Widget")
        .description("This widget displays...")
        .supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
    }
}
AppIntentConfiguration(iOS 17+)

适用于使用 App Intents 配置的小组件。

struct MyConfigurableWidget: Widget {
    let kind: String = "MyConfigurableWidget"

    var body: some WidgetConfiguration {
        AppIntentConfiguration(
            kind: kind,
            intent: SelectProjectIntent.self,
            provider: Provider()
        ) { entry in
            MyWidgetEntryView(entry: entry)
        }
        .configurationDisplayName("Project Status")
        .description("Shows your selected project")
    }
}

从 IntentConfiguration 迁移:iOS 16 及更早版本使用 IntentConfiguration 和 SiriKit 意图。迁移到 AppIntentConfiguration 以支持 iOS 17+。

ActivityConfiguration

适用于实时活动(详见“实时活动”部分)。

选择合适的配置

无需用户配置?使用 StaticConfiguration。选项简单且固定?使用带有 WidgetConfigurationIntent 的 AppIntentConfiguration。选项来自应用数据且动态变化?使用 AppIntentConfiguration + EntityQuery。

快速参考:

  • StaticConfiguration — 无需自定义(天气、电池状态)
  • AppIntentConfiguration(简单)— 固定选项(计时器预设、主题选择)
  • AppIntentConfiguration(EntityQuery)— 来自应用数据的动态列表(项目/联系人/播放列表选择器)
  • ActivityConfiguration — 正在进行的实时事件(配送跟踪、锻炼进度、体育比分)

Widget 类型

系统类型(主屏幕)
  • systemSmall(约 170×170,iOS 14+)— 单条信息、图标
  • systemMedium(约 360×170,iOS 14+)— 多个数据点、图表
  • systemLarge(约 360×380,iOS 14+)— 详细视图、列表
  • systemExtraLarge(约 720×380,iOS 15+ 仅限 iPad)— 丰富布局、多个视图
配件类型(锁定屏幕,iOS 16+)
  • accessoryCircular(约 48×48pt)— 圆形复杂功能,图标或仪表
  • accessoryRectangular(约 160×72pt)— 时钟上方,文本 + 图标
  • accessoryInline(单行)— 日期上方,仅文本
示例:支持多种类型
struct MyWidget: Widget {
    var body: some WidgetConfiguration {
        StaticConfiguration(kind: "MyWidget", provider: Provider()) { entry in
            if #available(iOSApplicationExtension 16.0, *) {
                switch entry.family {
                case .systemSmall:
                    SmallWidgetView(entry: entry)
                case .systemMedium:
                    MediumWidgetView(entry: entry)
                case .accessoryCircular:
                    CircularWidgetView(entry: entry)
                case .accessoryRectangular:
                    RectangularWidgetView(entry: entry)
                default:
                    Text("Unsupported")
                }
            } else {
                LegacyWidgetView(entry: entry)
            }
        }
        .supportedFamilies([
            .systemSmall,
            .systemMedium,
            .accessoryCircular,
            .accessoryRectangular
        ])
    }
}

时间线系统

TimelineProvider 协议

提供用于定义系统何时应渲染 Widget 的条目。

struct Provider: TimelineProvider {
    // Placeholder while loading
    func placeholder(in context: Context) -> SimpleEntry {
        SimpleEntry(date: Date(), emoji: "😀")
    }

    // Shown in widget gallery
    func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) {
        let entry = SimpleEntry(date: Date(), emoji: "📷")
        completion(entry)
    }

    // Actual timeline
    func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
        var entries: [SimpleEntry] = []
        let currentDate = Date()

        // Create entry every hour for 5 hours
        for hourOffset in 0 ..< 5 {
            let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
            let entry = SimpleEntry(date: entryDate, emoji: "⏰")
            entries.append(entry)
        }

        let timeline = Timeline(entries: entries, policy: .atEnd)
        completion(timeline)
    }
}
TimelineReloadPolicy

控制系统何时请求新时间线:

  • .atEnd — 在最后一个条目之后重新加载
  • .after(date) — 在指定日期重新加载
  • .never — 不自动重新加载(仅手动)
手动重新加载
import WidgetKit

// Reload all widgets of this kind
WidgetCenter.shared.reloadAllTimelines()

// Reload specific kind
WidgetCenter.shared.reloadTimelines(ofKind: "MyWidget")

性能与预算速查

时间线刷新预算
  • 每日预算:每天 40-70 次重新加载(因系统负载和使用情况而异)
  • 不计入预算:用户发起的重新加载、应用进入前台、添加 Widget、系统重启
  • 策略性刷新(每小时 4 次)— 每天约 48 次重新加载,对电池影响较小
  • 频繁刷新(每小时 12 次)— 下午 6 点前耗尽预算,影响较大
  • 仅按需刷新 — 每天 5-10 次重新加载,影响极小
  • 在重要数据变化和基于时间的事件发生时重新加载。避免推测性或仅为美观而进行的重新加载。
// ✅ GOOD: Strategic intervals (15-60 min)
let entries = (0..<8).map { offset in
    let date = Calendar.current.date(byAdding: .minute, value: offset * 15, to: now)!
    return SimpleEntry(date: date, data: data)
}
内存限制
  • 标准 Widget 约 30MB,实时活动约 50MB — 超出时系统会终止进程
  • 只加载所需内容(例如,loadRecentItems(limit: 10),而不是整个数据库)
网络请求

绝不要在 Widget 视图中发起网络请求 — 请求无法在渲染前完成。请改为在 getTimeline() 中获取数据。

时间线生成

在 5 秒内完成 getTimeline()。在主应用中缓存开销较大的计算,从共享容器读取预计算数据,并将条目限制为 10-20 个。

视图渲染

在 TimelineEntry 中预先计算所有内容,让视图保持简单。不要在 body 中执行开销较大的操作。

图像
  • 使用资源目录中的图像或 SF Symbols(速度快)
  • 可以使用共享容器中的小图像
  • AsyncImage 在 Widget 中无法使用
  • 大图像会导致内存超限并被终止

第 2 部分:交互式 Widget(iOS 17+)

按钮和开关

交互式 Widget 使用 SwiftUI Button 和 Toggle,并配合 App Intents。

使用 App Intent 的按钮
Button(intent: IncrementIntent()) {
    Label("Increment", systemImage: "plus.circle")
}

该意图在其 perform() 方法中通过 App Groups 更新共享数据。完整的 AppIntent 定义语法请参阅 axiom-app-intents-ref。

使用 App Intent 的开关

模式与按钮相同 — 使用绑定到状态的 Toggle,并在状态变化时调用意图:

Toggle(isOn: $isEnabled) {
    Text("Feature")
}
.onChange(of: isEnabled) { newValue in
    Task { try? await ToggleFeatureIntent(enabled: newValue).perform() }
}

该意图遵循相同的 AppIntent 结构,并包含 @Parameter(title: "Enabled") var enabled: Bool。完整的 AppIntent 定义语法请参阅 axiom-app-intents-ref。

invalidatableContent 修饰符

在 App Intent 执行期间提供视觉反馈。

struct MyWidgetView: View {
    var entry: Provider.Entry

    var body: some View {
        VStack {
            Text(entry.status)
                .invalidatableContent() // Dims during intent execution

            Button(intent: RefreshIntent()) {
                Image(systemName: "arrow.clockwise")
            }
        }
    }
}

效果:关联的意图执行期间,带有 .invalidatableContent() 的内容会略微变透明,从而向用户提供反馈。

动画系统

数值文本的 contentTransition
Text("\(entry.value)")
    .contentTransition(.numericText(value: Double(entry.value)))

效果:数字会平滑地递增或递减,而不是瞬间变化。

视图转场
VStack {
    if entry.showDetail {
        DetailView()
            .transition(.scale.combined(with: .opacity))
    }
}
.animation(.spring(response: 0.3), value: entry.showDetail)

第 3 部分:可配置小组件(iOS 17+)

WidgetConfigurationIntent

为小组件定义配置参数。

import AppIntents

struct SelectProjectIntent: WidgetConfigurationIntent {
    static var title: LocalizedStringResource = "Select Project"
    static var description = IntentDescription("Choose which project to display")

    @Parameter(title: "Project")
    var project: ProjectEntity?

    // Provide default value
    static var parameterSummary: some ParameterSummary {
        Summary("Show \(\.$project)")
    }
}

Entity 和 EntityQuery

为配置提供动态选项。

struct ProjectEntity: AppEntity {
    var id: String
    var name: String

    static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Project")

    var displayRepresentation: DisplayRepresentation {
        DisplayRepresentation(title: "\(name)")
    }
}

struct ProjectQuery: EntityQuery {
    func entities(for identifiers: [String]) async throws -> [ProjectEntity] {
        // Return projects matching these IDs
        return await ProjectStore.shared.projects(withIDs: identifiers)
    }

    func suggestedEntities() async throws -> [ProjectEntity] {
        // Return all available projects
        return await ProjectStore.shared.allProjects()
    }
}

在 Provider 中使用配置

struct Provider: AppIntentTimelineProvider {
    func timeline(for configuration: SelectProjectIntent, in context: Context) async -> Timeline<SimpleEntry> {
        let project = configuration.project // Use selected project
        let entries = await generateEntries(for: project)
        return Timeline(entries: entries, policy: .atEnd)
    }
}

第 4 部分:实时活动(iOS 16.1+)

ActivityAttributes

定义实时活动的静态和动态数据。

import ActivityKit

struct PizzaDeliveryAttributes: ActivityAttributes {
    // Static data - set when activity starts, never changes
    struct ContentState: Codable, Hashable {
        // Dynamic data - updated throughout activity lifecycle
        var status: DeliveryStatus
        var estimatedDeliveryTime: Date
        var driverName: String?
    }

    // Static attributes
    var orderNumber: String
    var pizzaType: String
}

关键限制:要成功启动,ActivityAttributes 的总数据大小必须小于 4KB。

启动活动

请求授权
import ActivityKit

let authorizationInfo = ActivityAuthorizationInfo()
let areActivitiesEnabled = authorizationInfo.areActivitiesEnabled
启动活动
let attributes = PizzaDeliveryAttributes(
    orderNumber: "12345",
    pizzaType: "Pepperoni"
)

let initialState = PizzaDeliveryAttributes.ContentState(
    status: .preparing,
    estimatedDeliveryTime: Date().addingTimeInterval(30 * 60)
)

let activity = try Activity.request(
    attributes: attributes,
    content: ActivityContent(state: initialState, staleDate: nil),
    pushType: nil // or .token for push notifications
)

错误处理

常见活动错误

请求前务必检查 ActivityAuthorizationInfo().areActivitiesEnabled。处理 Activity.request() 的以下错误:

  • ActivityAuthorizationError — 用户拒绝了实时活动权限
  • ActivityError.dataTooLarge — ActivityAttributes 超过 4KB;请缩减属性大小
  • ActivityError.tooManyActivities — 已达到系统限制(通常为同时运行 2-3 个)

请求成功后,保存 activity.id,以便后续更新。

更新活动

使用新内容更新
// Find active activity by stored ID
guard let activity = Activity<PizzaDeliveryAttributes>.activities
    .first(where: { $0.id == storedActivityID }) else { return }

let updatedState = PizzaDeliveryAttributes.ContentState(
    status: .onTheWay,
    estimatedDeliveryTime: Date().addingTimeInterval(10 * 60),
    driverName: "John"
)

await activity.update(
    ActivityContent(
        state: updatedState,
        staleDate: Date().addingTimeInterval(60) // Mark stale after 1 min
    )
)
提醒配置
await activity.update(updatedContent, alertConfiguration: AlertConfiguration(
    title: "Pizza is here!",
    body: "Your \(attributes.pizzaType) pizza has arrived",
    sound: .default
))
监控活动生命周期

使用 activity.activityStateUpdates 异步序列观察状态变化(.active、.ended、.dismissed、.stale)。在 .ended 或 .dismissed 时清理已保存的活动 ID。在 deinit 中取消监控任务。

结束活动

消除策略
await activity.end(
    ActivityContent(state: finalState, staleDate: nil),
    dismissalPolicy: .default
)

消除策略选项:

  • .immediate — 立即移除
  • .default — 在锁定屏幕上保留约 4 小时
  • .after(date) — 在指定时间移除(例如,.after(Date().addingTimeInterval(3600)))

实时活动的推送通知

请求推送令牌
let activity = try Activity.request(
    attributes: attributes,
    content: initialContent,
    pushType: .token // Request push token
)

// Monitor for push token
for await pushToken in activity.pushTokenUpdates {
    let tokenString = pushToken.map { String(format: "%02x", $0) }.joined()
    // Send to your server
    await sendTokenToServer(tokenString, activityID: activity.id)
}
频繁推送更新(iOS 18.2+)

标准限制为每小时约 10-12 次推送。对于实时事件(体育赛事、股票),添加 com.apple.developer.activity-push-notification-frequent-updates 权限,以大幅提高限制。


第 5 部分:灵动岛(iOS 16.1+)

展示类型

实时活动会以三种尺寸显示在灵动岛中:

紧凑(左侧 + 右侧)

当另一个实时活动展开,或有多个活动处于活跃状态时显示。

DynamicIsland {
    DynamicIslandExpandedRegion(.leading) {
        Image(systemName: "timer")
    }
    DynamicIslandExpandedRegion(.trailing) {
        Text("\(entry.timeRemaining)")
    }
    // ...
} compactLeading: {
    Image(systemName: "timer")
} compactTrailing: {
    Text("\(entry.timeRemaining)")
        .frame(width: 40)
}
最小

当有两个以上实时活动处于活跃状态时显示(圆形头像)。

DynamicIsland {
    // ...
} minimal: {
    Image(systemName: "timer")
        .foregroundStyle(.tint)
}
展开

用户长按紧凑视图时显示。

DynamicIsland {
    DynamicIslandExpandedRegion(.leading) {
        Image(systemName: "timer")
            .font(.title)
    }

    DynamicIslandExpandedRegion(.trailing) {
        VStack(alignment: .trailing) {
            Text("\(entry.timeRemaining)")
                .font(.title2.monospacedDigit())
            Text("remaining")
                .font(.caption)
        }
    }

    DynamicIslandExpandedRegion(.center) {
        // Optional center content
    }

    DynamicIslandExpandedRegion(.bottom) {
        HStack {
            Button(intent: PauseIntent()) {
                Label("Pause", systemImage: "pause.fill")
            }
            Button(intent: StopIntent()) {
                Label("Stop", systemImage: "stop.fill")
            }
        }
    }
}

设计原则(来自 WWDC 2023-10194)

同心对齐

内容应以均匀的边距同心嵌套在灵动岛的圆角形状内。使用 Circle() 或 RoundedRectangle(cornerRadius:),不要使用尖角的 Rectangle(),否则会凸入角落。

仿生动效

灵动岛动画应显得自然且富有弹性。使用 .spring(response: 0.6, dampingFraction: 0.7) 或 .interpolatingSpring(stiffness: 300, damping: 25),而非线性动画。


第 6 部分:控制中心小组件(iOS 18+)

ControlWidget 协议

控件会显示在控制中心、锁定屏幕和操作按钮中(iPhone 15 Pro+)。

StaticControlConfiguration

用于无需配置的简单控件。

import WidgetKit
import AppIntents

struct TorchControl: ControlWidget {
    var body: some ControlWidgetConfiguration {
        StaticControlConfiguration(kind: "TorchControl") {
            ControlWidgetButton(action: ToggleTorchIntent()) {
                Label("Flashlight", systemImage: "flashlight.on.fill")
            }
        }
        .displayName("Flashlight")
        .description("Toggle flashlight")
    }
}
AppIntentControlConfiguration

用于可配置的控件。

struct TimerControl: ControlWidget {
    var body: some ControlWidgetConfiguration {
        AppIntentControlConfiguration(
            kind: "TimerControl",
            intent: ConfigureTimerIntent.self
        ) { configuration in
            ControlWidgetButton(action: StartTimerIntent(duration: configuration.duration)) {
                Label("\(configuration.duration)m Timer", systemImage: "timer")
            }
        }
    }
}

ControlWidgetButton

用于离散操作(一次性操作)。

ControlWidgetButton(action: PlayMusicIntent()) {
    Label("Play", systemImage: "play.fill")
}
.tint(.purple)

ControlWidgetToggle

用于布尔状态。

struct AirplaneModeControl: ControlWidget {
    var body: some ControlWidgetConfiguration {
        StaticControlConfiguration(kind: "AirplaneModeControl") {
            ControlWidgetToggle(
                isOn: AirplaneModeIntent.isEnabled,
                action: AirplaneModeIntent()
            ) { isOn in
                Label(isOn ? "On" : "Off", systemImage: "airplane")
            }
        }
    }
}

值提供器(异步状态)

对于需要异步状态的控件,请将 ControlValueProvider 传给 StaticControlConfiguration:

struct ThermostatProvider: ControlValueProvider {
    func currentValue() async throws -> ThermostatValue {
        let temp = try await HomeManager.shared.currentTemperature()
        return ThermostatValue(temperature: temp)
    }
    var previewValue: ThermostatValue { ThermostatValue(temperature: 72) }
}

提供器的值会传入控件的闭包:{ value in ControlWidgetButton(...) }。

可配置控件

使用 AppIntentControlConfiguration 和 WidgetConfigurationIntent(与可配置 Widget 使用相同模式)。添加 .promptsForUserConfiguration(),以便用户添加控件时显示配置界面。

控件调整

  • .controlWidgetActionHint("Toggles flashlight") — VoiceOver 辅助功能提示
  • .displayName("My Control") / .description("...") — 在控制中心界面中显示

第 7 部分:iOS 18+ 更新

强调色渲染与 Liquid Glass

Widget 渲染模式横跨多个 iOS 版本:widgetAccentable()(iOS 16+)、WidgetAccentedRenderingMode(iOS 18+),以及 glassEffect() 和 GlassEffectContainer 等 Liquid Glass 效果(iOS 26+)。检测当前模式并相应调整布局。

检测渲染模式
struct MyWidgetView: View {
    @Environment(\.widgetRenderingMode) var renderingMode

    var body: some View {
        if renderingMode == .accented {
            // Simplified layout — opaque images tinted white, background replaced with glass
        } else {
            // Standard full-color layout
        }
    }
}
widgetAccentable(_:)

将视图标记为强调色组的一部分。在强调色模式下,强调色组视图会与主色组视图分别着色,从而形成视觉层次。

HStack {
    VStack(alignment: .leading) {
        Text("Title")
            .font(.headline)
            .widgetAccentable()  // Accent group — tinted in accented mode
        Text("Subtitle")
            // Primary group by default
    }
    Image(systemName: "star.fill")
        .widgetAccentable()  // Also accent group
}
WidgetAccentedRenderingMode

控制图像在强调色模式下的渲染方式。应用于 Image 视图:

Image("myPhoto")
    .widgetAccentedRenderingMode(.accented)      // Tinted with accent color
Image("myIcon")
    .widgetAccentedRenderingMode(.monochrome)     // Rendered as monochrome
Image("myBadge")
    .widgetAccentedRenderingMode(.fullColor)       // Keeps original colors (opt-out)

最佳实践:仅在 .fullColor 渲染模式下显示全彩图像。策略性地使用 .widgetAccentable() 来创建视觉层次。使用多种强调色和背景图像进行测试。

容器背景
VStack { /* content */ }
    .containerBackground(for: .widget) {
        Color.blue.opacity(0.2)
    }

在强调色模式下,系统会移除背景并将其替换为主题玻璃效果。若要防止背景被移除(这会使 Widget 无法显示在 iPad 锁定屏幕和待机模式中):

.containerBackgroundRemovable(false)
自定义 Widget 元素中的 Liquid Glass
Text("Label")
    .padding()
    .glassEffect()  // Default capsule shape

Image(systemName: "star.fill")
    .frame(width: 60, height: 60)
    .glassEffect(.regular, in: .rect(cornerRadius: 12))

Button("Action") { }
    .buttonStyle(.glass)

使用 GlassEffectContainer 组合多个玻璃效果元素:

GlassEffectContainer(spacing: 20.0) {
    HStack(spacing: 20.0) {
        Image(systemName: "cloud")
            .frame(width: 60, height: 60)
            .glassEffect()
        Image(systemName: "sun")
            .frame(width: 60, height: 60)
            .glassEffect()
    }
}

跨平台支持

visionOS Widget(visionOS 2+)

visionOS Widget 是放置在实体空间中的 3D 对象,可安装在表面上或悬浮显示。它们支持独特的空间功能。

安装样式

Widget 可以是凸起式(位于表面上方)或嵌入式(嵌入墙壁等垂直表面):

.supportedMountingStyles([.elevated, .recessed])  // Default is both
// .supportedMountingStyles([.recessed])           // Wall-only widget

如果仅限于 .recessed,用户就无法将 Widget 放置在水平表面上。

Widget 纹理

两种用于空间外观的视觉纹理:

.widgetTexture(.glass)   // Default — transparent glass-like appearance
.widgetTexture(.paper)   // Poster-like look, effective with extra-large sizes
距离感知(levelOfDetail)

Widget 会根据用户距离自动调整。系统会为不同细节级别之间的切换添加动画:

@Environment(\.levelOfDetail) var levelOfDetail

var body: some View {
    VStack {
        Text(entry.value)
            .font(levelOfDetail == .simplified ? .largeTitle : .title)
    }
}

取值:.default(近距离观看)和 .simplified(远距离观看——使用更大的文字和更少的细节)。

visionOS Widget 样式

visionOS 支持所有系统样式以及超大尺寸:

.supportedFamilies([
    .systemSmall, .systemMedium, .systemLarge,
    .systemExtraLarge,
    .systemExtraLargePortrait  // visionOS-specific portrait orientation
])

超大样式搭配 .widgetTexture(.paper) 尤其适合海报式展示。

背景检测

检测 Widget 背景是否可见(在强调色模式下会被移除):

@Environment(\.showsWidgetContainerBackground) var showsBackground
CarPlay(iOS 18+)

将 .supplementalActivityFamilies([.medium]) 添加到 ActivityConfiguration。使用待机模式风格的全宽仪表板呈现方式。

macOS 菜单栏

配对 iPhone 上的实时活动会自动显示在 macOS Sequoia+ 菜单栏中。无需更改代码。

watchOS 控件(11+)

ControlWidget 在 watchOS 上的工作方式相同,可用于控制中心、操作按钮和智能叠放。使用与 iOS 相同的 StaticControlConfiguration / ControlWidgetButton 模式。

相关性 Widget(iOS 18+)

使用 .relevanceConfiguration(for:score:attributes:) 帮助系统在智能叠放中优先显示 Widget。属性包括 .location(CLLocation)、.timeOfDay(DateInterval) 和 .activity(String),用于根据上下文进行排序。

推送通知更新(iOS 18+)

实现 PKPushRegistryDelegate 并处理 .widgetKit 推送类型,以接收从服务器推送到 Widget 的更新。更新共享容器中的数据,并调用 WidgetCenter.shared.reloadAllTimelines()。发送到 iPhone 的推送会自动同步到 Apple Watch 和 CarPlay。


第 8 部分:App Groups 与数据共享

App Groups 权限

在 App 与扩展之间共享数据时必需。

配置
  1. Xcode:Targets → Signing & Capabilities → Add "App Groups"
  2. 标识符格式:group.com.company.appname
  3. 为主应用目标和扩展目标都启用

共享容器

访问共享容器
let sharedContainer = FileManager.default.containerURL(
    forSecurityApplicationGroupIdentifier: "group.com.mycompany.myapp"
)!

let dataFileURL = sharedContainer.appendingPathComponent("widgetData.json")
使用 App Groups 的 UserDefaults
// Main app - write data
let shared = UserDefaults(suiteName: "group.com.mycompany.myapp")!
shared.set("Updated value", forKey: "myKey")

// Widget extension - read data
let shared = UserDefaults(suiteName: "group.com.mycompany.myapp")!
let value = shared.string(forKey: "myKey")
使用 App Groups 的 Core Data

将 NSPersistentStoreDescription 指向共享容器 URL:

let sharedStoreURL = FileManager.default.containerURL(
    forSecurityApplicationGroupIdentifier: "group.com.mycompany.myapp"
)!.appendingPathComponent("MyApp.sqlite")

let description = NSPersistentStoreDescription(url: sharedStoreURL)
container.persistentStoreDescriptions = [description]

IPC 通信

  • 后台 URL Session — 将 config.sharedContainerIdentifier 设置为你的 App Group ID,以便扩展访问下载内容
  • Darwin 通知中心 — 使用 CFNotificationCenterPostNotification / CFNotificationCenterAddObserver 和 CFNotificationCenterGetDarwinNotifyCenter() 发送简单的跨进程信号(例如,通知小组件调用 WidgetCenter.shared.reloadAllTimelines())

第 9 部分:watchOS 集成

supplementalActivityFamilies(watchOS 11+)

将 .supplementalActivityFamilies([.small]) 添加到 ActivityConfiguration,即可在 Apple Watch Smart Stack 中显示 Live Activities(CarPlay 使用相同修饰符,值为 .medium)。

activityFamily 环境值

使用 @Environment(\.activityFamily) 调整布局 — 检查是 .small(watchOS)还是 iPhone 布局。

全天候显示屏

使用 @Environment(\.isLuminanceReduced) 简化全天候显示屏上的视图 — 减少细节、使用白色文字和更大的字体。结合 @Environment(\.colorScheme),以正确处理深色模式。

更新预算(watchOS)

watchOS 更新会通过推送通知自动与 iPhone 同步。如果手表超出蓝牙范围,更新可能会延迟。


第 10 部分:实用工作流

构建你的第一个小组件

如需包含可运行代码示例的完整分步教程,请参阅 Apple 的示例项目 使用 WidgetKit 和 SwiftUI 构建小组件。

关键步骤:添加小组件扩展目标、配置 App Groups、实现 TimelineProvider、设计 SwiftUI 视图,并从主应用更新。生产环境要求请参阅下方的专家审核清单。


专家审核清单

发布小组件前

架构:

  • 在应用和扩展中配置 App Groups 权限
  • 两个目标中的组标识符完全一致
  • 所有数据共享都使用共享容器
  • 小组件代码中不使用 UserDefaults.standard

性能:

  • 时间线生成在 5 秒内完成
  • 小组件视图中没有网络请求
  • 时间线的刷新间隔合理(≥ 15 分钟)
  • 条目数量合理(< 20-30 个)
  • 内存用量低于限制(小组件约 30MB,实时活动约 50MB)
  • 图像经过优化(优先使用资源目录或 SF Symbols)

数据与状态:

  • 小组件能妥善处理缺失或 nil 数据
  • 条目日期按时间顺序排列
  • 占位视图外观合理
  • 快照视图能代表实际使用情况

用户体验:

  • 小组件显示在小组件图库中
  • configurationDisplayName 清晰简洁
  • description 说明小组件的用途
  • 已测试所有支持的尺寸,显示效果正确
  • 文本在浅色和深色背景下都清晰易读
  • 交互元素(按钮/开关)正常工作

实时活动(如适用):

  • ActivityAttributes 不超过 4KB
  • 启动前检查授权状态
  • 事件完成时结束活动
  • 设置适当的关闭策略
  • 如适用,配置 watchOS 支持(supplementalActivityFamilies)
  • 已测试灵动岛布局(紧凑、最小化、展开)

Liquid Glass(如适用):

  • 应用 widgetAccentable(),确保强调模式下的视觉层级
  • 为图像设置 WidgetAccentedRenderingMode(.accented、.monochrome 或 .fullColor)
  • 已使用多种强调色和背景图像进行测试
  • 使用 .containerBackground(for: .widget) 配置容器背景

visionOS(如适用):

  • 配置安装样式(.elevated、.recessed 或两者)
  • 选择小组件纹理(.glass 或 .paper)
  • 为感知距离的布局处理 levelOfDetail
  • 视情况支持超大尺寸(.systemExtraLarge、.systemExtraLargePortrait)
  • 已在不同距离下测试距离感知过渡效果

控制中心小组件(如适用):

  • ControlValueProvider 异步且响应迅速(< 1 秒)
  • previewValue 提供合理的备用值
  • 已设置 displayName 和 description
  • 已在控制中心、锁定屏幕和操作按钮中进行测试

测试:

  • 已在真实设备上测试(而不仅是模拟器)
  • 已测试添加和移除小组件
  • 已测试应用数据变更后小组件是否更新
  • 已测试强制退出应用后小组件是否仍能正常工作
  • 已测试低内存场景
  • 已测试所有支持的 iOS 版本
  • 已测试无网络连接时的情况

测试指南

单元测试模式

测试 placeholder()、getSnapshot() 和 getTimeline() 方法。将测试数据保存到共享容器,使用模拟上下文调用 getTimeline(),并断言条目不为空且包含预期数据。异步生成时间线时,使用 waitForExpectations(timeout: 5.0)。

手动测试清单
  • 将小组件添加到主屏幕,检查小组件图库、所有支持的尺寸,以及数据是否与应用一致
  • 在主应用中更改数据,观察小组件是否更新;强制退出应用并重启设备
  • 删除所有应用数据(检查能否妥善处理)、禁用网络(离线)、开启低电量模式、测试多个实例
  • 在 Xcode Debug Navigator 中监控内存,在 Console 中检查时间线生成时间,并在较旧设备上测试
调试提示
  • 在 getTimeline() 中添加 print() 日志,确认该方法是否被调用以及数据是否已加载
  • 验证 App Groups:分别在应用和小组件中输出 FileManager.default.containerURL(forSecurityApplicationGroupIdentifier:);路径必须一致
  • 在主应用中更改数据后,调用 WidgetCenter.shared.reloadAllTimelines()

第 11 部分:故障排查

小组件未显示在图库中:检查 WidgetBundle 是否包含该小组件,确认 supportedFamilies(),检查扩展的“Skip Install”是否设为 NO,并确认部署目标与应用一致。

小组件未刷新

症状:小组件显示的是过时数据,且不会更新

诊断步骤:

  1. 检查时间线策略(.atEnd、.after() 或 .never)
  2. 确认未超出每日预算(40-70 次重新加载)
  3. 检查是否调用了 getTimeline()(添加日志)
  4. 确保已正确配置用于共享数据的 App Groups

解决方案:

// Manual reload from main app when data changes
import WidgetKit

WidgetCenter.shared.reloadAllTimelines()
// or
WidgetCenter.shared.reloadTimelines(ofKind: "MyWidget")

应用与小组件之间的数据未共享

症状:小组件显示默认数据或空数据

诊断步骤:

  1. 确认两个目标都启用了 App Groups 权限
  2. 检查组标识符是否完全一致
  3. 确保两个目标使用相同的 suiteName
  4. 如果使用共享容器,请检查文件路径

解决方案:

// Both app AND extension must use:
let shared = UserDefaults(suiteName: "group.com.mycompany.myapp")!

// NOT:
let shared = UserDefaults.standard  // ❌ Different containers

Live Activity 无法启动

症状:Activity.request() 抛出错误

常见错误:

“Activity size exceeds 4KB”:

// ❌ BAD: Large images in attributes
struct MyAttributes: ActivityAttributes {
    var productImage: UIImage  // Too large!
}

// ✅ GOOD: Use asset catalog names
struct MyAttributes: ActivityAttributes {
    var productImageName: String  // Reference to asset
}

“Activities not enabled”:

// Check authorization first
let authInfo = ActivityAuthorizationInfo()
guard authInfo.areActivitiesEnabled else {
    throw ActivityError.notEnabled
}

交互式小组件按钮不起作用

症状:点击按钮没有反应

诊断步骤:

  1. 确认 App Intent 的 perform() 返回 IntentResult
  2. 检查小组件目标中是否导入了该 intent
  3. 确保按钮使用 intent: 参数,而不是 action:
  4. 检查 Console 中是否有 intent 执行错误

解决方案:

// ✅ CORRECT: Use intent parameter
Button(intent: MyIntent()) {
    Label("Action", systemImage: "star")
}

// ❌ WRONG: Don't use action closure
Button(action: { /* This won't work in widgets */ }) {
    Label("Action", systemImage: "star")
}

控制中心小组件响应缓慢:在 ControlValueProvider.currentValue() 中使用异步操作,切勿使用 Thread.sleep 阻塞。提供快速的 previewValue 回退值。

小组件显示的尺寸不正确:在视图中根据 @Environment(\.widgetFamily) 进行切换,针对不同的 family 调整布局,避免使用硬编码尺寸。

时间线条目顺序错误:确保条目日期按时间顺序排列。使用从 Date() 开始递增的偏移量。

watchOS Live Activity 未显示:在 ActivityConfiguration 中添加 .supplementalActivityFamilies([.small]),确认使用 watchOS 11+,并检查蓝牙连接和配对状态。

性能问题

症状:小组件渲染缓慢、耗电量增加

常见原因:

  • 时间线条目过多(> 100)
  • 在视图代码中发起网络请求
  • 在 getTimeline() 中执行繁重计算
  • 刷新间隔过短(< 15 min)

解决方案:

// ✅ GOOD: Strategic intervals
let entries = (0..<8).map { offset in
    let date = Calendar.current.date(byAdding: .minute, value: offset * 15, to: now)!
    return SimpleEntry(date: date, data: precomputedData)
}

// ❌ BAD: Too frequent, too many entries
let entries = (0..<100).map { offset in
    let date = Calendar.current.date(byAdding: .minute, value: offset, to: now)!
    return SimpleEntry(date: date, data: fetchFromNetwork())  // Network in timeline
}

调试小组件

模拟器与设备
  • 模拟器:小组件会立即刷新,不受预算限制。适合测试布局,但无法准确反映刷新行为。
  • 设备:受预算限制(每天 40-70 次重新加载)。发布前请在设备上测试,以确认实际刷新时机。
  • Xcode 预览:可用于测试布局,但不会调用 getTimeline()。请通过单元测试或在设备上运行来测试时间线逻辑。
常见调试流程
  1. 在 getTimeline() 中添加 print(),确认其被调用且数据已加载
  2. 在 Console.app 中按小组件扩展进程名称筛选
  3. 使用 WidgetCenter.shared.getCurrentConfigurations() 确认注册状态
  4. 如果应用更新后小组件仍显示旧数据,请确认 App Groups 容器路径一致
数据共享模式

小组件中的 SwiftData(iOS 17+):

  • 在小组件中创建 ModelContainer,并使用与主应用相同的架构
  • 使用共享的 App Groups 容器:ModelConfiguration(url: containerURL)
  • 小组件只读取数据,不要从小组件写入,以避免冲突
  • 主应用写入后调用 WidgetCenter.shared.reloadAllTimelines()

小组件中的 GRDB/SQLite:

  • 通过 App Groups 容器共享数据库文件
  • 并发读取时使用 DatabasePool(不要使用 DatabaseQueue)
  • 小组件以只读方式打开连接:try DatabasePool(path: dbPath, configuration: readOnlyConfig)
  • 在小组件中设置 configuration.readonly = true,以避免意外写入

资源

WWDC:2025-278、2024-10157、2024-10068、2024-10098、2023-10028、2023-10194、2022-10184、2022-10185

文档:/widgetkit、/activitykit、/appintents

技能:axiom-app-intents-ref、axiom-swift-concurrency、axiom-swiftui-performance、axiom-swiftui-layout、axiom-extensions-widgets


版本:0.9 | 平台:iOS 14+、iPadOS 14+、watchOS 9+、macOS 11+、visionOS 2+

评论

支持 Markdown