axiom-extensions-widgets

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Extensions & Widgets — Discipline

扩展与Widget —— 规范

Core Philosophy

核心理念

"Widgets are not mini apps. They're glanceable views into your app's data, rendered at strategic moments and displayed by the system. Extensions run in sandboxed environments with limited memory and execution time."
Mental model: Think of widgets as archived snapshots on a timeline, not live views. Your widget doesn't "run" continuously — it renders, gets archived, and the system displays the snapshot.
Extension sandboxing: Extensions have:
  • Limited memory (~30MB)
  • No network access in widget views (fetch in TimelineProvider only)
  • Separate bundle container from main app
  • Require App Groups for data sharing
“Widget并非迷你应用。它们是应用数据的概览视图,由系统在特定时刻渲染并展示。扩展运行在沙箱环境中,内存和执行时间受限。”
思维模型:将Widget视为时间线上的已存档快照,而非实时视图。你的Widget不会持续“运行”——它会渲染、被存档,然后由系统展示快照。
扩展沙箱限制:扩展具有以下限制:
  • 有限内存(约30MB)
  • Widget视图中无网络访问权限(仅可在TimelineProvider中获取数据)
  • 与主应用分离的Bundle容器
  • 需要App Groups实现数据共享

When to Use This Skill

何时使用本技能

Use this skill when:
  • Implementing any widget (Home Screen, Lock Screen, StandBy, Control Center)
  • Creating Live Activities
  • Debugging why widgets show stale data
  • Widget not appearing in gallery
  • Interactive buttons not responding
  • Live Activity fails to start
  • Control Center control is unresponsive
  • Sharing data between app and widget/extension
Do NOT use this skill for:
  • Pure App Intents implementation (use app-intents-ref)
  • SwiftUI layout questions (use swiftui-layout)
  • Performance profiling (use swiftui-performance)
  • General debugging (use xcode-debugging)
在以下场景使用
  • 实现任意Widget(主屏幕、锁屏、待机模式、Control Center)
  • 创建Live Activity
  • 调试Widget显示过期数据的问题
  • Widget未出现在组件库中
  • 交互按钮无响应
  • Live Activity无法启动
  • Control Center控件无响应
  • 在应用与Widget/扩展之间共享数据
请勿在以下场景使用
  • 纯App Intents实现(使用app-intents-ref
  • SwiftUI布局问题(使用swiftui-layout
  • 性能分析(使用swiftui-performance
  • 常规调试(使用xcode-debugging

Related Skills

相关技能

  • extensions-widgets-ref — Comprehensive API reference
  • app-intents-ref — App Intents for interactive widgets
  • swift-concurrency — Async patterns for data fetching
  • swiftdata — Using SwiftData with App Groups
  • extensions-widgets-ref —— 完整API参考
  • app-intents-ref —— 用于交互式Widget的App Intents
  • swift-concurrency —— 数据获取的异步模式
  • swiftdata —— 在App Groups中使用SwiftData

Example Prompts

示例提示

1. "My widget isn't updating"

1. “我的Widget不更新”

→ This skill covers timeline policies, refresh budgets, manual reload, and App Groups configuration
→ 本技能涵盖时间线策略、刷新预算、手动重载和App Groups配置

2. "How do I share data between app and widget?"

2. “如何在应用和Widget之间共享数据?”

→ This skill explains App Groups entitlement, shared UserDefaults, and container URLs
→ 本技能讲解App Groups权限、共享UserDefaults和容器URL

3. "Widget shows old data even after I update the app"

3. “即使更新应用后,Widget仍显示旧数据”

→ This skill covers container paths, UserDefaults suite names, and WidgetCenter reload
→ 本技能涵盖容器路径、UserDefaults套件名称和WidgetCenter重载

4. "Live Activity fails to start"

4. “Live Activity无法启动”

→ This skill covers 4KB data limit, ActivityAttributes constraints, authorization checks
→ 本技能涵盖4KB数据限制、ActivityAttributes约束、权限检查

5. "Control Center control takes forever to respond"

5. “Control Center控件响应极慢”

→ This skill covers async ValueProvider patterns and optimistic UI
→ 本技能涵盖异步ValueProvider模式和乐观UI

6. "Interactive widget button does nothing"

6. “交互式Widget按钮无作用”

→ This skill covers App Intent perform() implementation and WidgetCenter reload

→ 本技能涵盖App Intent perform()实现和WidgetCenter重载

Red Flags / Anti-Patterns

危险信号/反模式

Pattern 1: Network Calls in Widget View

模式1:Widget视图中进行网络请求

Time cost: 2-4 hours debugging why widgets are blank or show errors
排查耗时:2-4小时调试Widget空白或显示错误的原因

Symptom

症状

  • Widget renders but shows no data
  • Console errors: "NSURLSession not available in widget extension"
  • Widget appears blank intermittently
  • Widget已渲染但无数据显示
  • 控制台错误:“NSURLSession not available in widget extension”
  • Widget间歇性显示空白

❌ BAD Code

❌ 错误代码

swift
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)
            }
        }
    }
}
Why it fails: Widget views are rendered, archived, and reused. Network calls in views are unreliable and may not execute.
swift
struct MyWidgetView: View {
    @State private var data: String?

