mcpField
A
axiom-extensions-widgets

axiom-extensions-widgets

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

关于 axiom-extensions-widgets

面向开发者的技能,帮助构建和排查 Apple 小组件、实时活动及控制中心控件。内容涵盖扩展限制、数据共享、刷新策略和常见故障模式。

主要功能

  1. 01小组件时间线与刷新指导
  2. 02使用 App Groups 共享数据
  3. 03实时活动限制与生命周期
  4. 04控制中心异步数据模式
  5. 05小组件及交互问题排查

适用场景

  1. 01排查小组件数据过期
  2. 02在应用与扩展间共享数据
  3. 03构建或调试实时活动
  4. 04修复无响应的控制中心控件

安装

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

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

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

详细介绍

扩展与小组件 — 规范

核心理念

“小组件不是迷你应用。它们是应用数据的一览视图,在特定时刻渲染并由系统显示。扩展在沙盒环境中运行,内存和执行时间都有限。”

思维模型:把小组件视为时间线上的归档快照,而不是实时视图。小组件不会持续“运行”——它会渲染、归档,然后由系统显示该快照。

扩展沙盒机制:扩展具有以下限制:

  • 内存有限(约 30MB)
  • 小组件视图中无法访问网络(仅可在 TimelineProvider 中获取数据)
  • 与主应用使用独立的捆绑容器
  • 需要 App Groups 才能共享数据

何时使用此技能

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

  • 实现任何小组件(主屏幕、锁定屏幕、待机模式、控制中心)
  • 创建实时活动
  • 调试小组件显示过期数据的原因
  • 小组件未显示在图库中
  • 交互式按钮没有响应
  • 实时活动无法启动
  • 控制中心控件无响应
  • 在应用与小组件/扩展之间共享数据

❌ 以下情况请勿使用此技能:

  • 纯 App Intents 实现(使用 app-intents-ref)
  • SwiftUI 布局问题(使用 swiftui-layout)
  • 性能分析(使用 swiftui-performance)
  • 常规调试(使用 xcode-debugging)

相关技能

  • extensions-widgets-ref — 全面的 API 参考
  • app-intents-ref — 用于交互式小组件的 App Intents
  • swift-concurrency — 用于获取数据的异步模式
  • swiftdata — 在 App Groups 中使用 SwiftData

示例提示词

1. “我的小组件没有更新”

→ 此技能介绍时间线策略、刷新预算、手动重新加载和 App Groups 配置

2. “如何在应用与小组件之间共享数据?”

→ 此技能说明 App Groups 权限、共享的 UserDefaults 和容器 URL

3. “更新应用后,小组件仍显示旧数据”

→ 此技能介绍容器路径、UserDefaults 套件名称和 WidgetCenter 重新加载

4. “实时活动无法启动”

→ 此技能介绍 4KB 数据限制、ActivityAttributes 约束和授权检查

5. “控制中心控件响应特别慢”

→ 此技能介绍异步 ValueProvider 模式和乐观 UI

6. “交互式小组件按钮不起作用”

→ 此技能介绍 App Intent perform() 实现和 WidgetCenter 重新加载


危险信号/反模式

模式 1:在小组件视图中进行网络调用

耗时:调试小组件为何空白或显示错误需要 2-4 小时

症状
  • 小组件已渲染,但没有显示数据
  • 控制台错误:“NSURLSession not available in widget extension”
  • 小组件间歇性显示空白
❌ 错误代码
struct MyWidgetView: View {
    @State private var data: String?

    var body: some View {
        VStack {
            if let data = data {
                Text(data)
            }
        }
        .onAppear {
            // ❌ WRONG — Network in widget view
            Task {
                let (data, _) = try await URLSession.shared.data(from: apiURL)
                self.data = String(data: data, encoding: .utf8)
            }
        }
    }
}

失败原因:小组件视图会被渲染、归档并重复使用。在视图中进行网络调用并不可靠,也可能不会执行。

