Add configuration and intelligence to your widgets
Swift & UI 进阶 26m

为小组件添加配置与智能功能

Add configuration and intelligence to your widgets

2020年6月26日

在 Apple 官方观看视频

一句话判断

Widget 不只是静态信息卡片——加上 IntentConfigurationTimelineProvider 的智能调度,你的 Widget 可以变成”最懂用户”的桌面小组件。

这场 Session 讲了什么

这场 Session 是对 iOS 14 Widget(WidgetKit)体系的进阶讲解,聚焦在两个核心能力:配置(Configuration)和智能调度(Intelligence)。配置让用户可以在 Widget 上自定义显示内容——比如选择要展示的城市天气、选择要跟踪的股票。智能调度让系统在最佳时机刷新 Widget 内容——比如在用户通常查看天气的早晨自动更新天气 Widget。

配置基于 IntentConfiguration,复用了 SiriKit 的 Intent 系统。你定义一个 Custom Intent,系统会自动生成一个配置界面。用户在长按 Widget 时可以进入配置模式,选择参数后 Widget 立即更新。这种设计不需要你自己写配置 UI——系统帮你搞定。

智能调度通过 TimelineProvidergetTimeline() 方法实现。你向系统提供一个 TimelineEntry 序列,每个 entry 带有一个日期。系统会在你指定的日期自动刷新 Widget。Session 强调了”智能”的含义:你可以根据用户的使用习惯来安排 entry 的时间点——如果用户通常在早上 8 点查看天气,就把最准确的天气 entry 放在 8 点。

值得深挖的点

Timeline 的策略设计

Timeline 不是”每分钟刷新一次”这么简单。系统有预算限制——你的 Widget 每天的刷新次数是有限的。Session 建议采用”重要时刻密集,其他时刻稀疏”的策略。比如股市 Widget:交易时间内每分钟一个 entry,闭市后每小时间隔越来越大(因为价格不再变化)。这样你把有限的刷新预算用在了最需要精确数据的时段。

Reload vs Timeline 的区别

reloadTimelines(ofKind:) 会触发系统重新调用 getTimeline(),生成全新的 entry 序列。这适用于数据发生了重大变化(如用户切换了城市)。reloadAllTimelines() 刷新所有 Widget。Session 提醒:不要频繁调用 reload——每次调用都消耗系统预算。最好通过 getTimeline() 中的 future entries 来预判数据变化,减少 reload 的需求。

代码片段

import WidgetKit
import SwiftUI
import Intents

// 可配置的天气 Widget
struct WeatherWidget: Widget {
    let kind: String = "WeatherWidget"
    
    var body: some WidgetConfiguration {
        IntentConfiguration(
            kind: kind,
            intent: CitySelectionIntent.self,  // 自定义 Intent
            provider: WeatherTimelineProvider()
        ) { entry in
            WeatherWidgetEntryView(entry: entry)
        }
        .configurationDisplayName("天气")
        .description("显示指定城市的天气信息")
        .supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
    }
}

// Timeline Provider:提供智能调度
struct WeatherTimelineProvider: IntentTimelineProvider {
    typealias Entry = WeatherEntry
    typealias Intent = CitySelectionIntent
    
    func placeholder(in context: Context) -> WeatherEntry {
        WeatherEntry(
            date: Date(),
            city: "北京",
            temperature: 25,
            condition: .sunny
        )
    }
    
    func getSnapshot(for configuration: CitySelectionIntent,
                      in context: Context,
                      completion: @escaping (WeatherEntry) -> Void) {
        // Widget Gallery 中展示的预览
        let city = configuration.city ?? "北京"
        completion(WeatherEntry(
            date: Date(), city: city, temperature: 25, condition: .sunny
        ))
    }
    
    func getTimeline(for configuration: CitySelectionIntent,
                      in context: Context,
                      completion: @escaping (Timeline<WeatherEntry>) -> Void) {
        let city = configuration.city ?? "北京"
        let now = Date()
        
        // 生成未来 12 小时的 entry 序列
        var entries: [WeatherEntry] = []
        for hourOffset in 0..<12 {
            let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: now)!
            entries.append(WeatherEntry(
                date: entryDate,
                city: city,
                temperature: predictTemperature(at: entryDate),
                condition: predictCondition(at: entryDate)
            ))
        }
        
        // 关键:告诉系统什么时候来取新的 timeline
        let nextUpdate = Calendar.current.date(byAdding: .hour, value: 12, to: now)!
        let timeline = Timeline(entries: entries, policy: .after(nextUpdate))
        completion(timeline)
    }
    
    func predictTemperature(at date: Date) -> Int {
        // 简化的温度预测逻辑
        let hour = Calendar.current.component(.hour, from: date)
        return 20 + (hour > 12 ? (24 - hour) : hour)  // 正午最高
    }
    
    func predictCondition(at date: Date) -> WeatherCondition {
        return .sunny  // 简化
    }
}
// Widget Entry 和 View
struct WeatherEntry: TimelineEntry {
    let date: Date
    let city: String
    let temperature: Int
    let condition: WeatherCondition
}

enum WeatherCondition {
    case sunny, cloudy, rainy
}

struct WeatherWidgetEntryView: View {
    let entry: WeatherEntry
    
    var body: some View {
        VStack(spacing: 8) {
            Text(entry.city)
                .font(.headline)
            
            Text("\(entry.temperature)°")
                .font(.system(size: 48, weight: .bold, design: .rounded))
            
            Text(conditionText)
                .font(.caption)
                .foregroundColor(.secondary)
        }
        .padding()
    }
    
    var conditionText: String {
        switch entry.condition {
        case .sunny: return "晴天"
        case .cloudy: return "多云"
        case .rainy: return "下雨"
        }
    }
}
// 在主应用中触发 Widget 刷新
import WidgetKit

class WeatherApp {
    func refreshWidget() {
        // 当主应用获取到新的天气数据时,刷新 Widget
        WidgetCenter.shared.reloadTimelines(ofKind: "WeatherWidget")
    }
    
    // 获取所有 Widget 的信息
    func inspectWidgets() {
        WidgetCenter.shared.getCurrentConfigurations { widgetInfos in
            guard case .success(let infos) = widgetInfos else { return }
            for info in infos {
                print("Widget: \(info.kind), Family: \(info.family)")
            }
        }
    }
}

最佳实践

  • Timeline entry 的密度要与数据变化的频率匹配:交易时段密集,非活跃时段稀疏
  • 使用 .after(date) 策略告诉系统何时刷新,而不是 .atEnd(后者可能导致刷新频率失控)
  • getSnapshot() 中提供有意义的数据,这是 Widget Gallery 中的第一印象
  • 用户配置的 Intent 参数会持久化,不需要在每次 timeline 请求时重新获取
  • 测试 Timeline 策略时使用 Xcode 的 Widget Simulator,可以看到 entry 的时间线分布

还有什么值得关注

  • Widget 的刷新预算由系统管理,具体次数不公开——Apple 说”足够大多数应用使用”
  • WidgetCenter 还提供了 reloadAllTimelines() 方法,适合数据模型发生全局变化时使用
  • Session 提到 Stack Widget(智能叠放)会根据用户习惯自动轮换不同的 Widget,你的 Widget 需要为 isSnapshot 场景优化渲染速度
WWDC 2020