iOS 桌面可交互Widget(小组件)开发指南

333 阅读11分钟

文档需要在实践的基础上理解 建议先创建一个Demo尝试后理解 项目地址 MakeAweSomeWidget

目前仅包含桌面小组件,关于灵动岛及liveActivity的开发后续再进行补充。 灵动岛和LiveActivity可先参考SwiftUIWidgetDemo

提前鸣谢

WidgetKit

网易iOS小组件开发指南

iOS小组件开发全面总结

整体概述

基础概念

小组件从 App 提取及时且与个人相关的少量信息,将其显示在用户一眼就能看到的地方,并在不启动 App 的情况下提供特定的 App 功能。在 iPhone 和 iPad 上,用户可以将小组件放在今天视图、主屏幕和锁定屏幕。在 Mac 上,用户可以将原生 Mac App 小组件放在桌面和通知中心。从 iOS 17 和 macOS 14 开始,用户还可以将 iPhone 小组件放在 Mac 桌面和通知中心。在 Apple Watch 上,小组件显示在智能叠放中。

visionOS 不会载入兼容的 iPad 和 iPhone App 中的 WidgetKit 扩展。

本文目前仅介绍桌面小组件,其他涉及灵动岛、Live Activity、小组件等后续若有开发需求将进行补充。

交互和小组件设计

小组件的开发必须使用SwiftUI框架,这是苹果第一次强制要求使用特定UI框架开发系统功能。同时依赖的核心框架是WidgetKit,用于处理小组件的生命周期、数据更新和系统交互。

iOS14与iOS17核心差异对比

特性iOS14iOS17
交互能力点击只能打开主App支持Button和Toggle的直接交互
配置方式静态配置支持AppIntent动态配置
实时性依赖时间线刷新也无法保持实时更新,但触发时间线的方式有增加
设计限制严格限制交互元素允许有限但更有意义的交互
使用场景信息展示为主可完成简单功能操作

iOS17之前仅可以通过URL或者Link的方式进入APP来响应小组件的点击交互操作。 iOS17之后可以通过Button和Toggle两个控件传入APPIntent来响应用户的交互事件。可以采用ToggleStyle来控制组件样式

关键协议理解

WidgetStructure.png

WidgetBundle

  • 核心作用:允许多个小组件在单个扩展中聚合
  • 重要特性
    • 使用@main标记作为入口点
    • 必须实现var body: some Widget { get }
    • 典型实现包含多个Widget的集合

Widget

Widget 协议是 iOS 小组件开发中最核心的协议,它定义了小组件的入口和基本配置。

public protocol Widget {
    // 小组件的唯一标识符
    var kind: String { get }
    
    // 小组件的配置和内容
    @WidgetConfigurationBuilder var body: some WidgetConfiguration { get }
}
kind 属性
  • 作用:唯一标识你的小组件
  • 要求
    • 必须是字符串常量
    • 在同一个 App 中必须唯一
    • 建议使用反向域名格式(如 "com.yourcompany.weatherwidget")
body 属性
  • 作用:定义小组件的配置和内容
  • 返回类型:必须符合 WidgetConfiguration 协议

Configuration

在iOS 17中,小组件的配置方式进一步扩展,新增了 AppIntentConfiguration,与原有配置类型的对比:

  1. StaticConfiguration

    • 适用场景:完全静态的小组件,无需用户自定义参数。
    • 示例:仅显示固定数据的视图。
  2. IntentConfiguration

    • 适用场景:通过Siri快捷指令(Shortcuts)让用户动态配置参数(如待办事项的筛选条件)。
    • 依赖 INIntent 框架,需用户手动设置参数。
  3. AppIntentConfiguration(iOS 17新增)

    • 核心改进:直接集成 AppIntent 协议,无需依赖Siri快捷指令,开发者可自定义逻辑和参数。
    • 优势
      • 支持更灵活的交互逻辑(如链式调用多个Intent)。
      • 可注册到系统搜索服务,用户无需手动添加即可调用。
    • 参数解释 1. kind: String - 作用:小组件的唯一标识符 - 要求: - 必须与 Info.plist 中 NSExtension.NSExtensionAttributes.WKWidgetKind 一致 - 推荐使用反向域名格式(如 "com.example.NotesWidget") - 注意:同一 App 内不能重复 2. intent: Intent.Type - 作用:指定关联的 AppIntent 类型 - 约束: - 必须符合 AppIntent 协议 - 泛型参数 Intent 需与 Provider 的关联类型一致 3. provider: @escaping () -> Provider - 作用:创建数据提供者的工厂闭包 - 关键点: - 返回的实例需符合 AppIntentTimelineProvider - 系统会缓存该实例,通常无需自行管理生命周期 4. content: @escaping (Provider.Entry) -> Content - 作用:视图构建闭包 - 参数: - 接收 Provider 生成的 Entry 数据 - 需返回符合 View 协议的 SwiftUI 视图