    var body: some View {
        VStack {
            if let data = data {
                Text(data)
            }
        }
        .onAppear {
            // ❌ 错误 — 在Widget视图中发起网络请求
            Task {
                let (data, _) = try await URLSession.shared.data(from: apiURL)
                self.data = String(data: data, encoding: .utf8)
            }
        }
    }
}
失败原因:Widget视图会被渲染、存档并复用。视图中的网络请求不可靠,可能无法执行。

✅ GOOD Code

✅ 正确代码

swift
// 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)
    }
}
Pattern: Fetch data in main app, save to shared storage, read in widget.
Can TimelineProvider make network requests?
Yes, but with important caveats:
swift
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))
        }
    }
}
Constraints:
  • 30-second timeout - System kills extension if getTimeline() doesn't complete
  • No background sessions - Can't download large files
  • Battery cost - Every timeline reload uses battery
  • Not guaranteed - May fail on poor connections
Best practice: Prefetch in main app (faster, more reliable), use TimelineProvider network as fallback only.

swift
// 主应用 — 预获取并保存数据
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 — 从共享存储读取数据
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)
    }
}
模式:在主应用中获取数据,保存到共享存储,在Widget中读取。
TimelineProvider可以发起网络请求吗?
可以,但有重要注意事项:
swift
struct Provider: TimelineProvider {
    func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
        Task {
            // ✅ 此处允许发起网络请求
            let data = try await fetchFromAPI()
            let entry = SimpleEntry(date: Date(), data: data)
            completion(Timeline(entries: [entry], policy: .atEnd))
        }
    }
}
约束条件
  • 30秒超时 - 如果getTimeline()未完成,系统会终止扩展
  • 无后台会话 - 无法下载大文件
  • 电池消耗 - 每次时间线重载都会消耗电量
  • 不保证成功 - 网络连接差时可能失败
最佳实践:在主应用中预获取数据(更快、更可靠),仅将TimelineProvider中的网络请求作为备选方案。

Pattern 2: Missing App Groups

模式2:缺失App Groups

Time cost: 1-2 hours debugging why widget shows empty/default data
排查耗时:1-2小时调试Widget显示空/默认数据的原因

Symptom

症状

  • Widget always shows placeholder or default values
  • Changes in main app don't reflect in widget
  • UserDefaults reads return nil in widget
  • Widget始终显示占位符或默认值
  • 主应用中的更改未同步到Widget
  • Widget中读取UserDefaults返回nil

❌ BAD Code

❌ 错误代码

swift
// Main app
UserDefaults.standard.set("Updated", forKey: "myKey")

// Widget extension
let value = UserDefaults.standard.string(forKey: "myKey") // Returns nil!
Why it fails:
UserDefaults.standard
accesses different containers in app vs. extension.
swift
// 主应用
UserDefaults.standard.set("Updated", forKey: "myKey")

// Widget扩展
let value = UserDefaults.standard.string(forKey: "myKey") // 返回nil!
失败原因
UserDefaults.standard
在应用和扩展中访问的是不同的容器。

✅ GOOD Code

✅ 正确代码

swift
// 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"
Verification:
swift
let containerURL = FileManager.default.containerURL(
    forSecurityApplicationGroupIdentifier: "group.com.myapp"
)
print("Shared container: \(containerURL?.path ?? "MISSING")")
// Should print path, not "MISSING"

swift
// 1. 在两个目标中启用App Groups权限:
//    - 主应用目标:Signing & Capabilities → + App Groups → "group.com.myapp"
//    - Widget扩展目标:使用相同的组标识符

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

// 3. Widget扩展
let shared = UserDefaults(suiteName: "group.com.myapp")!
let value = shared.string(forKey: "myKey") // 返回"Updated"
验证
swift
let containerURL = FileManager.default.containerURL(
    forSecurityApplicationGroupIdentifier: "group.com.myapp"
)
print("Shared container: \(containerURL?.path ?? "MISSING")")
// 应打印路径,而非"MISSING"

Pattern 3: Over-Refreshing (Budget Exhaustion)

模式3:过度刷新(预算耗尽)

Time cost: Poor user experience, battery drain, widgets stop updating
影响:用户体验差、电池消耗快、Widget停止更新

Symptom

症状

  • Widget updates frequently at first, then stops
  • Console logs: "Timeline reload budget exhausted"
  • Widget becomes stale after a few hours
  • Widget最初频繁更新,随后停止
  • 控制台日志:“Timeline reload budget exhausted”
  • 数小时后Widget数据过期

❌ BAD Code

❌ 错误代码

swift
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)
}
Why it's bad: System gives 40-70 reloads/day. This approach uses 24 reloads/hour → exhausts budget in 2-3 hours.
swift
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
    var entries: [SimpleEntry] = []

    // ❌ 错误 — 60个条目,间隔1分钟
    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小时内耗尽预算。

✅ GOOD Code

✅ 正确代码