✅ 正确代码
// Main app — prefetch and save
func updateWidgetData() async {
    let data = try await fetchFromAPI()
    let shared = UserDefaults(suiteName: "group.com.myapp")!
    shared.set(data, forKey: "widgetData")

    WidgetCenter.shared.reloadAllTimelines()
}

// Widget TimelineProvider — read from shared storage
struct Provider: TimelineProvider {
    func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
        let shared = UserDefaults(suiteName: "group.com.myapp")!
        let data = shared.string(forKey: "widgetData") ?? "No data"

        let entry = SimpleEntry(date: Date(), data: data)
        let timeline = Timeline(entries: [entry], policy: .atEnd)
        completion(timeline)
    }
}

模式:在主应用中获取数据,保存到共享存储,再在小组件中读取。

TimelineProvider 可以发起网络请求吗?

可以,但有一些重要注意事项:

struct Provider: TimelineProvider {
    func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
        Task {
            // ✅ Network requests ARE allowed here
            let data = try await fetchFromAPI()
            let entry = SimpleEntry(date: Date(), data: data)
            completion(Timeline(entries: [entry], policy: .atEnd))
        }
    }
}

限制:

  • 30 秒超时 — 如果 getTimeline() 未完成,系统会终止扩展
  • 不支持后台会话 — 无法下载大型文件
  • 耗电 — 每次重新加载时间线都会消耗电量
  • 不保证成功 — 网络连接较差时可能失败

最佳实践:在主应用中预先获取数据(更快、更可靠),仅将 TimelineProvider 网络请求用作回退方案。


模式 2:缺少 App Groups

耗时:调试小组件为何显示空数据或默认数据需要 1-2 小时

症状
  • 小组件始终显示占位内容或默认值
  • 主应用中的更改不会反映到小组件中
  • 在小组件中读取 UserDefaults 会返回 nil
❌ 错误代码
// Main app
UserDefaults.standard.set("Updated", forKey: "myKey")

// Widget extension
let value = UserDefaults.standard.string(forKey: "myKey") // Returns nil!

失败原因:在应用和扩展中,UserDefaults.standard 访问的是不同的容器。

✅ 正确代码
// 1. Enable App Groups entitlement in BOTH targets:
//    - Main app target: Signing & Capabilities → + App Groups → "group.com.myapp"
//    - Widget extension target: Same group identifier

// 2. Main app
let shared = UserDefaults(suiteName: "group.com.myapp")!
shared.set("Updated", forKey: "myKey")

// 3. Widget extension
let shared = UserDefaults(suiteName: "group.com.myapp")!
let value = shared.string(forKey: "myKey") // Returns "Updated"

验证:

let containerURL = FileManager.default.containerURL(
    forSecurityApplicationGroupIdentifier: "group.com.myapp"
)
print("Shared container: \(containerURL?.path ?? "MISSING")")
// Should print path, not "MISSING"

模式 3:刷新过于频繁(预算耗尽)

时间成本:用户体验差、耗电、小组件停止更新

症状
  • 小组件起初频繁更新,随后停止更新
  • 控制台日志:“Timeline reload budget exhausted”
  • 几小时后,小组件显示的内容过时
❌ 错误代码
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
    var entries: [SimpleEntry] = []

    // ❌ WRONG — 60 entries at 1-minute intervals
    for minuteOffset in 0..<60 {
        let date = Calendar.current.date(byAdding: .minute, value: minuteOffset, to: Date())!
        entries.append(SimpleEntry(date: date, data: "Data"))
    }

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

问题所在:系统每天提供 40-70 次重新加载。此做法每小时重新加载 24 次 → 在 2-3 小时内耗尽预算。

✅ 正确代码
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
    var entries: [SimpleEntry] = []

    // ✅ CORRECT — 8 entries at 15-minute intervals (2 hours coverage)
    for offset in 0..<8 {
        let date = Calendar.current.date(byAdding: .minute, value: offset * 15, to: Date())!
        entries.append(SimpleEntry(date: date, data: getData()))
    }

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