extension AppIntentConfiguration {

    /// Creates a configuration for a widget by using a custom intent
    /// to provide user-configurable options.
    /// - Parameters:
    ///   - kind: A unique string that you choose.
    ///   - intent: A custom intent containing user-editable
    ///     parameters.
    ///   - provider: An object that determines the timing of updates
    ///     to the widget's views.
    ///   - content: A view that renders the widget.
    @MainActor @preconcurrency public init<Provider>(kind: String, intent: Intent.Type = Intent.self, provider: Provider, @ViewBuilder content: @escaping (Provider.Entry) -> Content) where Intent == Provider.Intent, Provider : AppIntentTimelineProvider
}
类型引入版本核心用途数据驱动方式交互能力
StaticConfigurationiOS 14完全静态内容手动更新仅深层链接
IntentConfigurationiOS 14可配置的动态内容(基于旧版 SiriKit)INIntent + 用户配置有限配置
AppIntentConfigurationiOS 17支持交互的动态内容AppIntent交互支持

Intent

  1. INIntent(对此不再进行详细介绍)

    • 让第三方应用集成到 Siri 和快捷指令(Shortcuts)中
    • 定义应用可以执行的特定操作
    • 允许系统和其他应用向你的应用发送意图请求
    • 需要创建单独的 Intent Definition 文件
    • 协议:
      • INIntent: 基础意图类
      • INIntentHandling: 处理意图的协议
      • INExtension: 处理Siri和快捷指令请求的扩展
  2. AppIntent(iOS17+) WWDC2023

    • 交互式操作的抽象表示
    • 支持异步操作执行
    • 必须实现perform()方法
    • 典型用途:按钮点击、开关切换等交互行为的逻辑封装
    • 注意使用参数化协议 使用 @Parameter
public protocol AppIntent: PersistentlyIdentifiable, _SupportsAppDependencies, Sendable {
    // 小组件需要
    // MARK: - 必需属性
    /// 意图的显示名称(动词+名词,标题式大写)
    /// 示例:LocalizedStringResource("创建笔记")
    static var title: LocalizedStringResource { get }
    /// 意图的详细描述(显示在配置界面)
    static var description: IntentDescription? { get }
    /// 参数摘要的动态配置(描述参数组合逻辑)
    /// 示例:Summary("按\(\.$category)筛选")
    static var parameterSummary: Self.SummaryContent { get }
    
    // MARK: - 必需方法
    /// 意图执行逻辑(核心业务处理)
    func perform() async throws -> Self.PerformResult
    
    /// 必须实现的空初始化器
    init()

    // ELSE 非必需
    // MARK: - 核心关联类型
    /// 定义意图执行结果的返回类型(必须符合IntentResult)
    associatedtype PerformResult: IntentResult
    
    /// 参数摘要的关联类型(自动推断自parameterSummary实现)
    associatedtype SummaryContent: ParameterSummary
    
    // MARK: - 重要可选属性
    /// 是否在执行时打开宿主App(默认false)
    static var openAppWhenRun: Bool { get }
    
    /// 身份验证策略(默认.unrequired)
    /// 可选值:.unrequired / .requiresAuthentication
    static var authenticationPolicy: IntentAuthenticationPolicy { get }
    
    /// 是否允许系统发现此意图(默认true)
    /// 关闭后将无法通过快捷指令/搜索触发
    static var isDiscoverable: Bool { get }

}

Tip: WidgetConfigurationIntent也是AppIntent 不过perform的返回永远为never 作为配置项使用

Provider