swift
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)
}
Guidelines:
  • 15-60 minute intervals for most widgets
  • 5-15 minutes for time-sensitive data (stocks, sports)
  • Use
    .atEnd
    policy for automatic reload
  • Let system decide optimal refresh based on user engagement

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

    // ✅ 正确 — 8个条目,间隔15分钟(覆盖2小时)
    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)
}
指导原则
  • 大多数Widget使用15-60分钟的间隔
  • 时间敏感数据(股票、体育)使用5-15分钟间隔
  • 使用
    .atEnd
    策略实现自动重载
  • 让系统根据用户互动情况决定最佳刷新时机

Pattern 4: Blocking Main Thread in Controls

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

Time cost: Control Center control unresponsive, poor UX
影响:Control Center控件无响应、用户体验差

Symptom

症状

  • Tapping control in Control Center shows spinner for seconds
  • Control seems "stuck" or frozen
  • No immediate visual feedback
  • 点击Control Center中的控件后显示加载指示器数秒
  • 控件似乎“卡住”或冻结
  • 无即时视觉反馈

❌ BAD Code

❌ 错误代码

swift
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")
            }
        }
    }
}
Why it's bad: Button renders on main thread. Blocking network/database calls freeze UI.
swift
struct ThermostatControl: ControlWidget {
    var body: some ControlWidgetConfiguration {
        StaticControlConfiguration(kind: "Thermostat") {
            ControlWidgetButton(action: GetTemperatureIntent()) {
                // ❌ 错误 — 同步获取阻塞UI
                let temp = HomeManager.shared.currentTemperature() // 阻塞调用
                Label("\(temp)°", systemImage: "thermometer")
            }
        }
    }
}
问题所在:按钮在主线程上渲染。阻塞性的网络/数据库调用会冻结UI。

✅ GOOD Code

✅ 正确代码

swift
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")
            }
        }
    }
}
Pattern: Use
ControlValueProvider
for async data, provide instant
previewValue
fallback.

swift
struct ThermostatProvider: ControlValueProvider {
    func currentValue() async throws -> ThermostatValue {
        // ✅ 正确 — 异步获取,无阻塞
        let temp = try await HomeManager.shared.fetchTemperature()
        return ThermostatValue(temperature: temp)
    }

    var previewValue: ThermostatValue {
        ThermostatValue(temperature: 72) // 即时备选值
    }
}

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
备选方案。

Pattern 5: Missing Dismissal Policy (Zombie Live Activities)

模式5:缺失关闭策略(僵尸Live Activity)

Time cost: User annoyance, negative reviews
影响:用户不满、负面评价

Symptom

症状

  • Live Activities stay on Lock Screen for hours after event ends
  • Users must manually dismiss completed activities
  • Activity shows "Delivered" but won't disappear
  • Live Activity在事件结束后仍在锁屏上停留数小时
  • 用户必须手动关闭已完成的Activity
  • Activity显示“已送达”但不会消失

❌ BAD Code

❌ 错误代码

swift
// Start activity
let activity = try Activity.request(attributes: attributes, content: initialContent)

// Later... event completes
// ❌ WRONG — Never call .end()
// Activity stays forever until user dismisses
Why it's bad: Activities persist indefinitely unless explicitly ended.
swift
// 启动Activity
let activity = try Activity.request(attributes: attributes, content: initialContent)

// 稍后...事件完成
// ❌ 错误 — 从未调用.end()
// Activity会一直保留,直到用户手动关闭
问题所在:除非显式结束,否则Activity会无限期存在。

✅ GOOD Code

✅ 正确代码

swift
// 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))
Best practices:
  • .immediate
    — Transient events (timer completed, song finished)
  • .default
    — Most activities (shows "completed" state for ~4 hours)
  • .after(date)
    — Specific end time (meeting ends, flight lands)

swift
// 事件完成时
let finalState = DeliveryAttributes.ContentState(
    status: .delivered,
    deliveredAt: Date()
)

await activity.end(
    ActivityContent(state: finalState, staleDate: nil),
    dismissalPolicy: .default // 约4小时后移除
)

// 或立即移除
await activity.end(nil, dismissalPolicy: .immediate)

// 或在特定时间移除
let dismissTime = Date().addingTimeInterval(30 * 60) // 30分钟
await activity.end(nil, dismissalPolicy: .after(dismissTime))
最佳实践
  • .immediate
    — 临时事件(计时器完成、歌曲结束)
  • .default
    — 大多数Activity(显示“已完成”状态约4小时)
  • .after(date)
    — 特定结束时间(会议结束、航班降落)

Pattern 6: Exceeding 4KB Data Limit (Live Activities)

模式6:超过4KB数据限制(Live Activity)

Time cost: Activity fails to start silently, hard to debug
排查耗时:Activity无声启动失败,难以调试

Symptom

症状

  • Activity.request()
    throws error
  • Console: "Activity attributes exceed size limit"
  • Activity never appears on Lock Screen
  • Activity.request()
    抛出错误
  • 控制台:“Activity attributes exceed size limit”
  • Activity从未出现在锁屏上

❌ BAD Code

❌ 错误代码

