axiom-extensions-widgets-ref by charleswiltgen/axiom
npx skills add https://github.com/charleswiltgen/axiom --skill axiom-extensions-widgets-ref此技能提供 Apple 小组件和扩展生态系统的全面 API 参考:
小组件是由系统在时间线上渲染的 SwiftUI 归档快照。扩展是与您的应用捆绑在一起的沙盒化可执行文件。
✅ 在以下情况下使用此技能:
❌ 请勿将此技能用于:
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
适用于不需要用户配置的小组件。
@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])
}
}
适用于使用 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 及更早版本使用带有 SiriKit 意图的 IntentConfiguration。对于 iOS 17+,请迁移到 AppIntentConfiguration。
用于实时活动(在实时活动部分介绍)。
不需要用户配置?使用 StaticConfiguration。简单的静态选项?使用带有 WidgetConfigurationIntent 的 AppIntentConfiguration。来自应用数据的动态选项?使用 AppIntentConfiguration + EntityQuery。
快速参考:
systemSmall (~170×170, iOS 14+) — 单一信息、图标systemMedium (~360×170, iOS 14+) — 多个数据点、图表systemLarge (~360×380, iOS 14+) — 详细视图、列表systemExtraLarge (~720×380, iOS 15+ 仅限 iPad) — 丰富的布局、多个视图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
])
}
}
提供定义系统何时应渲染您的小组件的条目。
struct Provider: TimelineProvider {
// 加载时的占位符
func placeholder(in context: Context) -> SimpleEntry {
SimpleEntry(date: Date(), emoji: "😀")
}
// 在小组件图库中显示
func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) {
let entry = SimpleEntry(date: Date(), emoji: "📷")
completion(entry)
}
// 实际时间线
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
var entries: [SimpleEntry] = []
let currentDate = Date()
// 每小时创建一个条目,持续 5 小时
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)
}
}
控制系统何时请求新的时间线:
.atEnd — 在最后一个条目后重新加载.after(date) — 在特定日期重新加载.never — 无自动重新加载(仅手动)import WidgetKit
// 重新加载此类型的所有小组件
WidgetCenter.shared.reloadAllTimelines()
// 重新加载特定类型
WidgetCenter.shared.reloadTimelines(ofKind: "MyWidget")
// ✅ 良好:策略性间隔(15-60 分钟)
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)
}
loadRecentItems(limit: 10),而非整个数据库)切勿在小组件视图中进行网络请求 — 它们在渲染前不会完成。应在 getTimeline() 中获取数据。
在 5 秒内完成 getTimeline()。在主应用中缓存昂贵的计算,从共享容器读取预计算的数据,限制在 10-20 个条目。
在 TimelineEntry 中预计算所有内容,保持视图简单。不要在 body 中进行昂贵的操作。
AsyncImage 在小组件中不起作用交互式小组件使用带有 App Intents 的 SwiftUI Button 和 Toggle。
Button(intent: IncrementIntent()) {
Label("Increment", systemImage: "plus.circle")
}
该意图在其 perform() 方法中通过 App Groups 更新共享数据。有关完整的 AppIntent 定义语法,请参见 axiom-app-intents-ref。
与按钮模式相同 — 使用绑定到状态的 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。
在 App Intent 执行期间提供视觉反馈。
struct MyWidgetView: View {
var entry: Provider.Entry
var body: some View {
VStack {
Text(entry.status)
.invalidatableContent() // 在意图执行期间变暗
Button(intent: RefreshIntent()) {
Image(systemName: "arrow.clockwise")
}
}
}
}
效果:带有 .invalidatableContent() 的内容在关联的意图执行期间会变得略微透明,为用户提供反馈。
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)
为小组件定义配置参数。
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?
// 提供默认值
static var parameterSummary: some ParameterSummary {
Summary("Show \(\.$project)")
}
}
为配置提供动态选项。
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] {
// 返回与这些 ID 匹配的项目
return await ProjectStore.shared.projects(withIDs: identifiers)
}
func suggestedEntities() async throws -> [ProjectEntity] {
// 返回所有可用项目
return await ProjectStore.shared.allProjects()
}
}
struct Provider: AppIntentTimelineProvider {
func timeline(for configuration: SelectProjectIntent, in context: Context) async -> Timeline<SimpleEntry> {
let project = configuration.project // 使用选定的项目
let entries = await generateEntries(for: project)
return Timeline(entries: entries, policy: .atEnd)
}
}
定义实时活动的静态和动态数据。
import ActivityKit
struct PizzaDeliveryAttributes: ActivityAttributes {
// 静态数据 - 活动开始时设置,永不更改
struct ContentState: Codable, Hashable {
// 动态数据 - 在整个活动生命周期内更新
var status: DeliveryStatus
var estimatedDeliveryTime: Date
var driverName: String?
}
// 静态属性
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 // 或 .token 用于推送通知
)
在请求之前始终检查 ActivityAuthorizationInfo().areActivitiesEnabled。处理来自 Activity.request() 的这些错误:
ActivityAuthorizationError — 用户拒绝了实时活动权限ActivityError.dataTooLarge — ActivityAttributes 超过 4KB;减少属性大小ActivityError.tooManyActivities — 达到系统限制(通常为 2-3 个同时活动)在成功请求后存储 activity.id 以供后续更新使用。
// 通过存储的 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) // 1 分钟后标记为陈旧
)
)
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 // 请求推送令牌
)
// 监控推送令牌
for await pushToken in activity.pushTokenUpdates {
let tokenString = pushToken.map { String(format: "%02x", $0) }.joined()
// 发送到您的服务器
await sendTokenToServer(tokenString, activityID: activity.id)
}
标准限制约为 10-12 次推送/小时。对于实时事件(体育、股票),添加 com.apple.developer.activity-push-notification-frequent-updates 权限以获得显著更高的限制。
实时活动以三种尺寸类别出现在灵动岛中:
当另一个实时活动展开或多个活动处于活动状态时显示。
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) {
// 可选的居中内容
}
DynamicIslandExpandedRegion(.bottom) {
HStack {
Button(intent: PauseIntent()) {
Label("Pause", systemImage: "pause.fill")
}
Button(intent: StopIntent()) {
Label("Stop", systemImage: "stop.fill")
}
}
}
}
内容应嵌套在灵动岛的圆角形状内,并具有均匀的边距。使用 Circle() 或 RoundedRectangle(cornerRadius:) — 切勿使用尖锐的 Rectangle(),它会戳入角落。
灵动岛动画应感觉有机且有弹性。使用 .spring(response: 0.6, dampingFraction: 0.7) 或 .interpolatingSpring(stiffness: 300, damping: 25) 代替线性动画。
控件出现在控制中心、锁定屏幕和操作按钮 (iPhone 15 Pro+) 中。
适用于没有配置的简单控件。
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")
}
}
适用于可配置的控件。
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(action: PlayMusicIntent()) {
Label("Play", systemImage: "play.fill")
}
.tint(.purple)
适用于布尔状态。
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(...) }。
使用带有 WidgetConfigurationIntent 的 AppIntentControlConfiguration(与可配置小组件模式相同)。添加 .promptsForUserConfiguration() 以在用户添加控件时显示配置 UI。
.controlWidgetActionHint("Toggles flashlight") — VoiceOver 辅助功能提示.displayName("My Control") / .description("...") — 在控制中心 UI 中显示小组件渲染模式跨越多个 iOS 版本:widgetAccentable() (iOS 16+)、WidgetAccentedRenderingMode (iOS 18+) 以及液态玻璃效果,如 glassEffect() 和 GlassEffectContainer (iOS 26+)。检测模式并相应调整布局。
struct MyWidgetView: View {
@Environment(\.widgetRenderingMode) var renderingMode
var body: some View {
if renderingMode == .accented {
// 简化布局 — 不透明图像被着色为白色,背景被玻璃替换
} else {
// 标准全彩布局
}
}
}
将视图标记为强调组的一部分。在强调色模式下,强调组视图与主组视图分开着色,创建视觉层次结构。
HStack {
VStack(alignment: .leading) {
Text("Title")
.font(.headline)
.widgetAccentable() // 强调组 — 在强调色模式下着色
Text("Subtitle")
// 默认为主组
}
Image(systemName: "star.fill")
.widgetAccentable() // 也是强调组
}
控制在强调色模式下图像的渲染方式。应用于 Image 视图:
Image("myPhoto")
.widgetAccentedRenderingMode(.accented) // 使用强调色着色
Image("myIcon")
.widgetAccentedRenderingMode(.monochrome) // 渲染为单色
Image("myBadge")
.widgetAccentedRenderingMode(.fullColor) // 保持原始颜色(选择退出)
最佳实践:仅在 .fullColor 渲染模式下显示全彩图像。策略性地使用 .widgetAccentable() 来创建视觉层次结构。使用多种强调色和背景图像进行测试。
VStack { /* content */ }
.containerBackground(for: .widget) {
Color.blue.opacity(0.2)
}
在强调色模式下,系统会移除背景并用主题化玻璃替换。要防止移除(将小组件排除在 iPad 锁定屏幕、待机模式之外):
.containerBackgroundRemovable(false)
Text("Label")
.padding()
.glassEffect() // 默认胶囊形状
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 小组件是放置在物理空间中的 3D 对象 — 安装在表面上或漂浮。它们支持独特的空间功能。
小组件可以抬高(在表面顶部)或凹陷(嵌入到垂直表面如墙壁中):
.supportedMountingStyles([.elevated, .recessed]) // 默认两者都支持
// .supportedMountingStyles([.recessed]) // 仅限墙壁的小组件
如果仅限于 .recessed,用户无法将小组件放置在水平表面上。
两种用于空间外观的视觉纹理:
.widgetTexture(.glass) // 默认 — 透明玻璃状外观
.widgetTexture(.paper) // 海报式外观,在超大尺寸下效果显著
小组件根据用户距离自动适应。系统在细节级别之间动画过渡:
@Environment(\.levelOfDetail) var levelOfDetail
var body: some View {
VStack {
Text(entry.value)
.font(levelOfDetail == .simplified ? .largeTitle : .title)
}
}
值:.default(近距离观看)和 .simplified(远距离观看 — 使用更大的文本、更少的细节)。
visionOS 支持所有系统系列以及超大尺寸:
.supportedFamilies([
.systemSmall, .systemMedium, .systemLarge,
.systemExtraLarge,
.systemExtraLargePortrait // visionOS 特定的纵向方向
])
超大系列与 .widgetTexture(.paper) 结合使用,对于海报式显示特别有效。
检测小组件背景是否可见(在强调色模式下被移除):
@Environment(\.showsWidgetContainerBackground) var showsBackground
将 .supplementalActivityFamilies([.medium]) 添加到 ActivityConfiguration。使用待机式全宽仪表板呈现。
来自配对 iPhone 的实时活动会自动出现在 macOS Sequoia+ 菜单栏中。无需代码更改。
ControlWidget 在 watchOS 上工作方式相同 — 在控制中心、操作按钮和智能堆栈中可用。与 iOS 相同的 StaticControlConfiguration / ControlWidgetButton 模式。
使用 .relevanceConfiguration(for:score:attributes:) 帮助系统在智能堆栈中推广小组件。属性包括 .location(CLLocation)、.timeOfDay(DateInterval) 和 .activity(String),用于上下文感知排名。
实现 PKPushRegistryDelegate 并处理 .widgetKit 推送类型以接收服务器到小组件的推送。更新共享容器数据并调用 WidgetCenter.shared.reloadAllTimelines()。推送到 iPhone 会自动同步到 Apple Watch 和 CarPlay。
在应用和扩展之间共享数据所必需。
group.com.company.appnamelet sharedContainer = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: "group.com.mycompany.myapp"
)!
let dataFileURL = sharedContainer.appendingPathComponent("widgetData.json")
// 主应用 - 写入数据
let shared = UserDefaults(suiteName: "group.com.mycompany.myapp")!
shared.set("Updated value", forKey: "myKey")
// 小组件扩展 - 读取数据
let shared = UserDefaults(suiteName: "group.com.mycompany.myapp")!
let value = shared.string(forKey: "myKey")
将 NSPersistentStoreDescription 指向共享容器 URL:
let sharedStoreURL = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: "group.com.mycompany.myapp"
)!.appendingPathComponent("MyApp.sqlite")
let description = NSPersistentStoreDescription(url: sharedStoreURL)
container.persistentStoreDescriptions = [description]
config.sharedContainerIdentifier 设置为您的 App Group ID,以便扩展可以访问下载CFNotificationCenterPostNotification / CFNotificationCenterAddObserver 和 CFNotificationCenterGetDarwinNotifyCenter() 进行简单的跨进程信号(例如,通知小组件调用 WidgetCenter.shared.reloadAllTimelines())将 .supplementalActivityFamilies([.small]) 添加到 ActivityConfiguration,以在 Apple Watch 智能堆栈上显示实时活动(与 CarPlay 使用 .medium 的修饰符相同)。
使用 @Environment(\.activityFamily) 来调整布局 — 检查 .small (watchOS) 与 iPhone 布局。
使用 @Environment(\.isLuminanceReduced) 来简化始终显示的视图 — 减少细节、使用白色文本、更大的字体。与 @Environment(\.colorScheme) 结合使用以正确处理深色模式。
watchOS 更新通过推送通知自动与 iPhone 同步。如果手表超出蓝牙范围,更新可能会延迟。
有关包含工作代码示例的完整分步教程,请参阅 Apple 的 使用 WidgetKit 和 SwiftUI 构建小组件 示例项目。
关键步骤:添加小组件扩展目标、配置 App Groups、实现 TimelineProvider、设计 SwiftUI 视图、从主应用更新。有关生产要求,请参见下面的专家审查清单。
架构:
UserDefaults.standard性能:
数据与状态:
用户体验:
实时活动(如果适用):
液态玻璃(如果适用):
widgetAccentable() 以创建视觉层次结构WidgetAccentedRenderingMode(.accented、.monochrome 或 .fullColor).containerBackground(for: .widget) 配置了容器背景visionOS(如果适用):
.elevated、.recessed 或两者).glass 或 .paper)levelOfDetail.systemExtraLarge、.systemExtraLargePortrait)控制中心小组件(如果适用):
This skill provides comprehensive API reference for Apple's widget and extension ecosystem:
Widgets are SwiftUI archived snapshots rendered on a timeline by the system. Extensions are sandboxed executables bundled with your app.
✅ Use this skill when :
❌ Do NOT use this skill for :
For widgets that don't require user configuration.
@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])
}
}
For widgets with user configuration using 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")
}
}
Migration from IntentConfiguration : iOS 16 and earlier used IntentConfiguration with SiriKit intents. Migrate to AppIntentConfiguration for iOS 17+.
For Live Activities (covered in Live Activities section).
No user configuration needed? Use StaticConfiguration. Simple static options? Use AppIntentConfiguration with WidgetConfigurationIntent. Dynamic options from app data? Use AppIntentConfiguration + EntityQuery.
Quick Reference :
systemSmall (~170×170, iOS 14+) — Single piece of info, iconsystemMedium (~360×170, iOS 14+) — Multiple data points, chartsystemLarge (~360×380, iOS 14+) — Detailed view, listsystemExtraLarge (~720×380, iOS 15+ iPad only) — Rich layouts, multiple viewsaccessoryCircular (~48×48pt) — Circular complication, icon or gaugeaccessoryRectangular (~160×72pt) — Above clock, text + iconaccessoryInline (single line) — Above date, text onlystruct 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
])
}
}
Provides entries that define when the system should render your 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)
}
}
Controls when the system requests a new timeline:
.atEnd — Reload after last entry.after(date) — Reload at specific date.never — No automatic reload (manual only)import WidgetKit
// Reload all widgets of this kind
WidgetCenter.shared.reloadAllTimelines()
// Reload specific kind
WidgetCenter.shared.reloadTimelines(ofKind: "MyWidget")
Daily budget : 40-70 reloads/day (varies by system load and engagement)
Budget-exempt : User-initiated reload, app foregrounding, widget added, system reboot
Strategic (4x/hour) — ~48 reloads/day, low battery impact
Aggressive (12x/hour) — Budget exhausted by 6 PM, high impact
On-demand only — 5-10 reloads/day, minimal impact
Reload on significant data changes and time-based events. Avoid speculative or cosmetic reloads.
// ✅ 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) }
loadRecentItems(limit: 10), not entire database)Never make network requests in widget views — they won't complete before rendering. Fetch data in getTimeline() instead.
Complete getTimeline() in under 5 seconds. Cache expensive computations in the main app, read pre-computed data from shared container, limit to 10-20 entries.
Precompute everything in TimelineEntry, keep views simple. No expensive operations in body.
AsyncImage does NOT work in widgetsInteractive widgets use SwiftUI Button and Toggle with App Intents.
Button(intent: IncrementIntent()) {
Label("Increment", systemImage: "plus.circle")
}
The intent updates shared data via App Groups in its perform() method. See axiom-app-intents-ref for full AppIntent definition syntax.
Same pattern as Button — use a Toggle bound to state, invoke intent on change:
Toggle(isOn: $isEnabled) {
Text("Feature")
}
.onChange(of: isEnabled) { newValue in
Task { try? await ToggleFeatureIntent(enabled: newValue).perform() }
}
The intent follows the same AppIntent structure with a @Parameter(title: "Enabled") var enabled: Bool. See axiom-app-intents-ref for full AppIntent definition syntax.
Provides visual feedback during App Intent execution.
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")
}
}
}
}
Effect : Content with .invalidatableContent() becomes slightly transparent while the associated intent executes, providing user feedback.
Text("\(entry.value)")
.contentTransition(.numericText(value: Double(entry.value)))
Effect : Numbers smoothly count up or down instead of instantly changing.
VStack {
if entry.showDetail {
DetailView()
.transition(.scale.combined(with: .opacity))
}
}
.animation(.spring(response: 0.3), value: entry.showDetail)
Define configuration parameters for your widget.
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)")
}
}
Provide dynamic options for configuration.
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()
}
}
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)
}
}
Defines static and dynamic data for a Live Activity.
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
}
Key constraint : ActivityAttributes total data size must be under 4KB to start successfully.
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
)
Always check ActivityAuthorizationInfo().areActivitiesEnabled before requesting. Handle these errors from Activity.request():
ActivityAuthorizationError — User denied Live Activities permissionActivityError.dataTooLarge — ActivityAttributes exceeds 4KB; reduce attribute sizeActivityError.tooManyActivities — System limit reached (typically 2-3 simultaneous)Store activity.id after successful request for later updates.
// 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
))
Use activity.activityStateUpdates async sequence to observe state changes (.active, .ended, .dismissed, .stale). Clean up stored activity IDs on .ended or .dismissed. Cancel the monitoring task in deinit.
await activity.end(
ActivityContent(state: finalState, staleDate: nil),
dismissalPolicy: .default
)
Dismissal policy options:
.immediate — Removes instantly.default — Stays on Lock Screen for ~4 hours.after(date) — Removes at specific time (e.g., .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)
}
Standard limit is ~10-12 pushes/hour. For live events (sports, stocks), add the com.apple.developer.activity-push-notification-frequent-updates entitlement for significantly higher limits.
Live Activities appear in the Dynamic Island with three size classes:
Shown when another Live Activity is expanded or when multiple activities are active.
DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
Image(systemName: "timer")
}
DynamicIslandExpandedRegion(.trailing) {
Text("\(entry.timeRemaining)")
}
// ...
} compactLeading: {
Image(systemName: "timer")
} compactTrailing: {
Text("\(entry.timeRemaining)")
.frame(width: 40)
}
Shown when more than two Live Activities are active (circular avatar).
DynamicIsland {
// ...
} minimal: {
Image(systemName: "timer")
.foregroundStyle(.tint)
}
Shown when user long-presses the compact view.
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")
}
}
}
}
Content should nest concentrically inside the Dynamic Island's rounded shape with even margins. Use Circle() or RoundedRectangle(cornerRadius:) — never sharp Rectangle() which pokes into corners.
Dynamic Island animations should feel organic and elastic. Use .spring(response: 0.6, dampingFraction: 0.7) or .interpolatingSpring(stiffness: 300, damping: 25) instead of linear animations.
Controls appear in Control Center, Lock Screen, and Action Button (iPhone 15 Pro+).
For simple controls without configuration.
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")
}
}
For configurable controls.
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")
}
}
}
}
For discrete actions (one-shot operations).
ControlWidgetButton(action: PlayMusicIntent()) {
Label("Play", systemImage: "play.fill")
}
.tint(.purple)
For boolean state.
struct AirplaneModeControl: ControlWidget {
var body: some ControlWidgetConfiguration {
StaticControlConfiguration(kind: "AirplaneModeControl") {
ControlWidgetToggle(
isOn: AirplaneModeIntent.isEnabled,
action: AirplaneModeIntent()
) { isOn in
Label(isOn ? "On" : "Off", systemImage: "airplane")
}
}
}
}
For controls needing async state, pass a ControlValueProvider to 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) }
}
The provider value is passed to your control's closure: { value in ControlWidgetButton(...) }.
Use AppIntentControlConfiguration with a WidgetConfigurationIntent (same pattern as configurable widgets). Add .promptsForUserConfiguration() to show configuration UI when the user adds the control.
.controlWidgetActionHint("Toggles flashlight") — VoiceOver accessibility hint.displayName("My Control") / .description("...") — Shown in Control Center UIWidget rendering modes span multiple iOS versions: widgetAccentable() (iOS 16+), WidgetAccentedRenderingMode (iOS 18+), and Liquid Glass effects like glassEffect() and GlassEffectContainer (iOS 26+). Detect the mode and adapt layout accordingly.
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
}
}
}
Marks views as part of the accent group. In accented mode, accent-group views are tinted separately from primary-group views, creating visual hierarchy.
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
}
Controls how images render in accented mode. Apply to Image views:
Image("myPhoto")
.widgetAccentedRenderingMode(.accented) // Tinted with accent color
Image("myIcon")
.widgetAccentedRenderingMode(.monochrome) // Rendered as monochrome
Image("myBadge")
.widgetAccentedRenderingMode(.fullColor) // Keeps original colors (opt-out)
Best practices : Display full-color images only in .fullColor rendering mode. Use .widgetAccentable() strategically for visual hierarchy. Test with multiple accent colors and background images.
VStack { /* content */ }
.containerBackground(for: .widget) {
Color.blue.opacity(0.2)
}
In accented mode, the system removes the background and replaces it with themed glass. To prevent removal (excludes widget from iPad Lock Screen, StandBy):
.containerBackgroundRemovable(false)
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)
Combine multiple glass elements with 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 widgets are 3D objects placed in physical space — mounted on surfaces or floating. They support unique spatial features.
Widgets can be elevated (on top of surfaces) or recessed (embedded into vertical surfaces like walls):
.supportedMountingStyles([.elevated, .recessed]) // Default is both
// .supportedMountingStyles([.recessed]) // Wall-only widget
If limited to .recessed, users cannot place the widget on horizontal surfaces.
Two visual textures for spatial appearance:
.widgetTexture(.glass) // Default — transparent glass-like appearance
.widgetTexture(.paper) // Poster-like look, effective with extra-large sizes
Widgets adapt to user distance automatically. The system animates transitions between detail levels:
@Environment(\.levelOfDetail) var levelOfDetail
var body: some View {
VStack {
Text(entry.value)
.font(levelOfDetail == .simplified ? .largeTitle : .title)
}
}
Values: .default (close viewing) and .simplified (distance viewing — use larger text, fewer details).
visionOS supports all system families plus extra-large sizes:
.supportedFamilies([
.systemSmall, .systemMedium, .systemLarge,
.systemExtraLarge,
.systemExtraLargePortrait // visionOS-specific portrait orientation
])
Extra-large families are particularly effective with .widgetTexture(.paper) for poster-like displays.
Detect whether the widget background is visible (removed in accented mode):
@Environment(\.showsWidgetContainerBackground) var showsBackground
Add .supplementalActivityFamilies([.medium]) to ActivityConfiguration. Uses StandBy-style full-width dashboard presentation.
Live Activities from paired iPhone appear automatically in macOS Sequoia+ menu bar. No code changes required.
ControlWidget works identically on watchOS — available in Control Center, Action Button, and Smart Stack. Same StaticControlConfiguration / ControlWidgetButton pattern as iOS.
Use .relevanceConfiguration(for:score:attributes:) to help the system promote widgets in Smart Stack. Attributes include .location(CLLocation), .timeOfDay(DateInterval), and .activity(String) for context-aware ranking.
Implement PKPushRegistryDelegate and handle .widgetKit push type to receive server-to-widget pushes. Update shared container data and call WidgetCenter.shared.reloadAllTimelines(). Pushes to iPhone automatically sync to Apple Watch and CarPlay.
Required for sharing data between your app and extensions.
group.com.company.appnamelet sharedContainer = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: "group.com.mycompany.myapp"
)!
let dataFileURL = sharedContainer.appendingPathComponent("widgetData.json")
// 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")
Point NSPersistentStoreDescription at the shared container URL:
let sharedStoreURL = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: "group.com.mycompany.myapp"
)!.appendingPathComponent("MyApp.sqlite")
let description = NSPersistentStoreDescription(url: sharedStoreURL)
container.persistentStoreDescriptions = [description]
config.sharedContainerIdentifier to your App Group ID for downloads accessible by extensionsCFNotificationCenterPostNotification / CFNotificationCenterAddObserver with CFNotificationCenterGetDarwinNotifyCenter() for simple cross-process signals (e.g., notify widget to call WidgetCenter.shared.reloadAllTimelines())Add .supplementalActivityFamilies([.small]) to ActivityConfiguration to show Live Activities on Apple Watch Smart Stack (same modifier used for CarPlay with .medium).
Use @Environment(\.activityFamily) to adapt layout — check for .small (watchOS) vs iPhone layout.
Use @Environment(\.isLuminanceReduced) to simplify views for Always On Display — reduce detail, use white text, larger fonts. Combine with @Environment(\.colorScheme) for proper dark mode handling.
watchOS updates sync automatically with iPhone via push notifications. Updates may be delayed if watch is out of Bluetooth range.
For a complete step-by-step tutorial with working code examples, see Apple's Building Widgets Using WidgetKit and SwiftUI sample project.
Key steps : Add widget extension target, configure App Groups, implement TimelineProvider, design SwiftUI view, update from main app. See Expert Review Checklist below for production requirements.
Architecture :
UserDefaults.standard in widget codePerformance :
Data & State:
User Experience :
Live Activities (if applicable):
Liquid Glass (if applicable):
widgetAccentable() applied for visual hierarchy in accented modeWidgetAccentedRenderingMode set on images (.accented, .monochrome, or .fullColor).containerBackground(for: .widget)visionOS (if applicable):
.elevated, .recessed, or both).glass or .paper)levelOfDetail handled for proximity-aware layouts.systemExtraLarge, .systemExtraLargePortrait)Control Center Widgets (if applicable):
Testing :
Test placeholder(), getSnapshot(), and getTimeline() methods. Save test data to shared container, call getTimeline() with a mock context, assert entries are non-empty and contain expected data. Use waitForExpectations(timeout: 5.0) for async timeline generation.
print() logging in getTimeline() to verify it's being called and data is loadedFileManager.default.containerURL(forSecurityApplicationGroupIdentifier:) in both app and widget — paths must matchWidgetCenter.shared.reloadAllTimelines()Widget not appearing in gallery : Check WidgetBundle includes it, verify supportedFamilies(), check extension's "Skip Install" = NO, verify deployment target matches app.
Symptoms : Widget shows stale data, doesn't update
Diagnostic Steps :
.atEnd vs .after() vs .never)getTimeline() is being called (add logging)Solution :
// Manual reload from main app when data changes
import WidgetKit
WidgetCenter.shared.reloadAllTimelines()
// or
WidgetCenter.shared.reloadTimelines(ofKind: "MyWidget")
Symptoms : Widget shows default/empty data
Diagnostic Steps :
Solution :
// Both app AND extension must use:
let shared = UserDefaults(suiteName: "group.com.mycompany.myapp")!
// NOT:
let shared = UserDefaults.standard // ❌ Different containers
Symptoms : Activity.request() throws error
Common Errors :
"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
}
Symptoms : Tapping button does nothing
Diagnostic Steps :
perform() returns IntentResultintent: parameter, not action:Solution :
// ✅ 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")
}
Control Center widget slow : Use async in ControlValueProvider.currentValue(), never block with Thread.sleep. Provide fast previewValue fallback.
Widget shows wrong size : Switch on @Environment(\.widgetFamily) in view, adapt layout per family, avoid hardcoded sizes.
Timeline entries out of order : Ensure entry dates are chronological. Use incrementing offsets from Date().
watchOS Live Activity not showing : Add .supplementalActivityFamilies([.small]) to ActivityConfiguration, verify watchOS 11+, check Bluetooth/pairing.
Symptoms : Widget rendering slow, battery drain
Common Causes :
getTimeline()Solution :
// ✅ 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
}
getTimeline(). Test timeline logic with unit tests or device runs.print() in getTimeline() — verify it's called and data loadsWidgetCenter.shared.getCurrentConfigurations() to verify registrationSwiftData in Widgets (iOS 17+):
ModelContainer in widget with same schema as main appModelConfiguration(url: containerURL)WidgetCenter.shared.reloadAllTimelines() after writesGRDB/SQLite in Widgets :
DatabasePool (not DatabaseQueue) for concurrent readstry DatabasePool(path: dbPath, configuration: readOnlyConfig)configuration.readonly = true in widget to prevent accidental writesWWDC : 2025-278, 2024-10157, 2024-10068, 2024-10098, 2023-10028, 2023-10194, 2022-10184, 2022-10185
Docs : /widgetkit, /activitykit, /appintents
Skills : axiom-app-intents-ref, axiom-swift-concurrency, axiom-swiftui-performance, axiom-swiftui-layout, axiom-extensions-widgets
Version : 0.9 | Platforms : iOS 14+, iPadOS 14+, watchOS 9+, macOS 11+, visionOS 2+
Weekly Installs
105
Repository
GitHub Stars
606
First Seen
Jan 21, 2026
Security Audits
Gen Agent Trust HubPassSocketWarnSnykPass
Installed on
opencode88
claude-code82
codex81
gemini-cli79
cursor76
github-copilot74
iOS实时活动开发指南:ActivityKit实现灵动岛与锁屏通知
465 周安装
Git推送工作流:自动暂存、规范提交、一键推送远程分支的智能脚本
471 周安装
文件管理技能 - 自动化文件/文件夹操作、批量处理、安全管理的智能工具
472 周安装
Turso数据库调试指南:SQLite兼容性、字节码对比与Rust日志记录
472 周安装
skill-creator:AI CLI技能自动化创建工具,遵循Anthropic最佳实践,支持Copilot/Claude/Codex
473 周安装
性能剖析指南:Lighthouse审计、核心网页指标优化与运行时性能分析
477 周安装