在 iOS 开发早期,构建应用程序很简单,构建的所有内容都构建在一个编译的二进制文件中,所有代码和功能包含在一个地方。如今情况不再如此,我们不仅发布一个应用程序,还发布许多单独的组件( iMessage 应用程序,自定义键盘,Safari 操作扩展,或者是连接到 Siri 的东西)。


小组件全局图景

组件 & 实时活动 & 控制

现在苹果的组件,从类型上划分其实主要有这几种:

| 类型 | 最常见小组件 Home/Lock Screen Widget | 实时活动/灵动岛 Live Activity / Dynamic Island | 控制中心控件 Control Center Control | | --- | --- | --- | --- | | 核心目的 | 提供应用核心信息的“概览”。 | 追踪一个“正在进行”的短期事件。用于在锁屏和灵动岛上,显示一个具有开始和结束状态、内容会实时更新的短期任务。 | 提供一个快捷的“开关”或“动作”。专门用于在控制中心创建新的开关、按钮或滑块。 | | 显示位置 | 主屏幕、锁屏、待机模式 | 锁屏、灵动岛 | 控制中心 | | 核心框架 | WidgetKit | ActivityKit 框架管理活动的生命周期 | 使用名为 Controls 的独立框架 | | UI 技术 | WidgetKit (SwiftUI) | WidgetKit (SwiftUI) | Controls 框架自定义 | | 交互技术 | AppIntents | AppIntents | Controls 框架自定义 | | 主要实现方式 | Widget | 需要定义一个遵循 ActivityAttributes 的属性结构体,然后使用 Activityrequest 方法来启动一个实时活动 | 需要创建一个遵循 ControlWidget 协议的结构体 | | 总结 | 在它里面根据实现方式的不同,可以分成:静态组件 & 可配置交互组件 | 虽然在 UI 和交互上与 WidgetKit 技术相通,但目的和管理方式完全不同。小组件是为了提供一目了然的概览信息,而实时活动是为了追踪一个“正在进行中”的事件。 | 是一种功能专一的“小组件”,使用场景和技术栈都和主屏幕小组件完全独立。可以把它看作是苹果开放给开发者的一种“系统级开关”的接口。 |

静态组件 & 可配置组件

上面提到的能够放置在主屏幕、锁屏和待机模式上的最常见小组件,其实有两种实现方式,它们都属于 WidgetKit 框架。基本差异对比:

静态小组件 Static Widget 可配置/交互式小组件 Configurable Widget
支持系统版本 iOS 14+ iOS 17+
主要用途 显示通用信息,用户无法进行任何个性化设置 用户可以通过系统提供的编辑界面,自定义小组件的外观和内容。支持交互式按钮
交互能力 不支持。点击小组件的任何位置只会打开主 App 支持。可以使用 Button(intent: ...) 来创建可交互的按钮,在不打开 App 的情况下执行一个 AppIntent 动作
用户配置定义 无。不需要定义任何配置 通过创建一个遵循 WidgetConfigurationIntent 协议的 struct 来定义。使用 @Parameter 属性包装器来定义每一个可配置的选项
配置方式 编译时固定 运行时动态配置

Widget 配置结构对比:

StaticConfiguration AppIntentConfiguration
关键配置 StaticConfiguration AppIntentConfiguration
配置创建 `StaticConfiguration(
kind: kind,
provider: Provider()

) { entry in MyWidgetEntryView(entry: entry) }|AppIntentConfiguration( kind: kind, intent: MyConfigIntent.self, provider: Provider() ) { entry in MyWidgetEntryView(entry: entry) }| | **Intent要求** | 无 | 需要定义继承自WidgetConfigurationIntent` 的AppIntent | | 配置UI | 无 | 自动生成配置界面 |

数据提供者协议对比:

StaticConfiguration AppIntentConfiguration
协议名称 TimelineProvider AppIntentTimelineProvider
一个通用的数据提供者,不关心用户配置 一个专门为可配置组件设计的数据提供者,它能接收用户当前的配置意图
泛型参数 TimelineProvider<Entry> AppIntentTimelineProvider<Entry, ConfigurationAppIntent>
配置参数 需要传入 AppIntent 类型

有差异的方法实现对比:

StaticConfiguration AppIntentConfiguration
快照方法 `func getSnapshot(
in context: Context,
completion: @escaping (Entry) -> ()

)|func snapshot( for configuration: MyConfigIntent, in context: Context ) async -> Entry| | | 1. 方法名以get开头 2. 不接收用户配置参数 3. 异步操作通过闭包completion返回结果 | 1. 方法名简化为snapshot`

  1. 接收一个 configuration 参数,包含了用户的所有配置

  2. 是一个 async 方法,直接 return 结果,语法更简洁 | | 时间线方法 | func getTimeline( in context: Context, completion: @escaping (Timeline<Entry>) -> () ) | func timeline( for configuration: MyConfigIntent, in context: Context ) async -> Timeline<Entry> | | | 1. 方法名以 get 开头

  3. 不接收用户配置参数

  4. 异步操作通过闭包 completion 返回结果 | 1. 方法名简化为 timeline

  5. 接收 configuration 参数,可根据用户选择生成不同时间线

  6. 是一个 async 方法,直接 return 结果 |