swift
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)
Why it fails: ActivityAttributes + ContentState combined must be < 4KB.
swift
struct GameAttributes: ActivityAttributes {
    struct ContentState: Codable, Hashable {
        var teamALogo: Data // ❌ 大图片数据
        var teamBLogo: Data
        var playByPlay: [String] // ❌ 无界数组
        var statistics: [String: Any] // ❌ 大字典
    }

    var gameID: String
    var venueName: String
}

// 如果总大小>4KB则失败
let activity = try Activity.request(attributes: attrs, content: content)
失败原因:ActivityAttributes + ContentState的总大小必须<4KB。

✅ GOOD Code

✅ 正确代码

swift
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)")
            // ...
        }
    }
}
Strategies:
  • Store IDs/references, not full objects
  • Use asset catalogs for images (not embedded Data)
  • Keep ContentState minimal (only changeable data)
  • Use computed properties in views for derived data
swift
struct GameAttributes: ActivityAttributes {
    struct ContentState: Codable, Hashable {
        var teamAScore: Int // ✅ 小原始类型
        var teamBScore: Int
        var quarter: Int
        var timeRemaining: String // "2:34"
        var lastPlay: String? // 仅最近一次赛事
    }

    var gameID: String // ✅ 引用,而非完整数据
    var teamAName: String
    var teamBName: String
}

// 在视图中使用资源目录获取图片
struct GameLiveActivityView: View {
    var context: ActivityViewContext<GameAttributes>

    var body: some View {
        HStack {
            Image(context.attributes.teamAName) // 资源目录
            Text("\(context.state.teamAScore)")
            // ...
        }
    }
}
策略
  • 存储ID/引用,而非完整对象
  • 使用资源目录存储图片(而非嵌入Data)
  • 保持ContentState最小化(仅包含可更改数据)
  • 在视图中使用计算属性获取派生数据

Size Targets (Safety Margins)

大小目标(安全边际)

Hard limit: 4096 bytes (4KB)
Target guidance:
  • < 2KB: Safe with room to grow - recommended for v1.0
  • ⚠️ 2-3KB: Acceptable but monitor closely as you add features
  • 🔴 3.5KB+: Risky - future fields may push you over limit
Why safety margins matter: You'll add fields later (new features, more data). Starting at 3.8KB leaves zero room for growth.
Checking size:
swift
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")
    }
}
Optimization priorities (when over 2KB):
  1. Replace
    String
    descriptions with enums (if fixed set)
  2. Shorten string values ("Team A" → "A")
  3. Use smaller types (Int → Int8 if range allows)
  4. Remove optional fields that are rarely used

硬限制:4096字节(4KB)
目标指导
  • <2KB:安全且有扩展空间 - 推荐v1.0版本使用
  • ⚠️ 2-3KB:可接受,但添加功能时需密切监控
  • 🔴 3.5KB+:有风险 - 未来新增字段可能导致超出限制
安全边际的重要性:后续会添加字段(新功能、更多数据)。从3.8KB开始没有任何增长空间。
检查大小
swift
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")
    }
}

Pattern 7: Widget Not Appearing in Gallery

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

Time cost: 30 minutes debugging invisible widget
排查耗时:30分钟调试不可见的Widget

Symptom

症状

  • Widget builds successfully
  • No errors in console
  • Widget doesn't appear in widget picker/gallery
  • Can't add to Home Screen
  • Widget构建成功
  • 控制台无错误
  • Widget未出现在Widget选择器/组件库中
  • 无法添加到主屏幕

❌ BAD Code

❌ 错误代码

swift
@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!
    }
}
Why it fails: Without supportedFamilies(), system doesn't know which sizes to offer.
swift
@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() — Widget不会显示!
    }
}
失败原因:没有supportedFamilies(),系统不知道要提供哪些尺寸。

✅ GOOD Code

✅ 正确代码

swift
@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
    }
}
Other common causes:
  • Widget target's "Skip Install" set to YES (should be NO)
  • Widget extension not added to app's "Embed App Extensions"
  • Clean build folder needed (
    Cmd+Shift+K
    )