指南:

  • 大多数小组件采用 15-60 分钟的间隔
  • 对时间敏感的数据(股票、体育赛事)采用 5-15 分钟的间隔
  • 使用 .atEnd 策略自动重新加载
  • 让系统根据用户参与度决定最佳刷新时机

模式 4:在控件中阻塞主线程

时间成本:控制中心控件无响应,用户体验差

症状
  • 在控制中心轻点控件后,转圈加载数秒
  • 控件似乎“卡住”或冻结
  • 没有即时视觉反馈
❌ 错误代码
struct ThermostatControl: ControlWidget {
    var body: some ControlWidgetConfiguration {
        StaticControlConfiguration(kind: "Thermostat") {
            ControlWidgetButton(action: GetTemperatureIntent()) {
                // ❌ WRONG — Synchronous fetch blocks UI
                let temp = HomeManager.shared.currentTemperature() // Blocking call
                Label("\(temp)°", systemImage: "thermometer")
            }
        }
    }
}

问题所在:按钮在主线程上渲染。阻塞网络或数据库调用会导致 UI 冻结。

✅ 正确代码
struct ThermostatProvider: ControlValueProvider {
    func currentValue() async throws -> ThermostatValue {
        // ✅ CORRECT — Async fetch, non-blocking
        let temp = try await HomeManager.shared.fetchTemperature()
        return ThermostatValue(temperature: temp)
    }

    var previewValue: ThermostatValue {
        ThermostatValue(temperature: 72) // Instant fallback
    }
}

struct ThermostatValue: ControlValueProviderValue {
    var temperature: Int
}

struct ThermostatControl: ControlWidget {
    var body: some ControlWidgetConfiguration {
        StaticControlConfiguration(kind: "Thermostat", provider: ThermostatProvider()) { value in
            ControlWidgetButton(action: AdjustTemperatureIntent()) {
                Label("\(value.temperature)°", systemImage: "thermometer")
            }
        }
    }
}

模式:使用 ControlValueProvider 获取异步数据,并提供即时的 previewValue 作为备用值。


模式 5:缺少结束策略(僵尸实时活动)

时间成本:惹恼用户,收到差评

症状
  • 事件结束后,实时活动仍在锁定屏幕上停留数小时
  • 用户必须手动关闭已完成的活动
  • 活动显示“Delivered”,但不会消失
❌ 错误代码
// Start activity
let activity = try Activity.request(attributes: attributes, content: initialContent)

// Later... event completes
// ❌ WRONG — Never call .end()
// Activity stays forever until user dismisses

问题所在:除非明确结束,否则活动会无限期持续。

✅ 正确代码
// When event completes
let finalState = DeliveryAttributes.ContentState(
    status: .delivered,
    deliveredAt: Date()
)

await activity.end(
    ActivityContent(state: finalState, staleDate: nil),
    dismissalPolicy: .default // Removes after ~4 hours
)

// Or for immediate removal
await activity.end(nil, dismissalPolicy: .immediate)

// Or remove at specific time
let dismissTime = Date().addingTimeInterval(30 * 60) // 30 min
await activity.end(nil, dismissalPolicy: .after(dismissTime))

最佳实践:

  • .immediate — 短暂事件(计时器结束、歌曲播放完毕)
  • .default — 大多数活动(显示“已完成”状态约 4 小时)
  • .after(date) — 指定结束时间(会议结束、航班降落)

模式 6:超出 4KB 数据限制(实时活动)

时间成本:活动无法启动且不显示错误,难以调试

症状
  • Activity.request() 抛出错误
  • 控制台显示:“Activity attributes exceed size limit”
  • 活动从未出现在锁定屏幕上
❌ 错误代码
struct GameAttributes: ActivityAttributes {
    struct ContentState: Codable, Hashable {
        var teamALogo: Data // ❌ Large image data
        var teamBLogo: Data
        var playByPlay: [String] // ❌ Unbounded array
        var statistics: [String: Any] // ❌ Large dictionary
    }

    var gameID: String
    var venueName: String
}

// Fails if total size > 4KB
let activity = try Activity.request(attributes: attrs, content: content)