AppIntentTimelineProvider && IntentTimelineProvider

  1. IntentTimelineProvider(可配置组件):
protocol IntentTimelineProvider {
    associatedtype Intent: WidgetConfigurationIntent
    func placeholder(in: Context) -> Entry
    func getSnapshot(for: Intent, in: Context, completion: @escaping (Entry) -> Void)
    func getTimeline(for: Intent, in: Context, completion: @escaping (Timeline<Entry>) -> Void)
}
  • 核心责任:将用户配置(Intent)转换为数据时间线
  • 必须处理配置参数的动态变化
  1. AppIntentTimelineProvider(iOS17交互式组件):
public protocol AppIntentTimelineProvider<Intent, Entry> where Intent: AppIntent, Entry: TimelineEntry {
    associatedtype Intent
    associatedtype Entry
    
    // 必须实现的方法
    func placeholder(in context: Context) -> Entry
    func snapshot(for configuration: Intent, in context: Context) async -> Entry
    func timeline(for configuration: Intent, in context: Context) async -> Timeline<Entry>
    
    // 可选实现的方法
    func recommendations() -> [IntentRecommendation<Intent>]
}
  • 在基础时间线功能上增加:
    • 交互事件与时间线的关联管理
    • 动态响应AppIntent触发的更新
  • 额外要求:
  • 处理RelevanceUpdate事件
  • 协调后台刷新与用户交互的优先级