swift
@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]) // ✅ 必填
    }
}
其他常见原因
  • Widget目标的“Skip Install”设置为YES(应设为NO)
  • Widget扩展未添加到应用的“Embed App Extensions”中
  • 需要清理构建文件夹(
    Cmd+Shift+K

Decision Tree

决策树

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?

Widget/扩展问题?
├─ Widget未出现在组件库中?
│  ├─ 检查@main中是否注册了WidgetBundle
│  ├─ 验证supportedFamilies()包含所需尺寸
│  └─ 清理构建文件夹,重启Xcode
├─ Widget不更新?
│  ├─ 时间线策略设为.never?
│  │  └─ 改为.atEnd或.after(date)
│  ├─ 预算耗尽?(刷新过于频繁)
│  │  └─ 增加条目间隔(15-60分钟)
│  └─ 手动重载
│     └─ WidgetCenter.shared.reloadAllTimelines()
├─ Widget显示空/旧数据?
│  ├─ 主应用和扩展目标都启用了App Groups?
│  │  ├─ 否 → 添加“App Groups”权限
│  │  └─ 是 → 验证组ID相同
│  ├─ 使用的是UserDefaults.standard?
│  │  └─ 改为UserDefaults(suiteName: "group.com.myapp")
│  └─ 共享容器路径正确?
│     └─ 打印containerURL,验证不为nil
├─ 交互按钮不工作?
│  ├─ App Intent perform()返回值?
│  │  └─ 必须返回IntentResult
│  ├─ perform()更新了共享数据?
│  │  └─ 更新App Group存储
│  └─ 是否调用了WidgetCenter.reloadTimelines()?
│     └─ 重载以反映更改
├─ Live Activity无法启动?
│  ├─ 数据大小>4KB?
│  │  └─ 减小ActivityAttributes + ContentState
│  ├─ 权限已启用?
│  │  └─ 检查ActivityAuthorizationInfo().areActivitiesEnabled
│  └─ pushType正确?
│     └─ 本地更新设为nil,推送设为.token
├─ Control Center控件无响应?
│  ├─ 异步操作阻塞UI?
│  │  └─ 使用带async currentValue()的ControlValueProvider
│  └─ 提供previewValue作为即时备选
└─ watchOS Live Activity未显示?
   ├─ supplementalActivityFamilies包含.small?
   └─ Apple Watch已配对且在范围内?

Mandatory First Steps

强制初始步骤

Before debugging any widget or extension issue, complete this checklist:
在调试任何Widget或扩展问题之前,请完成以下检查清单:

Widget Debugging Checklist

Widget调试检查清单

  • App Groups enabled in BOTH main app AND extension targets
    bash
    # Verify entitlements
    codesign -d --entitlements - /path/to/YourApp.app
    # Should show com.apple.security.application-groups
  • Widget in Widget Gallery (not just on Home Screen)
    • Long-press Home Screen → + button → Find your widget
    • Verify it appears with correct name and description
  • Console logs for timeline errors
    bash
    # Xcode Console
    # Filter: "widget" OR "timeline"
    # Look for: "Timeline reload failed", "Budget exhausted"
  • Manual reload test
    swift
    WidgetCenter.shared.reloadAllTimelines()
    • If this fixes it → problem is timeline policy or refresh budget
  • Shared container accessible
    swift
    let container = FileManager.default.containerURL(
        forSecurityApplicationGroupIdentifier: "group.com.myapp"
    )
    print("Container: \(container?.path ?? "NIL")")
    // Must print valid path, not "NIL"
  • 主应用和扩展目标都启用了App Groups
    bash
    # 验证权限
    codesign -d --entitlements - /path/to/YourApp.app
    # 应显示com.apple.security.application-groups
  • Widget在组件库中可见(不仅在主屏幕上)
    • 长按主屏幕 → +按钮 → 找到你的Widget
    • 验证它以正确的名称和描述显示
  • 查看时间线错误的控制台日志
    bash
    # Xcode控制台
    # 筛选:"widget" OR "timeline"
    # 查找:"Timeline reload failed", "Budget exhausted"
  • 手动重载测试
    swift
    WidgetCenter.shared.reloadAllTimelines()
    • 如果此操作解决了问题 → 问题出在时间线策略或刷新预算
  • 共享容器可访问
    swift
    let container = FileManager.default.containerURL(
        forSecurityApplicationGroupIdentifier: "group.com.myapp"
    )
    print("Container: \(container?.path ?? "NIL")")
    // 必须打印有效路径,而非"NIL"

Live Activity Debugging Checklist

Live Activity调试检查清单

  • ActivityAttributes < 4KB
    swift
    let encoded = try JSONEncoder().encode(attributes)
    print("Size: \(encoded.count) bytes") // Must be < 4096
  • Authorization check
    swift
    let authInfo = ActivityAuthorizationInfo()
    print("Enabled: \(authInfo.areActivitiesEnabled)")
  • pushType matches server integration
    • nil
      → local updates only
    • .token
      → expects push notifications
  • Dismissal policy implemented
    • Every activity.end() must specify policy
  • ActivityAttributes <4KB
    swift
    let encoded = try JSONEncoder().encode(attributes)
    print("Size: \(encoded.count) bytes") // 必须<4096
  • 权限检查
    swift
    let authInfo = ActivityAuthorizationInfo()
    print("Enabled: \(authInfo.areActivitiesEnabled)")
  • pushType与服务器集成匹配
    • nil
      → 仅本地更新
    • .token
      → 期望推送通知
  • 实现了关闭策略
    • 每个activity.end()必须指定策略

Control Center Widget Checklist

Control Center Widget检查清单

  • ControlValueProvider for async data
  • previewValue provides instant fallback
  • App Intent perform() is async
  • No blocking network/database calls in views

  • 使用ControlValueProvider处理异步数据
  • previewValue提供即时备选
  • App Intent perform()是异步的
  • 视图中无阻塞性网络/数据库调用

Pressure Scenarios

压力场景

Scenario 1: "Widget shows wrong data in production"

场景1:“生产环境中Widget显示错误数据”

Situation

情况

  • App released to App Store
  • Users report widget displaying incorrect/stale information
  • Works fine in development
  • 应用已发布到App Store
  • 用户反馈Widget显示不正确/过期信息
  • 开发环境中工作正常

Pressure Signals

压力信号

  • 🚨 App Store reviews — 1-star reviews mentioning broken widget
  • Time pressure — Need hotfix ASAP
  • 👔 Executive visibility — Management asking for status updates
  • 🚨 App Store评价 — 1星评价提到Widget故障
  • 时间压力 — 需要紧急修复
  • 👔 管理层关注 — 管理层询问状态更新

Rationalization Traps (DO NOT)

合理化陷阱(请勿尝试)

  1. "Just force a timeline reload more often"
    • Why it fails: Exhausts budget, makes problem worse
  2. "The widget worked in testing"
    • Why it fails: Development vs. production App Groups mismatch
  3. "Users should just restart their phone"
    • Why it fails: Not a fix, damages reputation
  1. “只需更频繁地强制时间线重载”
    • 失败原因:耗尽预算,使问题更严重
  2. “测试中Widget工作正常”
    • 失败原因:开发环境与生产环境的App Groups不匹配
  3. “用户只需重启手机”
    • 失败原因:这不是修复方案,会损害声誉

MANDATORY Systematic Fix

强制系统修复方案

Step 1: Verify App Groups (30 min)

步骤1:验证App Groups(30分钟)

swift
// 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")
Verify: Run app, then widget. Both should print SAME container path.
swift
// 在应用和Widget中添加日志
let group = "group.com.myapp.production" // 必须完全匹配
let container = FileManager.default.containerURL(
    forSecurityApplicationGroupIdentifier: group
)

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

// 记录所有读写操作
let shared = UserDefaults(suiteName: group)!
print("Writing key 'lastUpdate' = \(Date())")
shared.set(Date(), forKey: "lastUpdate")
验证:运行应用,然后运行Widget。两者应打印相同的容器路径。

Step 2: Check Container Paths

步骤2:检查容器路径

bash
undefined
bash
undefined

Device logs (Xcode → Window → Devices and Simulators → View Device Logs)

设备日志(Xcode → Window → Devices and Simulators → View Device Logs)

Filter: Your app bundle ID

筛选:你的应用Bundle ID

Look for: Container path mismatches

查找:容器路径不匹配


Common issues:
- App uses `group.com.myapp.dev`
- Widget uses `group.com.myapp.production`
- **Fix**: Ensure EXACT same group ID in both .entitlements files

常见问题:
- 应用使用`group.com.myapp.dev`
- Widget使用`group.com.myapp.production`
- **修复**:确保两个.entitlements文件使用完全相同的组标识符

Step 3: Add Version Stamp

步骤3:添加版本标记

swift
// 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)")
}
swift
// 主应用 — 每次写入时添加标记
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 — 验证版本
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)")
}