失败原因:ActivityAttributes 与 ContentState 的总大小必须小于 4KB。

✅ 正确代码
struct GameAttributes: ActivityAttributes {
    struct ContentState: Codable, Hashable {
        var teamAScore: Int // ✅ Small primitives
        var teamBScore: Int
        var quarter: Int
        var timeRemaining: String // "2:34"
        var lastPlay: String? // Single most recent play
    }

    var gameID: String // ✅ Reference, not full data
    var teamAName: String
    var teamBName: String
}

// Use asset catalog for images in view
struct GameLiveActivityView: View {
    var context: ActivityViewContext<GameAttributes>

    var body: some View {
        HStack {
            Image(context.attributes.teamAName) // Asset catalog
            Text("\(context.state.teamAScore)")
            // ...
        }
    }
}

策略:

  • 存储 ID 或引用,而不是完整对象
  • 图片使用资源目录(不要嵌入 Data)
  • 让 ContentState 保持精简(仅包含可变数据)
  • 在视图中使用计算属性获取派生数据
大小目标(安全余量)

硬性限制:4096 字节(4KB)

目标指南:

  • ✅ < 2KB:安全且留有增长空间 — 建议用于 v1.0
  • ⚠️ 2-3KB:可以接受,但添加功能时要密切留意
  • 🔴 3.5KB+:有风险 — 后续字段可能导致超限

安全余量为何重要:之后你会添加字段(新功能、更多数据)。如果一开始就达到 3.8KB,后续将毫无增长空间。

检查大小:

let attributes = GameAttributes(gameID: "123", teamAName: "Hawks", teamBName: "Eagles")
let state = GameAttributes.ContentState(teamAScore: 14, teamBScore: 10, quarter: 2, timeRemaining: "5:23", lastPlay: nil)

let encoder = JSONEncoder()
if let attributesData = try? encoder.encode(attributes),
   let stateData = try? encoder.encode(state) {
    let totalSize = attributesData.count + stateData.count
    print("Total size: \(totalSize) bytes")

    if totalSize < 2048 {
        print("✅ Safe with room to grow")
    } else if totalSize < 3072 {
        print("⚠️ Acceptable but monitor")
    } else if totalSize < 3584 {
        print("🔴 Risky - optimize now")
    } else {
        print("❌ CRITICAL - will likely fail")
    }
}

优化优先级(超过 2KB 时):

  1. 如果取值范围固定,将 String 描述替换为枚举
  2. 缩短字符串值(“Team A” → “A”)
  3. 使用更小的类型(范围允许时,将 Int 改为 Int8)
  4. 删除很少使用的可选字段

模式 7:小组件未出现在图库中

时间成本:花 30 分钟调试不可见的小组件

症状
  • 小组件构建成功
  • 控制台没有错误
  • 小组件未出现在小组件选择器或图库中
  • 无法添加到主屏幕
❌ 错误代码
@main
struct MyWidget: Widget {
    var body: some WidgetConfiguration {
        StaticConfiguration(kind: "MyWidget", provider: Provider()) { entry in
            MyWidgetView(entry: entry)
        }
        .configurationDisplayName("My Widget")
        .description("Shows data")
        // ❌ MISSING: supportedFamilies() — widget won't appear!
    }
}

失败原因:如果没有 supportedFamilies(),系统就不知道要提供哪些尺寸。

✅ 正确代码
@main
struct MyWidget: Widget {
    var body: some WidgetConfiguration {
        StaticConfiguration(kind: "MyWidget", provider: Provider()) { entry in
            MyWidgetView(entry: entry)
        }
        .configurationDisplayName("My Widget")
        .description("Shows data")
        .supportedFamilies([.systemSmall, .systemMedium]) // ✅ Required
    }
}

其他常见原因:

  • 小组件目标的“Skip Install”设为 YES(应设为 NO)
  • 小组件扩展未添加到应用的“Embed App Extensions”
  • 需要清理构建文件夹(Cmd+Shift+K)

决策树