StaticConfiguration 示例结构

struct MyWidget: Widget {
    var body: some WidgetConfiguration {
        StaticConfiguration(
            kind: "MyWidget",
            provider: MyTimelineProvider()
        ) { entry in
            MyWidgetView(entry: entry)
        }
    }
}

struct MyTimelineProvider: TimelineProvider {
    func placeholder(in context: Context) -> MyEntry { }
    func getSnapshot(in context: Context, completion: @escaping (MyEntry) -> Void) { }
    func getTimeline(in context: Context, completion: @escaping (Timeline<MyEntry>) -> Void) { }
}

AppIntentConfiguration示例结构

struct MyWidget: Widget {
    var body: some WidgetConfiguration {
        AppIntentConfiguration(
            kind: "MyWidget",
            intent: MyConfigIntent.self,
            provider: MyTimelineProvider()
        ) { entry in
            MyWidgetView(entry: entry)
        }
    }
}

struct MyTimelineProvider: AppIntentTimelineProvider {
    func placeholder(in context: Context) -> MyEntry { }
    func snapshot(for configuration: MyConfigIntent, in context: Context, completion: @escaping (MyEntry) -> Void) { }
    func timeline(for configuration: MyConfigIntent, in context: Context, completion: @escaping (Timeline<MyEntry>) -> Void) { }
}

仅使用可配置组件

由于 AppIntent 方式是未来的超集,所以未来完全可以只学习并使用这一种方式来构建所有的小组件,这几乎没有任何负面影响。

  1. 功能上完全兼容

首先 AppIntentConfiguration 可以完美地实现 StaticConfiguration 的所有效果。如何做到?只需提供一个不包含任何 @Parameter 属性的 AppIntent 即可。当 WidgetKit 系统检测到提供给 AppIntentConfiguration 的意图(Intent)里没有任何可供用户配置的 @Parameter 时,它就足够智能,根本不会在长按菜单中显示“编辑小组件”的选项。从体验来看它和用 StaticConfiguration 创建的小组件是完全一样的。

// **示例:用 AppIntent 方式做一个“静态”小组件**
import AppIntents
import WidgetKit

// 1. 创建一个“空的”意图,没有任何 @Parameter
struct EmptyIntent: WidgetConfigurationIntent {
    // 标题和描述是必需的,但因为没有可配置项,用户永远不会看到这个界面
    static var title: LocalizedStringResource = "My Simple Widget Intent"
    static var description = IntentDescription("This intent has no configurable options.")
}

// 2. 在小组件主体中,依然使用 AppIntentConfiguration
@main
struct MySimpleWidget: Widget {
    let kind: String = "MySimpleWidget"

    var body: some WidgetConfiguration {
        // 传入这个“空的”意图
        AppIntentConfiguration(
            kind: kind,
            intent: EmptyIntent.self,     // <-- 使用空意图
            provider: Provider()          // Provider 需遵循 AppIntentTimelineProvider
        ) { entry in
            MyWidgetEntryView(entry: entry)
        }
        .configurationDisplayName("我的简单小组件")
        .description("这是一个简单的信息展示小组件。")
    }
}
  1. 学习和维护上的巨大优势

小组件前期准备

创建小组件 Target

在创建小组件时,实际上是在构建项目中一个完全独立的部分 target,这意味着:

首先我们需要创建一个小组件目标 widget target,该 target 将成为未来所有小组件的 host 主体,所有的小组件都在这个 target 中定义

<aside> 💡 如果这是您第一次使用 scheme 方案,请仔细阅读以下内容以避免混淆! 我们现在在多个独立的 targets 之间工作,不同的程序必须单独运行才能发挥作用。Xcode 会根据 schemes 决定应该运行哪一个,当前激活的方案就是下次按 Cmd+R 时,会运行的程序。这里 Xcode 询问是否激活新的小组件 scheme ,这很有帮助。这意味着我们可以继续构建小部件,并且之后每次按 Cmd+R 时,都会部署并运行新的小部件代码。因此,请立即按激活。 将来,如果希望返回到以前的方案,可以通过转到 “Product” 菜单并选择 “Scheme > UltimatePortfolio” 来执行此操作。

</aside>

配置跨目标共享代码

往小组件添加编译文件:

现在小组件 target 文件夹,看起来像一个迷你应用:它有两个 Swift 代码文件;一个资产目录;还有一个 Info.plist 文件。小组件是单独的二进制文件,默认情况下它只有两个 Swift 文件的代码,是不够的。除了直接在该 target 中写代码,我们还可以复用之前在主应用中的一些代码,所以我们要告诉 Xcode 哪些其他 Swift 文件应该编译到小组件中。

添加方法是选择需要的文件,打开右侧文件检查器面板,然后选中 “Target Membership” 中的 “PortfolioWidgetExtension” 框。这代表这些文件既是为原始应用程序目标构建的,现在也是为小组件构建的。

为什么不添加所有文件: