给 iOS App 加一个桌面小组件(WidgetKit),在 Windows 上完成
小组件让用户不打开 App 就能看到最关心的那条信息,比如今天的步数、下一班车、待办数量。这篇给一个 SwiftUI 工程 MyApp 加一个「喝水打卡」小组件,App 里点一下记一杯,主屏幕上的小组件跟着更新,全程在 Windows 上完成。
文中的工程已经能用 KXBuild 编译并装到真机上。还没走到这一步的,先看第一个 SwiftUI App。
什么时候适合加小组件
小组件是一段单独运行的 SwiftUI 界面,放在主屏幕、锁屏或今天视图里。它不能滚动,不能输入文字,也不是一直在运行。系统按你提前给出的时间线显示内容,到点才换下一条。
适合的场景是「一眼看完」的信息,比如计数、进度、倒计时、最新一条消息。需要频繁交互或实时刷新的内容不适合做成小组件,锁屏上的实时进度(外卖、比分)用实时活动(Live Activity)。
小组件和 App 是两个独立的程序,各有自己的 Bundle ID,数据要通过 App Group 共享。下面按添加、写代码、共享数据、签名、装机、发布的顺序走一遍。
添加小组件扩展
以下两种做法任选其一。
- VS Code 插件。在 KXBuild 视图中右键 MyApp 这个 target,选
KXBuild: 添加 Target…,也可以在命令面板运行它。类型选「小组件」,名称用默认的 MyAppWidget,App Group 填group.com.example.myapp。完成后右下角的提示里会列出要在开发者后台注册的 App ID。 - 命令行。在工程所在目录执行下面的命令。
1kxbuild project add-target -P .\MyApp.xcodeproj --type widget --name MyAppWidget --app-group group.com.example.myapp
App Group 一般写成 group. 加 App 的 Bundle ID。小组件要读 App 里的数据,添加时就把它填上,App 和小组件会同时加上这个 App Group。
小组件的 Bundle ID 自动取 App 的 Bundle ID 加名称,这里是 com.example.myapp.myappwidget。另外它要求 iOS 17 及以上,App 支持更低版本也没关系,只是低版本系统上看不到这个小组件。插件的其他类型和参数见添加 Target和 kxbuild 命令参考。
生成了哪些文件
工程目录下多出一个 MyAppWidget 文件夹,已经可以直接编译。
| 文件 | 作用 |
|---|---|
| MyAppWidgetBundle.swift | 小组件扩展的入口,列出这个扩展提供的所有小组件 |
| MyAppWidget.swift | 小组件本身,包括时间线、条目、界面和配置 |
| Info.plist | 声明这是一个 WidgetKit 扩展,一般不用改 |
| Assets.xcassets | 小组件的强调色和背景色 |
| MyAppWidget.entitlements | 小组件的 App Group |
App 这边的 MyApp.entitlements 也加上了同一个 App Group,工程里原来没有这个文件的会新建一个。小组件已经设置为随 App 一起构建、打包和安装,不用再改工程设置。
打开 MyAppWidget.swift,里面几个类型的分工如下。
- SimpleEntry。一条要显示的数据,
date是它开始显示的时间。 - Provider。给系统提供数据。
placeholder是加载前的占位内容,getSnapshot用在添加小组件时的预览,getTimeline返回接下来一段时间要显示的条目,以及什么时候再来要。 - MyAppWidgetEntryView。把一条数据画成界面。
- MyAppWidget。小组件的配置,包括名称、说明和支持的尺寸。
文件末尾的 MyAppWidget_Previews 是给 Mac 上的 Xcode 画布用的,可以保留,也可以删掉。
写小组件
把 MyAppWidget.swift 换成下面的内容。它读 App 记下的今日杯数,支持主屏幕小号、中号和锁屏圆形三种尺寸。
1import WidgetKit
2import SwiftUI
3
4let appGroup = "group.com.example.myapp"
5
6var sharedDefaults: UserDefaults {
7 UserDefaults(suiteName: appGroup) ?? .standard
8}
9
10/// 今天记了几杯,日期不是今天就算 0
11func todayCups() -> Int {
12 let today = Calendar.current.startOfDay(for: Date()).timeIntervalSince1970
13 guard sharedDefaults.double(forKey: "cupsDay") == today else { return 0 }
14 return sharedDefaults.integer(forKey: "cups")
15}
16
17struct CupsEntry: TimelineEntry {
18 let date: Date
19 let cups: Int
20}
21
22struct Provider: TimelineProvider {
23 func placeholder(in context: Context) -> CupsEntry {
24 CupsEntry(date: Date(), cups: 3)
25 }
26
27 func getSnapshot(in context: Context, completion: @escaping (CupsEntry) -> Void) {
28 completion(CupsEntry(date: Date(), cups: todayCups()))
29 }
30
31 func getTimeline(in context: Context, completion: @escaping (Timeline<CupsEntry>) -> Void) {
32 let now = Date()
33 let midnight = Calendar.current.date(byAdding: .day, value: 1, to: Calendar.current.startOfDay(for: now))!
34 // 现在显示当前杯数,零点自动归零,之后再来要新的时间线
35 let entries = [
36 CupsEntry(date: now, cups: todayCups()),
37 CupsEntry(date: midnight, cups: 0),
38 ]
39 completion(Timeline(entries: entries, policy: .atEnd))
40 }
41}
42
43struct MyAppWidgetEntryView: View {
44 @Environment(\.widgetFamily) var family
45 var entry: Provider.Entry
46 let goal = 8
47
48 var body: some View {
49 switch family {
50 case .accessoryCircular:
51 Gauge(value: Double(min(entry.cups, goal)), in: 0...Double(goal)) {
52 Image(systemName: "drop.fill")
53 } currentValueLabel: {
54 Text("\(entry.cups)")
55 }
56 .gaugeStyle(.accessoryCircularCapacity)
57 case .systemMedium:
58 HStack(spacing: 16) {
59 VStack(alignment: .leading, spacing: 4) {
60 Label("今日喝水", systemImage: "drop.fill")
61 .font(.caption)
62 .foregroundStyle(.blue)
63 Text("\(entry.cups) / \(goal) 杯")
64 .font(.title.bold())
65 }
66 ProgressView(value: Double(min(entry.cups, goal)), total: Double(goal))
67 .tint(.blue)
68 }
69 default:
70 VStack(alignment: .leading, spacing: 6) {
71 Label("喝水", systemImage: "drop.fill")
72 .font(.caption)
73 .foregroundStyle(.blue)
74 Text("\(entry.cups)")
75 .font(.system(size: 44, weight: .bold, design: .rounded))
76 Text("目标 \(goal) 杯")
77 .font(.caption)
78 .foregroundStyle(.secondary)
79 }
80 .frame(maxWidth: .infinity, alignment: .leading)
81 }
82 }
83}
84
85struct MyAppWidget: Widget {
86 let kind: String = "MyAppWidget"
87
88 var body: some WidgetConfiguration {
89 StaticConfiguration(kind: kind, provider: Provider()) { entry in
90 MyAppWidgetEntryView(entry: entry)
91 .containerBackground(.fill.tertiary, for: .widget)
92 }
93 .configurationDisplayName("喝水打卡")
94 .description("显示今天喝了几杯水。")
95 .supportedFamilies([.systemSmall, .systemMedium, .accessoryCircular])
96 }
97}
几个要点。
- 时间线一次给多条。系统不会每秒来问小组件要数据,而是拿到整条时间线后按每条的
date自己切换。这里提前放好零点归零的那一条,不用在零点唤醒小组件。 - 刷新策略。
.atEnd表示最后一条显示后再来要,.after(date)表示到指定时间再来要,.never表示除非 App 主动通知,否则不再更新。 - 尺寸。
supportedFamilies决定用户能选哪些尺寸,界面里用widgetFamily分别布局。锁屏尺寸以accessory开头,系统会把它们渲染成单色。 - 背景。iOS 17 起小组件必须用
containerBackground设置背景,不设会显示一条提示而不是你的内容。
MyAppWidgetBundle.swift 不用改。以后想再加一个小组件,写好新的 Widget 结构体后加进它的 body 里即可。
让 App 和小组件共享数据
小组件和 App 各有各的存储,UserDefaults.standard 互相看不到。两边都用 UserDefaults(suiteName: "group.com.example.myapp") 读写,数据就放在 App Group 的共享区域里。
把 App 的 ContentView.swift 换成下面的内容。点一下按钮记一杯,然后通知小组件刷新。
1import SwiftUI
2import WidgetKit
3
4struct ContentView: View {
5 private let defaults = UserDefaults(suiteName: "group.com.example.myapp") ?? .standard
6 @State private var cups = 0
7
8 var body: some View {
9 VStack(spacing: 24) {
10 Image(systemName: "drop.fill")
11 .font(.system(size: 56))
12 .foregroundStyle(.blue)
13 Text("今天喝了 \(cups) 杯水")
14 .font(.title.bold())
15 Button("喝了一杯") {
16 addCup()
17 }
18 .buttonStyle(.borderedProminent)
19 }
20 .padding()
21 .onAppear { cups = todayCups() }
22 }
23
24 private func todayCups() -> Int {
25 let today = Calendar.current.startOfDay(for: Date()).timeIntervalSince1970
26 guard defaults.double(forKey: "cupsDay") == today else { return 0 }
27 return defaults.integer(forKey: "cups")
28 }
29
30 private func addCup() {
31 cups = todayCups() + 1
32 defaults.set(cups, forKey: "cups")
33 defaults.set(Calendar.current.startOfDay(for: Date()).timeIntervalSince1970, forKey: "cupsDay")
34 WidgetCenter.shared.reloadAllTimelines()
35 }
36}
WidgetCenter.shared.reloadAllTimelines() 让系统马上重新调用小组件的 getTimeline。只想刷新某一个小组件时用 reloadTimelines(ofKind: "MyAppWidget"),参数就是小组件配置里的 kind。
App Group 的名字在 App、小组件代码和 entitlements 里必须一字不差。项目大了可以把读写的代码放进一个两边都能用的 Swift 文件,这里为了看得清楚直接写在了两处。
在开发者后台准备 App ID 和描述文件
小组件是一个单独的程序,签名用自己的描述文件。登录 developer.apple.com,进入 Certificates, Identifiers & Profiles,按下面的顺序操作。
- 创建 App Group。在 Identifiers 页点加号,选 App Groups,标识符填
group.com.example.myapp。 - App 的 App ID 开启 App Groups。点开
com.example.myapp,在 Capabilities 里勾选 App Groups,点旁边的 Configure 选中刚建的 App Group,保存。 - 给小组件注册 App ID。点加号选 App IDs → App,Bundle ID 选 Explicit,填
com.example.myapp.myappwidget,同样勾选 App Groups 并选中同一个 App Group。 - 生成描述文件。在 Profiles 页给小组件的 App ID 新建一份 Development 描述文件,勾选证书和测试机。App 原来的描述文件是开启 App Groups 之前生成的,点开它 Edit 后直接 Save,重新下载。
把两份描述文件都放进 KXBuild 的 keychain 目录,删掉 App 的旧描述文件。构建时会按 Bundle ID 自动给 App 和小组件各配一份,不用在工程里指定。
通配 App ID(com.example.*)不能开启 App Groups。只做界面、暂时不共享数据的话,添加时 App Group 留空,一份通配的开发描述文件就能同时签 App 和小组件。App ID、描述文件和证书目录的完整步骤见在 Windows 上申请 iOS 开发证书、描述文件和 p12。
装到 iPhone 上看效果
在状态栏选好 iPhone,按 F5。KXBuild 会把 App 和小组件一起构建、签名并安装,然后启动 App。在 App 里点几下「喝了一杯」。
回到主屏幕,长按空白处进入编辑状态,点左上角的「编辑」或加号,选「添加小组件」,在列表里找到 MyApp,挑一个尺寸添加。锁屏尺寸在锁屏上长按,选「自定」后添加。再回 App 点一下,小组件的数字会跟着变。
调试时注意几点。
- F5 调试的是 App。小组件运行在自己的进程里,App 里的断点管不到它。小组件的逻辑尽量简单,数据的计算放在 App 里完成,写进共享存储后让小组件只负责显示。
- 刷新次数有限。系统每天给每个小组件的刷新次数有上限,时间线间隔设得很短也不会真的那么频繁地刷新。App 在前台时调用
reloadAllTimelines()不占这个次数,数据变化时由 App 主动通知是最可靠的做法。 - 改了代码看不到变化。重新 F5 安装后,桌面上的小组件一般会自动换成新代码。没有变化时删掉小组件重新添加一次。
打包 ipa 上架
上架时 App 和小组件都要有 App Store 类型的描述文件。在 Profiles 页分别给两个 App ID 生成 App Store 描述文件,放进 keychain 目录。然后以下两种做法任选其一。
- VS Code 插件。在 KXBuild 视图的「构建配置」节点切到 Release,命令面板运行
KXBuild: 构建。 - 命令行。在工程所在目录执行下面的命令。
1kxbuild build .\MyApp.xcodeproj -c Release
生成的 build\MyApp.ipa 里已经带着小组件。App Store 要求扩展和 App 的版本号一致,以后改版本号时,在工程设置编辑器里把 MyApp 和 MyAppWidget 两个 target 一起改。上传和提交审核的流程见在 Windows 上上架 App Store。
常见问题
添加小组件的列表里找不到
先确认手机是 iOS 17 及以上。然后打开一次 App,再回到添加列表。仍然没有的,重启手机后再看。
小组件一片空白或一直是灰色占位
灰色块是 placeholder 的样子,正常情况下很快会换成真实内容。一直停在这里,多半是 getTimeline 没有调用 completion,或者小组件代码崩溃了,检查强制解包和数组越界。只显示一行提示文字的,是没有设置 containerBackground。
App 里改了数据,小组件不更新
- App 和小组件的 App Group 名字不一致,或者有一边还在用
UserDefaults.standard。 - App 写完数据后没有调用
WidgetCenter.shared.reloadAllTimelines()。 - 系统限制了刷新次数。App 不在前台时发出的刷新请求可能被推迟,打开 App 再操作一次看看。
构建时小组件签名报错
- 提示找不到匹配的描述文件,说明
keychain目录里没有com.example.myapp.myappwidget的描述文件,或者它不包含这台测试机。 - 提示描述文件不含 App Groups,说明 App ID 开启 App Groups 之后没有重新生成描述文件,或者用的是通配描述文件。
- 更多签名和安装报错的原因与处理见 iOS 签名报错与 ipa 安装失败,16 条常见错误及解决办法。