Widget/Extension Issue?
│
├─ Widget not appearing in gallery?
│  ├─ Check WidgetBundle registered in @main
│  ├─ Verify supportedFamilies() includes intended families
│  └─ Clean build folder, restart Xcode
│
├─ Widget not refreshing?
│  ├─ Timeline policy set to .never?
│  │  └─ Change to .atEnd or .after(date)
│  ├─ Budget exhausted? (too frequent reloads)
│  │  └─ Increase interval between entries (15-60 min)
│  └─ Manual reload
│     └─ WidgetCenter.shared.reloadAllTimelines()
│
├─ Widget shows empty/old data?
│  ├─ App Groups configured in BOTH targets?
│  │  ├─ No → Add "App Groups" entitlement
│  │  └─ Yes → Verify same group ID
│  ├─ Using UserDefaults.standard?
│  │  └─ Change to UserDefaults(suiteName: "group.com.myapp")
│  └─ Shared container path correct?
│     └─ Print containerURL, verify not nil
│
├─ Interactive button not working?
│  ├─ App Intent perform() returns value?
│  │  └─ Must return IntentResult
│  ├─ perform() updates shared data?
│  │  └─ Update App Group storage
│  └─ Calls WidgetCenter.reloadTimelines()?
│     └─ Reload to reflect changes
│
├─ Live Activity fails to start?
│  ├─ Data size > 4KB?
│  │  └─ Reduce ActivityAttributes + ContentState
│  ├─ Authorization enabled?
│  │  └─ Check ActivityAuthorizationInfo().areActivitiesEnabled
│  └─ pushType correct?
│     └─ nil for local updates, .token for push
│
├─ Control Center control unresponsive?
│  ├─ Async operation blocking UI?
│  │  └─ Use ControlValueProvider with async currentValue()
│  └─ Provide previewValue for instant fallback
│
└─ watchOS Live Activity not showing?
   ├─ supplementalActivityFamilies includes .small?
   └─ Apple Watch paired and in range?

必须先完成的步骤

在调试任何小组件或扩展问题之前,请完成以下检查清单:

小组件调试清单

  • ☐ 主应用和扩展目标中均已启用 App Groups

    # Verify entitlements
    codesign -d --entitlements - /path/to/YourApp.app
    # Should show com.apple.security.application-groups
    
  • ☐ 小组件显示在小组件图库中(而不仅仅是在主屏幕上)

    • 长按主屏幕 → + 按钮 → 查找你的小组件
    • 确认其显示的名称和描述正确
  • ☐ 检查时间线错误的控制台日志

    # Xcode Console
    # Filter: "widget" OR "timeline"
    # Look for: "Timeline reload failed", "Budget exhausted"
    
  • ☐ 手动重新加载测试

    WidgetCenter.shared.reloadAllTimelines()
    
    • 如果这样能解决问题 → 问题出在时间线策略或刷新预算
  • ☐ 可访问共享容器

    let container = FileManager.default.containerURL(
        forSecurityApplicationGroupIdentifier: "group.com.myapp"
    )
    print("Container: \(container?.path ?? "NIL")")
    // Must print valid path, not "NIL"
    

实时活动调试清单

  • ☐ ActivityAttributes 小于 4KB

    let encoded = try JSONEncoder().encode(attributes)
    print("Size: \(encoded.count) bytes") // Must be < 4096
    
  • ☐ 检查授权状态

    let authInfo = ActivityAuthorizationInfo()
    print("Enabled: \(authInfo.areActivitiesEnabled)")
    
  • ☐ pushType 与服务端集成相匹配

    • nil → 仅限本地更新
    • .token → 需要推送通知
  • ☐ 已实现关闭策略

    • 每次调用 activity.end() 都必须指定策略

控制中心小组件清单

  • ☐ 异步数据使用 ControlValueProvider
  • ☐ previewValue 提供即时回退值
  • ☐ App Intent perform() 为异步
  • ☐ 视图中没有阻塞式网络/数据库调用

压力场景

场景 1:“小组件在生产环境中显示错误数据”