Step 4: Force Reload on App Launch

步骤4:应用启动时强制重载

swift
// AppDelegate / @main App
func applicationDidBecomeActive(_ application: UIApplication) {
    WidgetCenter.shared.reloadAllTimelines()
}
swift
// AppDelegate / @main App
func applicationDidBecomeActive(_ application: UIApplication) {
    WidgetCenter.shared.reloadAllTimelines()
}

Communication Template

沟通模板

To stakeholders:
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)
致利益相关者
状态:正在调查Widget数据同步问题

根本原因:生产构建中应用与Widget扩展的App Groups配置不匹配

修复方案:更新两个目标以使用相同的组标识符,添加日志以防止复发

时间线:紧急修复已提交到App Store审核(24-48小时)

用户临时解决方案:强制退出应用并重新启动(触发Widget刷新)

Time Saved

节省时间

  • Without systematic fix: 4-8 hours of trial-and-error, multiple resubmissions
  • With this process: 1-2 hours to identify, fix, and verify

  • 无系统修复:4-8小时的反复尝试,多次重新提交
  • 使用此流程:1-2小时内识别、修复并验证

Scenario 2: "Live Activity must update instantly"

场景2:“Live Activity必须即时更新”

Situation

情况

  • Sports score app
  • Users expect scores to update within seconds of real game events
  • Current timeline-based approach too slow
  • 体育比分应用
  • 用户期望比分在真实赛事发生后几秒内更新
  • 当前基于时间线的方法太慢

Pressure

压力

  • Competitive: "Other apps update faster"
  • Deadline: Marketing promised "real-time" updates
  • 竞争:“其他应用更新更快”
  • 截止日期:营销部门承诺“实时”更新

Rationalization Traps (DO NOT)

合理化陷阱(请勿尝试)

  1. "Just create entries every 5 seconds"
    • Why it fails: Not real-time, exhausts battery, doesn't scale
  2. "Add WebSocket to widget view"
    • Why it fails: Extensions can't maintain persistent connections
  3. "Lower refresh interval to 1 second"
    • Why it fails: Timeline system not designed for sub-minute updates
  1. “只需每5秒创建一个条目”
    • 失败原因:不是实时的,消耗电池,无法扩展
  2. “在Widget视图中添加WebSocket”
    • 失败原因:扩展无法维持持久连接
  3. “将刷新间隔降低到1秒”
    • 失败原因:时间线系统并非为亚分钟级更新设计

MANDATORY Solution: Phased Approach

强制解决方案:分阶段方法

Critical reality check: Push notification entitlement approval takes 3-7 days. Never promise features before approval.
关键现实检查:推送通知权限审批需要3-7天。在获得审批前切勿承诺功能。

Phase 1: Ship with Local Updates (No Approval Required)

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

Ship immediately with app-driven updates:
swift
// 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
))
Set expectations: Updates occur when user interacts with app. This is acceptable for v1.0 and requires zero approval.
立即发布应用驱动的更新:
swift
// 启动Activity时不使用推送(无需权限)
let activity = try Activity.request(
    attributes: attributes,
    content: initialContent,
    pushType: nil  // 仅本地更新
)

// 数据变化时在应用中更新(用户打开应用、下拉刷新)
await activity.update(ActivityContent(
    state: updatedState,
    staleDate: nil
))
设定预期:更新在用户与应用互动时发生。这对于v1.0版本是可接受的,且无需任何审批。

Phase 2: Add Push After Approval (3-7 Days)

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

After entitlement approved, switch to push:
权限获批后,切换到推送:

Step 1: Enable Push for Live Activities

步骤1:为Live Activity启用推送

swift
// 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)
    }
}
swift
// 1. 权限:"com.apple.developer.activity-push-notification"

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

// 3. 监控令牌
Task {
    for await pushToken in activity.pushTokenUpdates {
        let tokenString = pushToken.map { String(format: "%02x", $0) }.joined()
        await sendTokenToServer(activityID: activity.id, token: tokenString)
    }
}

Step 2: Server-Side Push (Phase 2 Only)

步骤2:服务器端推送(仅阶段2)

json
{
  "aps": {
    "timestamp": 1633046400,
    "event": "update",
    "content-state": {
      "teamAScore": 14,
      "teamBScore": 10,
      "quarter": 2,
      "timeRemaining": "5:23"
    },
    "alert": {
      "title": "Touchdown!",
      "body": "Team A scores"
    }
  }
}
Standard push limit: ~10-12 per hour
json
{
  "aps": {
    "timestamp": 1633046400,
    "event": "update",
    "content-state": {
      "teamAScore": 14,
      "teamBScore": 10,
      "quarter": 2,
      "timeRemaining": "5:23"
    },
    "alert": {
      "title": "达阵!",
      "body": "A队得分"
    }
  }
}
标准推送限制:约每小时10-12次

Step 3: Request Frequent Updates Entitlement (Phase 2, iOS 18.2+)

步骤3:申请频繁更新权限(阶段2,iOS 18.2+)

For apps requiring more frequent pushes (sports, stocks):
xml
<key>com.apple.developer.activity-push-notification-frequent-updates</key>
<true/>
Requires justification in App Store Connect: "Live sports scores require immediate updates for user engagement"
对于需要更频繁推送的应用(体育、股票):
xml
<key>com.apple.developer.activity-push-notification-frequent-updates</key>
<true/>
需要在App Store Connect中说明理由:“实时体育比分需要即时更新以提升用户参与度”

Verification

验证

swift
// 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
swift
// 在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

Communication Template

沟通模板

To marketing/exec (Phase 1):
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.
To marketing/exec (Phase 2):
"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)
发布时间线:
- 阶段1(立即):带应用驱动更新的Live Activity。更新在用户打开应用或下拉刷新时显示。
- 阶段2(3-7天):获得Apple审批后集成推送通知。服务器事件发生后1-3秒内更新送达。

建议:发布阶段1并推向市场,获批后将阶段2宣传为“即将推出”。
致营销/管理层(阶段2)
“实时”定位需要澄清:

技术层面:Live Activity通过推送通知更新,从服务器到设备的延迟为1-3秒

约束条件:Apple的推送系统有速率限制(标准约每小时10次,特殊权限下更高)

竞争分析:竞争对手可能使用相同系统,具有类似限制

建议:定位为“近实时”(准确)而非“即时”(误导)

Reality Check

现实检查

  • Push notifications are fastest mechanism available
  • 1-3 second latency is normal
  • Budget limits exist for battery optimization
  • Users prefer longer battery life over millisecond-faster scores

  • 推送通知是可用的最快机制
  • 1-3秒的延迟是正常的
  • 存在预算限制以优化电池寿命
  • 用户更偏好更长的电池寿命,而非毫秒级更快的比分

Scenario 3: "Control Center control is slow"

场景3:“Control Center控件响应慢”

Situation

情况

  • Smart home control for lights
  • Tapping control in Control Center takes 3-5 seconds to respond
  • Users expect instant feedback
  • 智能家居灯光控制
  • 点击Control Center中的控件需要3-5秒响应
  • 用户期望即时反馈

MANDATORY Fix: Optimistic UI + Async Value Provider

强制修复方案:乐观UI + 异步Value Provider

Problem Code

问题代码

swift
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")
            }
        }
    }
}
swift
struct LightControl: ControlWidget {
    var body: some ControlWidgetConfiguration {
        StaticControlConfiguration(kind: "Light") {
            ControlWidgetToggle(
                isOn: LightManager.shared.isOn, // ❌ 阻塞性获取
                action: ToggleLightIntent()
            ) { isOn in
                Label(isOn ? "On" : "Off", systemImage: "lightbulb.fill")
            }
        }
    }
}

