iOS 小组件开发用 WidgetKit:在 Windows 上给 SwiftUI 工程加小组件扩展,写时间线和不同尺寸界面,用 App Group 共享数据并主动刷新,装到 iOS 17 及以上的 iPhone 调试,随 ipa 一起上架。
iOS 小组件开发用 WidgetKit:在 Windows 上用 VS Code 插件或 kxbuild project add-target --type widget 给 SwiftUI 工程加一个小组件扩展,写好时间线和各尺寸界面,用 App Group 和 App 共享数据,App 里调用 WidgetCenter 主动刷新,装到 iOS 17 及以上的 iPhone 上看效果,最后随 ipa 一起上架。本文给工程 MyApp 加一个「喝水打卡」桌面小组件,App 里点一下记一杯,主屏幕上的小组件跟着更新。
文中的工程已经能用 KXBuild 编译并装到真机上。还没走到这一步的,先看[第一个 SwiftUI App]。
什么时候适合加 iOS 小组件?
小组件是一段单独运行的 SwiftUI 界面,放在主屏幕、锁屏或今天视图里。它不能滚动,不能输入文字,也不是一直在运行。系统按你提前给出的时间线显示内容,到点才换下一条。
适合的场景是「一眼看完」的信息,比如计数、进度、倒计时、最新一条消息。需要频繁交互或实时刷新的内容不适合做成小组件,锁屏上的实时进度(外卖、比分)用实时活动(Live Activity)。
小组件和 App 是两个独立的程序,各有自己的 Bundle ID,数据要通过 App Group 共享。下面按添加、写代码、共享数据、签名、装机、发布的顺序走一遍。
怎么添加小组件扩展?
以下两种做法任选其一。
- VS Code 插件。在 KXBuild 视图中右键 MyApp 这个 target,选
KXBuild: 添加 Target…,也可以在命令面板运行它。类型选「小组件」,名称用默认的 MyAppWidget,App Group 填group.com.example.myapp。完成后右下角的提示里会列出要在开发者后台注册的 App ID。 - 命令行。在工程所在目录执行下面的命令。
kxbuild 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 是预览画布用的代码,可以保留,也可以删掉。
怎么用 WidgetKit 写小组件?
把 MyAppWidget.swift 换成下面的内容。它读 App 记下的今日杯数,支持主屏幕小号、中号和锁屏圆形三种尺寸。
import WidgetKit
import SwiftUI
let appGroup = "group.com.example.myapp"
var sharedDefaults: UserDefaults {
UserDefaults(suiteName: appGroup) ?? .standard
}
/// 今天记了几杯,日期不是今天就算 0
func todayCups() -> Int {
let today = Calendar.current.startOfDay(for: Date()).timeIntervalSince1970
guard sharedDefaults.double(forKey: "cupsDay") == today else { return 0 }
return sharedDefaults.integer(forKey: "cups")
}
struct CupsEntry: TimelineEntry {
let date: Date
let cups: Int
}
struct Provider: TimelineProvider {
func placeholder(in context: Context) -> CupsEntry {
CupsEntry(date: Date(), cups: 3)
}
func getSnapshot(in context: Context, completion: @escaping (CupsEntry) -> Void) {
completion(CupsEntry(date: Date(), cups: todayCups()))
}
func getTimeline(in context: Context, completion: @escaping (Timeline<CupsEntry>) -> Void) {
let now = Date()
let midnight = Calendar.current.date(byAdding: .day, value: 1, to: Calendar.current.startOfDay(for: now))!
// 现在显示当前杯数,零点自动归零,之后再来要新的时间线
let entries = [
CupsEntry(date: now, cups: todayCups()),
CupsEntry(date: midnight, cups: 0),
]
completion(Timeline(entries: entries, policy: .atEnd))
}
}
struct MyAppWidgetEntryView: View {
@Environment(\.widgetFamily) var family
var entry: Provider.Entry
let goal = 8
var body: some View {
switch family {
case .accessoryCircular:
Gauge(value: Double(min(entry.cups, goal)), in: 0...Double(goal)) {
Image(systemName: "drop.fill")
} currentValueLabel: {
Text("\(entry.cups)")
}
.gaugeStyle(.accessoryCircularCapacity)
case .systemMedium:
HStack(spacing: 16) {
VStack(alignment: .leading, spacing: 4) {
Label("今日喝水", systemImage: "drop.fill")
.font(.caption)
.foregroundStyle(.blue)
Text("\(entry.cups) / \(goal) 杯")
.font(.title.bold())
}
ProgressView(value: Double(min(entry.cups, goal)), total: Double(goal))
.tint(.blue)
}
default:
VStack(alignment: .leading, spacing: 6) {
Label("喝水", systemImage: "drop.fill")
.font(.caption)
.foregroundStyle(.blue)
Text("\(entry.cups)")
.font(.system(size: 44, weight: .bold, design: .rounded))
Text("目标 \(goal) 杯")
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
struct MyAppWidget: Widget {
let kind: String = "MyAppWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: Provider()) { entry in
MyAppWidgetEntryView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget)
}
.configurationDisplayName("喝水打卡")
.description("显示今天喝了几杯水。")
.supportedFamilies([.systemSmall, .systemMedium, .accessoryCircular])
}
}
几个要点。
- 时间线一次给多条。系统不会每秒来问小组件要数据,而是拿到整条时间线后按每条的
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 换成下面的内容。点一下按钮记一杯,然后通知小组件刷新。
import SwiftUI
import WidgetKit
struct ContentView: View {
private let defaults = UserDefaults(suiteName: "group.com.example.myapp") ?? .standard
@State private var cups = 0
var body: some View {
VStack(spacing: 24) {
Image(systemName: "drop.fill")
.font(.system(size: 56))
.foregroundStyle(.blue)
Text("今天喝了 \(cups) 杯水")
.font(.title.bold())
Button("喝了一杯") {
addCup()
}
.buttonStyle(.borderedProminent)
}
.padding()
.onAppear { cups = todayCups() }
}
private func todayCups() -> Int {
let today = Calendar.current.startOfDay(for: Date()).timeIntervalSince1970
guard defaults.double(forKey: "cupsDay") == today else { return 0 }
return defaults.integer(forKey: "cups")
}
private func addCup() {
cups = todayCups() + 1
defaults.set(cups, forKey: "cups")
defaults.set(Calendar.current.startOfDay(for: Date()).timeIntervalSince1970, forKey: "cupsDay")
WidgetCenter.shared.reloadAllTimelines()
}
}
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 和小组件各配一份,不用在工程里指定,目录位置见 [Windows 给 iOS 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 安装后,桌面上的小组件一般会自动换成新代码。没有变化时删掉小组件重新添加一次。
带小组件的 App 怎么打包 ipa 上架?
上架时 App 和小组件都要有 App Store 类型的描述文件。在 Profiles 页分别给两个 App ID 生成 App Store 描述文件,放进 keychain 目录。然后以下两种做法任选其一。
- VS Code 插件。在 KXBuild 视图的「构建配置」节点切到 Release,命令面板运行
KXBuild: 构建。 - 命令行。在工程所在目录执行下面的命令。
kxbuild build .\MyApp.xcodeproj -c Release
生成的 build\MyApp.ipa 里已经带着小组件,iOS 小组件开发的成果随 App 一起提交。App Store 要求扩展和 App 的版本号一致,以后改版本号时,在[工程设置编辑器]里把 MyApp 和 MyAppWidget 两个 target 一起改。上传和提交审核的流程见[在 Windows 上上架 App Store]。
常见问题
添加小组件的列表里找不到怎么办?
先确认手机是 iOS 17 及以上,再打开一次 App 后回到添加列表。仍然没有的,重启手机后再看。
小组件一片空白或一直是灰色占位是什么原因?
多半是 getTimeline 没有调用 completion,或者小组件代码崩溃了,检查强制解包和数组越界。灰色块是 placeholder 的样子,正常情况下很快会换成真实内容。只显示一行提示文字的,是没有设置 containerBackground。
App 里改了数据,小组件为什么不更新?
通常是两边没用同一个 App Group、App 没通知刷新,或者系统限制了刷新次数,按下面逐条检查。
- App 和小组件的 App Group 名字不一致,或者有一边还在用
UserDefaults.standard。 - App 写完数据后没有调用
WidgetCenter.shared.reloadAllTimelines()。 - 系统限制了刷新次数。App 不在前台时发出的刷新请求可能被推迟,打开 App 再操作一次看看。
构建时小组件签名报错怎么办?
先确认 keychain 目录里有小组件的描述文件,并且是在开启 App Groups 之后生成的。
- 提示找不到匹配的描述文件,说明
keychain目录里没有com.example.myapp.myappwidget的描述文件,或者它不包含这台测试机。 - 提示描述文件不含 App Groups,说明 App ID 开启 App Groups 之后没有重新生成描述文件,或者用的是通配描述文件。
- 更多签名和安装报错的原因与处理见 [iOS 签名报错与 ipa 安装失败,16 条常见错误及解决办法]。
更多教程查看官网:https://www.kxbuild.net/zh
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/2501_91600747/article/details/169845282