情况
  • 应用已发布到 App Store
  • 用户反映小组件显示的信息有误或过时
  • 在开发环境中运行正常
压力信号
  • 🚨 App Store 评价 — 1 星评价提到小组件无法正常工作
  • ⏰ 时间压力 — 需要尽快发布热修复
  • 👔 高管关注 — 管理层要求汇报最新状态
合理化陷阱(不要这样做)
  1. “只要更频繁地强制重新加载时间线就行了”

    • 为何无效:会耗尽预算,让问题更严重
  2. “小组件在测试中运行正常”

    • 为何无效:开发环境与生产环境中的 App Groups 不匹配
  3. “用户只要重启手机就行了”

    • 为何无效:这不是修复方案,还会损害声誉
必须执行的系统化修复方案
步骤 1:验证 App Groups(30 分钟)
// Add logging to BOTH app and widget
let group = "group.com.myapp.production" // Must match exactly
let container = FileManager.default.containerURL(
    forSecurityApplicationGroupIdentifier: group
)

print("[\(Bundle.main.bundleIdentifier ?? "?")] Container: \(container?.path ?? "NIL")")

// Log EVERY read/write
let shared = UserDefaults(suiteName: group)!
print("Writing key 'lastUpdate' = \(Date())")
shared.set(Date(), forKey: "lastUpdate")

验证:先运行应用,再运行小组件。两者应打印出相同的容器路径。

步骤 2:检查容器路径
# Device logs (Xcode → Window → Devices and Simulators → View Device Logs)
# Filter: Your app bundle ID
# Look for: Container path mismatches

常见问题:

  • 应用使用 group.com.myapp.dev
  • 小组件使用 group.com.myapp.production
  • 修复方法:确保两个 .entitlements 文件中的组 ID 完全相同
步骤 3:添加版本标记
// Main app — stamp every write
struct WidgetData: Codable {
    var value: String
    var timestamp: Date
    var appVersion: String
}

let data = WidgetData(
    value: "Latest",
    timestamp: Date(),
    appVersion: Bundle.main.appVersion
)
shared.set(try JSONEncoder().encode(data), forKey: "widgetData")

// Widget — verify version
if let data = shared.data(forKey: "widgetData"),
   let decoded = try? JSONDecoder().decode(WidgetData.self, from: data) {
    print("Widget reading data from app version: \(decoded.appVersion)")
}
步骤 4:在应用启动时强制重新加载
// AppDelegate / @main App
func applicationDidBecomeActive(_ application: UIApplication) {
    WidgetCenter.shared.reloadAllTimelines()
}
沟通模板

致相关方:

Status: Investigating widget data sync issue

Root cause: App Groups configuration mismatch between app and widget extension in production build

Fix: Updated both targets to use identical group identifier, added logging to prevent recurrence

Timeline: Hotfix submitted to App Store review (24-48h)

Workaround for users: Force-quit app and relaunch (triggers widget refresh)
节省的时间
  • 不采用系统化修复方案:需要 4-8 小时反复试错,并多次重新提交
  • 采用此流程:需要 1-2 小时来定位、修复和验证

场景 2:“实时活动必须立即更新”

情况
  • 体育比分应用
  • 用户希望比分在比赛事件发生后的几秒内更新
  • 当前基于时间线的方案太慢
压力
  • 竞争压力:“其他应用更新更快”
  • 截止期限:营销团队承诺提供“实时”更新
合理化陷阱(不要这样做)
  1. “只要每 5 秒创建一次条目就行了”

    • 为何无效:这不是真正的实时更新,还会耗尽电池电量,无法扩展
  2. “在小组件视图中添加 WebSocket”

    • 为何无效:扩展无法维持持久连接
  3. “把刷新间隔缩短到 1 秒”

    • 为何无效:时间线系统并非为亚分钟级更新而设计
必须采用的方案:分阶段实施

重要的现实情况:推送通知权限申请需要 3-7 天 才能获批。切勿在获批前承诺相关功能。

阶段 1:采用本地更新发布(无需审批)