Fixed Code

修复后代码

swift
// 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)
            }
        }
    }
}
Result: Control responds instantly with cached state, actual device updates in background.

swift
// 1. 用于异步状态的Value Provider
struct LightProvider: ControlValueProvider {
    func currentValue() async throws -> LightValue {
        // 从HomeKit/服务器异步获取
        let isOn = try await HomeManager.shared.fetchLightState()
        return LightValue(isOn: isOn)
    }

    var previewValue: LightValue {
        // 从缓存获取即时备选值
        let shared = UserDefaults(suiteName: "group.com.myapp")!
        return LightValue(isOn: shared.bool(forKey: "lastKnownLightState"))
    }
}

struct LightValue: ControlValueProviderValue {
    var isOn: Bool
}

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

    func perform() async throws -> some IntentResult {
        // 立即更新缓存(乐观)
        let shared = UserDefaults(suiteName: "group.com.myapp")!
        let currentState = shared.bool(forKey: "lastKnownLightState")
        let newState = !currentState
        shared.set(newState, forKey: "lastKnownLightState")

        // 然后异步更新实际设备
        try await HomeManager.shared.setLight(isOn: newState)

        return .result()
    }
}

// 3. 使用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 ? "开启" : "关闭", systemImage: "lightbulb.fill")
                    .tint(isOn ? .yellow : .gray)
            }
        }
    }
}
结果:控件立即以缓存状态响应,实际设备在后台更新。

Final Checklist

最终检查清单

Before shipping widgets or Live Activities:
在发布Widget或Live Activity之前:

Pre-Release

发布前

  • ☐ App Groups entitlement in BOTH targets (app + extension)
  • ☐ Shared UserDefaults uses
    suiteName
    (not
    .standard
    )
  • ☐ Timeline entries ≥ 5 minutes apart (avoid budget exhaustion)
  • ☐ No network calls in widget views (only in TimelineProvider)
  • ☐ ActivityAttributes + ContentState < 4KB
  • ☐ Live Activities call
    .end()
    with appropriate dismissal policy
  • ☐ Control Center controls use ControlValueProvider for async data
  • ☐ Tested on actual device (not just simulator) — Required because:
    • Simulator doesn't enforce timeline budget limits
    • Push notifications don't work in simulator
    • App Groups container paths differ (simulator vs device)
    • Memory limits not enforced in simulator
    • Background refresh behavior different
  • ☐ Tested all supported widget families
  • ☐ Verified widget appears in Widget Gallery
  • ☐ 主应用和扩展目标都启用了App Groups权限
  • ☐ 共享UserDefaults使用
    suiteName
    (而非
    .standard
  • ☐ 时间线条目间隔≥5分钟(避免预算耗尽)
  • ☐ Widget视图中无网络请求(仅在TimelineProvider中)
  • ☐ ActivityAttributes + ContentState <4KB
  • ☐ Live Activity调用
    .end()
    并使用适当的关闭策略
  • ☐ Control Center控件使用ControlValueProvider处理异步数据
  • ☐ 在真实设备上测试(不仅是模拟器)—— 必须,因为:
    • 模拟器不强制执行时间线预算限制
    • 模拟器中推送通知无法工作
    • App Groups容器路径不同(模拟器 vs 设备)
    • 模拟器中不强制执行内存限制
    • 后台刷新行为不同
  • ☐ 测试所有支持的Widget尺寸
  • ☐ 验证Widget出现在组件库中

Post-Release Monitoring

发布后监控

  • ☐ Monitor for "Timeline reload budget exhausted" errors
  • ☐ Track widget data staleness in analytics
  • ☐ Watch App Store reviews for widget-related complaints
  • ☐ Log App Group container access for debugging
  • ☐ 监控“Timeline reload budget exhausted”错误
  • ☐ 在分析中跟踪Widget数据过期情况
  • ☐ 关注App Store评价中与Widget相关的投诉
  • ☐ 记录App Group容器访问情况以用于调试

Common Failure Modes

常见失败模式

  • Missing App Groups → Widget shows default data
  • Wrong group ID → App and widget can't communicate
  • Over-refreshing → Widget stops updating after hours
  • Network in view → Widget renders blank
  • No dismissal policy → Zombie Live Activities
  • Blocking main thread → Unresponsive controls

Remember: Widgets are NOT mini apps. They're glanceable snapshots rendered by the system. Extensions run in sandboxed environments with strict resource limits. Follow the patterns in this skill to avoid the most common pitfalls.
  • 缺失App Groups → Widget显示默认数据
  • 错误的组ID → 应用与Widget无法通信
  • 过度刷新 → 数小时后Widget停止更新
  • 视图中发起网络请求 → Widget渲染空白
  • 无关闭策略 → 僵尸Live Activity
  • 阻塞主线程 → 控件无响应

请记住:Widget并非迷你应用。它们是由系统渲染的概览快照。扩展运行在沙箱环境中,具有严格的资源限制。遵循本技能中的模式,以避免最常见的陷阱。