Entry && EntryView

  1. Entry
    • 数据的时空快照
    • 必须包含date字段(用于时间线管理)
    • 建议遵循Equatable协议优化刷新逻辑
    • 典型包含三个数据维度:
      • 时间维度(自动管理)
      • 配置参数(来自Intent)
      • 业务数据(来自App)
  2. EntryView
    • SwiftUI视图的特定实现
    • 遵循Widget特有的布局约束:
      • 不支持交互视图(除iOS17+的特定控件)
      • 自动适配各尺寸变体(通过widgetFamily
      • 禁止使用耗性能的动画

小组件的配置

关于配置项建议优先参考代码

iOS16+

iOS16之后官方支持了 @Parameter来创建小组件的可配置选项 只需要在WidgetConfigurationIntent创建参数即刻增加配置

@Parameter(
        title: "功能项",
        description: "选择并排序要显示的功能",
        requestValueDialog: .init("选择并排序要显示的功能"),
        optionsProvider: SortableItemOptionsProvider()
    )
    var items: [SortableItem]?

参数类型决定了配置的样式,也可以通过optionsProvider进行部分自定义。 同时可以在parameterSummary根据条件来展示隐藏某些配置项。

    static var parameterSummary: some ParameterSummary {
        When(\.$showTitle, .equalTo, true) {
            Summary {
                \.$carControls
                \.$items
                \.$province
                \.$city
                \.$background
                \.$showTitle
                \.$title
                \.$refreshInterval
            }
        } otherwise: {
            Summary {
                \.$carControls
                \.$items
                \.$province
                \.$city
                \.$background
                \.$showTitle
                \.$refreshInterval
            }
        }
    }

如果两个配置项存在关联关系,也可以在EntityStringQuery控制,如选择省份后再选择城市,可参考如下代码

struct CityOption: AppEntity, Hashable {
    let id: String
    let name: String
    let province: String
    
    static var typeDisplayRepresentation: TypeDisplayRepresentation = "城市"
    static var defaultQuery = CityOptionQuery()
    
    var displayRepresentation: DisplayRepresentation {
        DisplayRepresentation(
            title: LocalizedStringResource(stringLiteral: "\(name)")
        )
    }
}

struct CityOptionQuery: EntityStringQuery {
    
    @IntentParameterDependency<SortableWidgetConfigIntent>(
        \.$province
    )
    var configIntent
    
    func entities(matching string: String) async throws -> [CityOption] {
        CityOption.allCities.filter { string.contains($0.id) }
    }
    
    func entities(for identifiers: [String]) async throws -> [CityOption] {
        CityOption.allCities.filter { identifiers.contains($0.id) }
    }

    func suggestedEntities() async throws -> [CityOption] {
        guard let configIntent else { return [] }
        return CityOption.allCities.filter { $0.province == configIntent.province.name }
    }
    
}

如需要在配置中实现分组功能 可以参考如下代码 实现IntentItemCollection 内部使用IntentItemSection 进行分区 最小细分为IntentItem


struct AuthorNameItem: AppEntity, Hashable {
    
    let id: String
    var name: String
    static var typeDisplayRepresentation: TypeDisplayRepresentation = "操作项"
    static var defaultQuery = AuthorNameQuery()
    var displayRepresentation: DisplayRepresentation {
        DisplayRepresentation(
            title: LocalizedStringResource(stringLiteral: name)
        )
    }
}

struct AuthorNameQuery: EntityStringQuery {
    func entities(for identifiers: [String]) async throws -> [AuthorNameItem] {
        return []
    }
    
    func entities(matching string: String) async throws -> IntentItemCollection<AuthorNameItem> {
        // 未实现搜索功能
        let sections: [IntentItemSection<AuthorNameItem>] = [IntentItemSection<AuthorNameItem>(
            "Italian Authors",
            items: [IntentItem(AuthorNameItem(id: "0", name: "Alessandro Manzoni")),
                    IntentItem(AuthorNameItem(id: "1", name: "Blessandro Manzoni")),]
        ),
                                                             IntentItemSection<AuthorNameItem>(
            "Russian Authors",
            items: [IntentItem(AuthorNameItem(id: "2", name: "Anton Chekhov")),
                    IntentItem(AuthorNameItem(id: "3", name: "Fyodor Dostoevsky")),]
        )]
        return IntentItemCollection(sections: sections)
    }
    
    
    func suggestedEntities() async throws -> IntentItemCollection<AuthorNameItem> {
        let sections: [IntentItemSection<AuthorNameItem>] = [IntentItemSection<AuthorNameItem>(
            "Italian Authors",
            items: [IntentItem(AuthorNameItem(id: "0", name: "Alessandro Manzoni")),
                    IntentItem(AuthorNameItem(id: "1", name: "Blessandro Manzoni")),]
        ),
                                                             IntentItemSection<AuthorNameItem>(
            "Russian Authors",
            items: [IntentItem(AuthorNameItem(id: "2", name: "Anton Chekhov")),
                    IntentItem(AuthorNameItem(id: "3", name: "Fyodor Dostoevsky")),]
        )]

        return IntentItemCollection(sections: sections)
        
    }

iOS14 - iOS15

需要通过创建intentdefinition做为意图文件,在里面配置对应的内容来配置小组件 可以参考iOS小组件开发全面总结的小组件配置章节,此块代码也增加在了代码库中囊括。

交互&&数据流

iOS17和iOS14提供的时间线提供者协议

小组件数据流及刷新

以下是一个用于显示角色生命值等级的游戏小组件的示例。当生命值等级低于 100% 时,角色会以每小时 25% 的速率进行恢复。例如,当角色的生命值等级为 25% 时,需要 3 个小时才能完全恢复到 100%。下图显示了 WidgetKit 如何从提供程序请求时间线,从而在时间线条目中指定的每个时间呈现小组件。

WidgetKit-Timeline-At-End.png

WidgetKit-Timeline-After-Date.png

上述是官方提供的小组件时序图,可以查看让小组件保持最新状态这篇文档来理解。

小组件的核心数据刷新方法为下面的timeline方法:

func timeline(for configuration: Self.Intent, in context: Self.Context) async -> Timeline<Self.Entry>

数据流图.png

三种刷新机制

  1. 小组件自动刷新时间线
    • 通过设置ReloadPolicy让小组件自己执行timeline方法来刷新,但是不能保证精准的时间间隔
  2. 用户点击小组件交互
  3. APP主动触发小组件刷新

上述任何一次触发刷新都是走进timeline方法,然后返回未来时间点的小组件需要显示的状态交给EntryView来展示。

每个活跃时间段不能超过28s,例如timeline返回结果必须在28s以内完成,否则刷新超时。

点击交互的刷新问题

目前发现AppIntent接收的点击事件去刷新时间线,会出现刷新两次的问题。此问题在iOS18.4已经得以解决,但表现在目前的小组件上,可能存在问题。

理解小组件的刷新是一种单向数据流,小组件根据传入的 Timeline<Entry>来展示。

按秒刷新的某种手段

使用系统级Text(date, style: .relative)可以实现时间戳的按秒刷新

  • 只能选择系统提供的几种样式(.relative / .offset / .timer / .time / .date),它们每一种的显示规则都是 Apple 预设好的,且带有自动化的国际化适配。
  • 不支持开发者时间样式格式化
struct WidgetView: View {
    let lastUpdateTime: Date // 数据最后更新的时间
    
    var body: some View {
        VStack(alignment: .leading) {
            
            // 关键点:使用系统托管的 DateStyle
            HStack(spacing: 4) {
                Text("已发生:")
                // 1. 相对时间样式:显示“1分10秒前”,且秒数会自动跳动
                Text(lastUpdateTime, style: .relative) 
                
                // 2. 计时器样式:显示“00:01:23”,像秒表一样自动跳动
                // Text(lastUpdateTime, style: .timer) 
            }
            .font(.caption)
            .foregroundColor(.gray)
        }
    }
}

但此方案存在限制

  1. 逻辑不可干预:你无法写 if duration > 60 这种判断逻辑,因为 Swift 代码不运行。
  2. 样式不可自定义:显示的格式(如“1分10秒”还是“1m 10s”)由系统多语言环境决定,开发者改不了。
  3. 非真·刷新:它只是 UI 层的数值跳动,并不能带起业务数据的更新。业务数据依然要靠 TimelineProvider。

小组件与主工程通信

方式支持方向适用场景
App Group + UserDefaults双向主 App 修改数据,Widget读取
App Group + FileManager双向图片、JSON 等复杂文件共享
App Group + CoreData双向复杂结构数据、数据库
WidgetCenter.reloadAllTimeline主 App → Widget主动触发 Widget 刷新
Deeplink / URL SchemeWidget → App用户点击跳转 App 具体页面
AppIntent (iOS 16+)Widget → AppWidget 内按钮或开关触发动作

相关关键开发问题

关于内存

如果你在 Widget 里加载大图,很容易超过 30MB 触发 OOM,你是怎么处理的?(答案:Downsampling 采样、清理缓存)。

关于调试

WidgetDebug.jpg

  • Kind 作为唯一标识可以指定调试的小组件
  • Family指定调试的Widget的大小

关于请求问题

  1. 必须使用 async/await(Swift Concurrency模型)
  2. 不能脱离 TimelineProvider /Intent的生命周期
    • 网络请求必须在 timeline(for:in:)或者AppIntent的perform() 的调用生命周期里完成
    • 不能启动一个异步任务然后自己放着,TimelineProvider就结束了,结果还没回来。
    • 否则,小组件框架认为你超时了,直接丢弃你的请求(不会等你)。
  3. 不能手动开 Task {} 去异步执行,必须直接 await 等待结果。
  4. 小组件内不支持弹窗/提示/授权交互

关于定位问题

iOS14 可以通过闭包回调的方式却获取定位

API调用位置能否获取定位备注
getTimeline(completion:) (iOS14-15)✅ 有机会获取系统调度,成功率不保证
timeline(for:in:) (iOS16+)❌ 几乎无法获取环境限制,定位请求直接失败
AppIntent.perform() 点击触发❌ 无法定位无权限,只能同步操作
从 App 同步到 Widget✅ 可行App内先拿定位,存储到共享容器,再供 Widget 读取

编译问题

问题本质

  1. 语言隔离性
    • 小组件(Widget Extension)默认使用Swift编写,而主工程若以Objective-C为主,混合编译时容易出现符号(Symbol)无法解析的问题。
    • 即使通过Bridging Header桥接,部分OC库可能因模块化配置(如未正确定义@import#import)或依赖链断裂导致编译失败。
  2. 动态库/静态库限制
    • 小组件与主工程属于不同的二进制目标(Target),若基础库(如网络请求库)未明确支持动态链接(Dynamic Framework)或未正确配置Embed & Sign,会导致运行时加载失败。
  3. 封装库的兼容性问题
    • 若网络请求库封装时依赖了主工程的OC代码(如全局宏、Category扩展等),而小组件无法访问主工程的编译上下文,会直接报错“Undefined symbol”。

参考文档

WidgetKit

网易iOS小组件开发指南

iOS小组件开发全面总结