立即发布,使用由应用驱动的更新:

// Start activity WITHOUT push (no entitlement needed)
let activity = try Activity.request(
    attributes: attributes,
    content: initialContent,
    pushType: nil  // Local updates only
)

// In your app when data changes (user opens app, pulls to refresh)
await activity.update(ActivityContent(
    state: updatedState,
    staleDate: nil
))

设定预期:只有在用户与应用交互时才会更新。对于 v1.0 来说,这可以接受,且无需任何审批。

阶段 2:获批后添加推送(3-7 天)

权限获批后,切换为推送:

步骤 1:为实时活动启用推送
// 1. Entitlement: "com.apple.developer.activity-push-notification"

// 2. Request activity with push token
let activity = try Activity.request(
    attributes: attributes,
    content: initialContent,
    pushType: .token
)

// 3. Monitor for token
Task {
    for await pushToken in activity.pushTokenUpdates {
        let tokenString = pushToken.map { String(format: "%02x", $0) }.joined()
        await sendTokenToServer(activityID: activity.id, token: tokenString)
    }
}
步骤 2:服务端推送(仅限阶段 2)
{
  "aps": {
    "timestamp": 1633046400,
    "event": "update",
    "content-state": {
      "teamAScore": 14,
      "teamBScore": 10,
      "quarter": 2,
      "timeRemaining": "5:23"
    },
    "alert": {
      "title": "Touchdown!",
      "body": "Team A scores"
    }
  }
}

标准推送限制:每小时约 10-12 次

步骤 3:申请高频更新权限(第二阶段,iOS 18.2+)

对于需要更频繁推送的应用(体育、股票):

<key>com.apple.developer.activity-push-notification-frequent-updates</key>
<true/>

需要在 App Store Connect 中提供理由:“实时体育比分需要即时更新,以提升用户参与度”

验证
// Log push receipt in Live Activity widget
#if DEBUG
let logURL = FileManager.default.containerURL(
    forSecurityApplicationGroupIdentifier: "group.com.myapp"
)!.appendingPathComponent("push_log.txt")

let timestamp = DateFormatter.localizedString(from: Date(), dateStyle: .none, timeStyle: .medium)
try! "\(timestamp): Received push\n".append(to: logURL)
#endif
沟通模板

发给市场营销/高管(第一阶段):

Launch Timeline:
- Phase 1 (immediate): Live Activities with app-driven updates. Updates appear when users open app or pull to refresh.
- Phase 2 (3-7 days): Push notification integration after Apple approval. Updates arrive within 1-3 seconds of server events.

Recommendation: Launch Phase 1 to market, communicate Phase 2 as "coming soon" once approved.

发给市场营销/高管(第二阶段):

"Real-time" positioning requires clarification:

Technical: Live Activities update via push notifications with 1-3 second latency from server to device

Constraints: Apple's push system has rate limits (~10/hour standard, axiom-higher with special entitlement)

Competitive analysis: Competitors likely use same system with similar limitations

Recommendation: Position as "near real-time" (accurate) vs "instant" (misleading)
现实情况
  • 推送通知是目前最快的机制
  • 1-3 秒的延迟很正常
  • 为优化电池续航,存在预算限制
  • 相比快几毫秒的比分更新,用户更重视更长的电池续航

如需全面了解推送通知设置(APNs 身份验证、负载格式、令牌管理、服务扩展),请参阅 axiom-push-notifications 和 axiom-push-notifications-ref。本技能涵盖 ActivityKit UI 和状态管理方面。


场景 3:“控制中心中的控制项响应缓慢”

情况
  • 智能家居灯光控制
  • 在控制中心点按控制项后,需要 3-5 秒才有响应
  • 用户期待即时反馈
必须修复:乐观 UI + 异步值提供程序
问题代码
struct LightControl: ControlWidget {
    var body: some ControlWidgetConfiguration {
        StaticControlConfiguration(kind: "Light") {
            ControlWidgetToggle(
                isOn: LightManager.shared.isOn, // ❌ Blocking fetch
                action: ToggleLightIntent()
            ) { isOn in
                Label(isOn ? "On" : "Off", systemImage: "lightbulb.fill")
            }
        }
    }
}
修复后的代码
// 1. Value Provider for async state
struct LightProvider: ControlValueProvider {
    func currentValue() async throws -> LightValue {
        // Async fetch from HomeKit/server
        let isOn = try await HomeManager.shared.fetchLightState()
        return LightValue(isOn: isOn)
    }

    var previewValue: LightValue {
        // Instant fallback from cache
        let shared = UserDefaults(suiteName: "group.com.myapp")!
        return LightValue(isOn: shared.bool(forKey: "lastKnownLightState"))
    }
}

struct LightValue: ControlValueProviderValue {
    var isOn: Bool
}

// 2. Optimistic Intent
struct ToggleLightIntent: AppIntent {
    static var title: LocalizedStringResource = "Toggle Light"

    func perform() async throws -> some IntentResult {
        // Immediately update cache (optimistic)
        let shared = UserDefaults(suiteName: "group.com.myapp")!
        let currentState = shared.bool(forKey: "lastKnownLightState")
        let newState = !currentState
        shared.set(newState, forKey: "lastKnownLightState")

        // Then update actual device (async)
        try await HomeManager.shared.setLight(isOn: newState)

        return .result()
    }
}

// 3. Control with provider
struct LightControl: ControlWidget {
    var body: some ControlWidgetConfiguration {
        StaticControlConfiguration(kind: "Light", provider: LightProvider()) { value in
            ControlWidgetToggle(
                isOn: value.isOn,
                action: ToggleLightIntent()
            ) { isOn in
                Label(isOn ? "On" : "Off", systemImage: "lightbulb.fill")
                    .tint(isOn ? .yellow : .gray)
            }
        }
    }
}

结果:控制项使用缓存状态即时响应,实际设备则在后台更新。


最终检查清单

发布小组件或实时活动前:

发布前

  • ☐ 两个目标(应用 + 扩展)都已配置 App Groups 权限
  • ☐ 共享 UserDefaults 使用 suiteName(而非 .standard)
  • ☐ 时间线条目间隔 ≥ 5 分钟(避免耗尽预算)
  • ☐ 小组件视图中没有网络请求(仅在 TimelineProvider 中发起)
  • ☐ ActivityAttributes + ContentState < 4KB
  • ☐ 实时活动通过 .end() 结束,并使用适当的关闭策略
  • ☐ 控制中心控制项使用 ControlValueProvider 获取异步数据
  • ☐ 已在实际设备上测试(而非仅在模拟器上)——必须测试,因为:
    • 模拟器不会强制执行时间线预算限制
    • 推送通知无法在模拟器中运行
    • App Groups 容器路径不同(模拟器与设备之间)
    • 模拟器不会强制执行内存限制
    • 后台刷新行为不同
  • ☐ 已测试所有支持的小组件类型
  • ☐ 已确认小组件显示在小组件图库中

发布后监控

  • ☐ 监控“Timeline reload budget exhausted”错误
  • ☐ 在分析数据中跟踪小组件数据过时情况
  • ☐ 留意 App Store 评论中与小组件相关的投诉
  • ☐ 记录 App Group 容器访问情况,以便调试

常见故障模式

  • 缺少 App Groups → 小组件显示默认数据
  • 群组 ID 错误 → 应用和小组件无法通信
  • 刷新过于频繁 → 数小时后小组件停止更新
  • 在视图中发起网络请求 → 小组件渲染为空白
  • 未设置关闭策略 → 实时活动成为僵尸
  • 阻塞主线程 → 控制项无响应

请记住:小组件不是迷你应用。它们是由系统渲染、可供用户快速浏览的快照。扩展运行在沙盒环境中,资源限制严格。遵循本技能中的模式,避免最常见的问题。


资源

技能:axiom-extensions-widgets-ref、axiom-push-notifications、axiom-push-notifications-ref、axiom-background-processing

评论

支持 Markdown