在 WidgetKit 中,小组件的核心是时间轴(Timeline 和时间轴提供者(TimelineProvider)。
WidgetKit 工作流程:
在 WidgetKit 中,数据通过 TimelineProvider 提供,TimelineProvider 负责生成一个或多个时间点(Entries),每个时间点对应着小组件应该显示的数据。

1、组件的数据流
TimelineProvider 是负责提供数据的核心组件,它将时间和数据结合,定义小组件在不同时间点的显示内容。
struct SimpleProvider: TimelineProvider {
func placeholder(in context: Context) -> SimpleProviderEntry {
SimpleProviderEntry(date: Date())
}
func getSnapshot(in context: Context, completion: @escaping (SimpleProviderEntry) -> ()) {
let entry = SimpleProviderEntry(date: Date())
completion(entry)
}
func getTimeline(in context: Context, completion: @escaping (Timeline<SimpleProviderEntry>) -> ()) {
var entries: [SimpleProviderEntry] = []
// 获取当前时间
let currentDate = Date()
// 创建多个时间点(这里假设每隔一小时更新一次)
for hourOffset in 0..<5 {
let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
var entry = SimpleProviderEntry(date: Date())
entry.date = entry
entries.append(entry)
}
// 定义更新时间策略为“每次到期后”更新
let timeline = Timeline(entries: entries, policy: .atEnd)
// 返回生成的时间线
completion(timeline)
}
}

它会通过以下三个方法提供数据:
1)placeholder(in:)
该方法提供一个初始的“占位数据”。
它主要用于在小组件未加载或正在加载时,显示一个默认的或占位的数据。通常只在 Widget 刚被添加到屏幕或首次加载时使用。
在真机的测试过程中,没有查看到placeholder实际的显示。
可能指的是,在没有数据的情况下显示的空白状态。

2)getSnapshot(in:completion:)
快照方法会获取一个小组件在特定时刻的快照数据。这个方法返回的数据通常用于小组件的预览(例如小组件在 Widget Gallery 中的展示)。
这个数据只在需要快速显示的情况下使用。

在getSnapshot方法中使用 completion(entry) 而不是直接返回 SimpleProviderEntry 是因为 getSnapshot 是一个异步操作的回调方法。
func placeholder(in context: Context) -> SimpleProviderEntry {
SimpleProviderEntry(date: Date()) // 返回 SimpleProviderEntry
}
func getSnapshot(in context: Context, completion: @escaping (SimpleProviderEntry) -> ()) {
let entry = SimpleProviderEntry(date: Date())
completion(entry) // 返回 completion 闭包
}
getSnapshot 方法的目的是在提供小组件的快照数据时,给系统一个机会来异步地准备这些数据。通常,系统会异步地请求小组件的快照,以确保它在UI线程之外获取数据并渲染。为了能够处理异步的操作(例如从网络或数据库加载数据),系统使用了回调(completion 闭包)来将处理后的数据返回给调用方。
异步操作:如果需要进行一些数据加载、计算或其他操作,使用 completion 让系统能够在数据准备好时通过回调将其传递回来。例如,从网络或数据库读取数据时,不能保证数据是立即可用的,因此需要一个回调来处理数据。
为什么不直接返回数据?
Swift 的异步操作设计要求不能直接返回结果,而是通过闭包或其他异步机制传递结果。在 getSnapshot 方法中,返回一个 completion 闭包是告诉系统:“我已经准备好数据了,现在将数据通过回调传回”。这样可以让系统在不阻塞主线程的情况下继续处理其他任务,直到数据准备好。
如果直接返回数据,而不通过回调,就会违反异步操作的设计模式,可能导致数据处理阻塞主线程,甚至引发 UI 更新的问题。
3)getTimeline(in:completion:)
该方法最重要,它返回一个 Timeline,这是包含时间点数据的数组。每个 TimelineEntry 表示小组件在某个时间点应该展示的数据。
小组件通过 getTimeline 定义数据的更新时间,例如每隔一定时间更新一次。它会告诉系统小组件在什么时候(例如在某个时间点)需要显示什么数据。
通过 TimelinePolicy(例如 .atEnd,.after 等)来告诉系统如何处理时间点更新的频率。

func getTimeline(in context: Context, completion: @escaping (Timeline<SimpleProviderEntry>) -> ()) {
var entries: [SimpleProviderEntry] = []
// 获取当前时间
let currentDate = Date()
// 创建多个时间点(这里假设每隔一小时更新一次)
for hourOffset in 0..<5 {
let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
var entry = SimpleProviderEntry(date: Date())
entry.date = entry
entries.append(entry)
}
// 定义更新时间策略为“每次到期后”更新
let timeline = Timeline(entries: entries, policy: .atEnd)
// 返回生成的时间线
completion(timeline)
}
Timeline<SimpleProviderEntry>是什么意思?
Timeline< SimpleProviderEntry > 是一个泛型类型,它代表了一个时间线,包含了一系列的 TimelineEntry 数据。具体来说,Timeline< SimpleProviderEntry > 中的 SimpleProviderEntry是定义的数据类型,表示每个时间点的小组件数据。

Timeline: 时间线对象,它管理了多个 TimelineEntry(在这个例子中是 SimpleProviderEntry)。它不仅仅是一个单纯的数据结构,还包含了时间更新的策略,告诉系统何时更新小组件。
TimelineEntry: 每个时间点的数据对象,包含了在特定时刻需要展示的数据。这个数据通常是通过 TimelineProvider 提供的。
所以,Timeline< SimpleProviderEntry > 表示一个时间线,它包含了多个 SimpleProviderEntry类型的数据条目。

为什么需要设计一个 entries 数组?
entries 数组用于保存一系列在未来时刻需要展示的数据。小组件不仅仅会展示当前的数据,还会展示一段时间内的数据。通过将多个 SimpleProviderEntry加入 entries 数组,系统可以按时间顺序呈现这些数据。
SimpleProviderEntry(date: Date())只是返回一个 SimpleProviderEntry的数据,但在 getTimeline 中,通常需要为多个时间点准备数据。比如希望小组件在接下来的几小时内,每小时更新一次内容。这样你就需要为每个小时生成一个 SimpleProviderEntry,并将它们存储在 entries 数组中。
直接返回SimpleProviderEntry(date: Date()) 只会得到一个数据,它不会为多个时间点生成数据,这样就无法形成一个时间线(Timeline)。
let timeline = Timeline(entries: entries, policy: .atEnd) 这段代码是什么意思?
这段代码创建了一个 Timeline 对象,它包含了 entries 数组中的多个条目,并设置了时间更新的策略。
entries: entries: 这里传入准备好的多个 BankletWidgetEntry 数据(也就是时间点的数据),这些数据会按照时间顺序在小组件中显示。
policy: .atEnd: 这个参数设置了小组件更新时间的策略。在这个例子中,.atEnd 表示小组件会在时间线的最后一个条目到期后自动更新。如果设置为 .after(Date),小组件将在指定的时间点更新。选择 .atEnd 是为了让系统在最后一个条目到期后自动更新整个时间线。
重复的时间点问题
// 获取当前时间
let currentDate = Date()
// 创建多个时间点(这里假设每隔一小时更新一次)
for hourOffset in 0..<5 {
let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
var entry = SimpleProviderEntry(date: Date())
entry.date = entry
entries.append(entry)
}
// 定义更新时间策略为“每次到期后”更新
let timeline = Timeline(entries: entries, policy: .atEnd)
在这个代码中,需要理解的是通过hourOffset创建了五个时间点,假设设置每小时更新一次,创建 5 个时间点,分别是:
当前时间(例如:2025年2月16日 10:00)
当前时间 + 1 小时(例如:2025年2月16日 11:00)
当前时间 + 2 小时(例如:2025年2月16日 12:00)
当前时间 + 3 小时(例如:2025年2月16日 13:00)
当前时间 + 4 小时(例如:2025年2月16日 14:00)
这些时间点的更新顺序是由 getTimeline 方法中定义的。系统会逐个展示这些时间点的数据,直到最后一个时间点到期。
当 Timeline 中的最后一个时间点(例如:2025年2月16日 14:00)到期后,系统会重新调用 getTimeline 方法来获取新的数据和时间点。
这个“重新调用”是非常重要的,它让小组件能够在新的时间点进行更新。这意味着并不是简单地每小时返回一个数据条目,而是每个时间点都有一个更新周期,并且每个周期结束后,系统会重新请求下一个时间段的数据。
为什么需要多个时间点而不是一个时间点
可以设置 1 个时间点来代表“每小时更新一次”,也可以设置 5 个时间点来代表 5 小时的更新。为什么要有多个时间点,而不是只设置一个?
周期性更新的优势:通过多个时间点,系统能够提前知道未来几个时间点需要更新什么数据,并且在这些时间点之间继续更新。如果设置了 1 个时间点,虽然系统知道需要更新,但是它并不知道在未来的某个时刻是否还需要继续更新,直到重新调用 getTimeline。
“重复”调用 getTimeline 是正常的:完全可以每小时返回一个时间点,这和设置多个时间点没什么区别。本质上,每次返回一个新的时间点数据,系统都会调用 getTimeline 更新小组件,这就是每个时间点被“刷新”的本质。
此外,也可以设置成每隔10分钟更新一次,这里的时间点和时间间隔都是可以自定义的,如果想要每10分钟更新一次,只需要调整hourOffset的计算方式,将每个小时的间隔从“1小时”改为“10分钟”。
// 创建多个时间点(每隔 10 分钟更新一次)
for minuteOffset in 0..<6 { // 假设你想生成 6 个时间点(即 60 分钟)
let entryDate = Calendar.current.date(byAdding: .minute, value: minuteOffset * 10, to: currentDate)!
var entry = loadBankletData()
entry.date = entryDate
entries.append(entry)
}
如果想要实现每小时更新一次,可以一次性定义从当前时刻起的未来24小时的每个小时的时间点,可以生成 24 个时间点(例如,从当前时刻起的 24 小时内,每小时更新一次)。这种方式适合在较长时间周期内预先设定每小时的数据。
func getTimeline(in context: Context, completion: @escaping (Timeline<SimpleProviderEntry>) -> ()) {
var entries: [SimpleProviderEntry] = []
// 获取当前时间
let currentDate = Date()
// 创建多个时间点(每隔一小时更新一次,共 24 个小时)
for hourOffset in 0..<24 { // 24小时内的每个小时
let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
var entry = SimpleProviderEntry(date: Date())
entry.date = entryDate
entries.append(entry)
}
// 定义更新时间策略为“每次到期后”更新
let timeline = Timeline(entries: entries, policy: .atEnd)
// 返回生成的时间线
completion(timeline)
}
如果只希望每小时更新一次,而不需要在 getTimeline 中生成 24 个时间点,只需返回当前时间点,并通过更新策略让系统知道在每个小时到期后重新调用 getTimeline。
在这种情况下,将每次只生成当前时间点的数据,然后告诉系统每小时更新一次。
func getTimeline(in context: Context, completion: @escaping (Timeline<SimpleProviderEntry>) -> ()) {
var entries: [SimpleProviderEntry] = []
// 获取当前时间
let currentDate = Date()
// 仅生成当前时间点的条目
var entry = SimpleProviderEntry(date: Date())
entry.date = currentDate
entries.append(entry)
// 定义更新时间策略为“每次到期后”更新
let timeline = Timeline(entries: entries, policy: .after(Date().addingTimeInterval(3600)))
// 返回生成的时间线
completion(timeline)
}
policy: .after(Date().addingTimeInterval(3600))
这个配置指定了 时间线将在当前时间之后的一小时更新。
Date().addingTimeInterval(3600) 计算出当前时间加上 3600 秒(即 1 小时)。
这意味着 小组件将会在 1 小时后重新调用 getTimeline 来更新数据。
所以指定了一个明确的时间点,系统会在这个时间点后触发更新。
效果:系统将在当前时间之后的 1 小时自动更新(即触发下一个周期)。
注意:这里使用的是.after,表示小组件按时更新,如果设置为 .atEnd,则会根据条目的展示周期来触发更新,通常由每个条目展示的结束时刻来更新数据。
2、时间轴与时间点(Entries)
每个 TimelineEntry 表示一个时间点及该时间点要显示的数据。TimelineProvider 负责创建这些条目,并定义它们在时间轴上的更新方式。时间点的创建是根据 TimelineProvider 的方法,特别是 getTimeline(in:completion:) 中的逻辑。
关键步骤:
1、数据创建:在 getTimeline(in:completion:) 方法中创建数据。例如,根据当前时间、用户数据、网络请求等来生成这些数据。
2、数据返回:将数据封装在 TimelineEntry 中,并将它们通过 Timeline 返回。
3、数据展示:当系统需要更新小组件时,它会通过 TimelineEntry 提供的数据来更新视图。
3、数据更新策略
TimelinePolicy 定义了小组件数据更新的策略。通过不同的策略来控制小组件的刷新频率:
.atEnd:表示数据会在每个时间点的结束时更新。
.after:表示数据会在指定的时间间隔后更新。
.never:表示不进行更新。
4、数据流的具体实现

为了更清楚地了解数据流动的具体实现,下面将分步讲解如何通过 TimelineProvider 提供数据,并确保小组件正确地显示和更新:
1)定义 TimelineEntry 和数据结构
struct BankletWidgetEntry: TimelineEntry {
var date: Date
let piggyBankIcon: String
let piggyBankName: String
let piggyBankAmount: Double
let piggyBankTargetAmount: Double
let loopAnimation: String
let background: String
}
这里我们定义了一个 TimelineEntry 的数据结构 BankletWidgetEntry,它包含了展示所需的所有数据(如存钱罐图标、名称、金额等)。
2)实现 TimelineProvider
struct BankletWidgetProvider: TimelineProvider {
func placeholder(in context: Context) -> BankletWidgetEntry {
loadBankletData()
}
func getSnapshot(in context: Context, completion: @escaping (BankletWidgetEntry) -> ()) {
let entry = loadBankletData()
completion(entry)
}
func getTimeline(in context: Context, completion: @escaping (Timeline<BankletWidgetEntry>) -> ()) {
var entries: [BankletWidgetEntry] = []
// 获取当前时间
let currentDate = Date()
// 创建多个时间点(这里假设每隔一小时更新一次)
for hourOffset in 0..<5 {
let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
var entry = loadBankletData()
entry.date = entryDate
entries.append(entry)
}
// 定义更新时间策略为“每次到期后”更新
let timeline = Timeline(entries: entries, policy: .atEnd)
// 返回生成的时间线
completion(timeline)
}
private func loadBankletData() -> BankletWidgetEntry {
// 从 UserDefaults 中获取存钱罐的数据
let userDefaults = UserDefaults(suiteName: "group.com.fangjunyu.piglet")
let piggyBankIcon = userDefaults?.string(forKey: "piggyBankIcon") ?? "dollarsign"
let piggyBankName = userDefaults?.string(forKey: "piggyBankName") ?? "PiggyBank"
let piggyBankAmount = userDefaults?.double(forKey: "piggyBankAmount") ?? 100.0
let piggyBankTargetAmount = userDefaults?.double(forKey: "piggyBankTargetAmount") ?? 10.0
let loopAnimation = userDefaults?.string(forKey: "LoopAnimation") ?? "Home0"
let background = userDefaults?.string(forKey: "background") ?? "bg0"
// 返回加载的数据
return BankletWidgetEntry(
date: Date(),
piggyBankIcon: piggyBankIcon,
piggyBankName: piggyBankName,
piggyBankAmount: piggyBankAmount,
piggyBankTargetAmount: piggyBankTargetAmount,
loopAnimation: loopAnimation,
background: background
)
}
}
placeholder(in:):当小组件刚加入屏幕时,提供一个占位数据。这里返回了一个默认的数据。
getSnapshot(in:completion:):提供一个快照数据,通常用于 Widget Gallery 预览时展示。
getTimeline(in:completion:):生成一个包含多个时间点(BankletWidgetEntry)的时间线,并定义更新时间策略。我们在这里每小时生成一个数据条目。
因为主应用中的数据是通过App Group共享数据,因此TimelineProvider可以通过App Group读取存储的数据,将其放入到BankletWidgetEntry中。
3)配置小组件
struct BankletWidget: Widget {
let kind: String = "BankletWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: BankletWidgetProvider()) { entry in
BankletWidgetEntryView(entry: entry)
.containerBackground(for: .widget) {
Image("WidgetBackground")
}
}
.supportedFamilies([.systemSmall]) // 支持小尺寸
.configurationDisplayName("Progress widget") // 小组件的显示名称
.description("Shows the current progress percentage of the piggy bank.") // 小组件的描述
}
}
StaticConfiguration 用来创建静态的小组件配置,使用 BankletWidgetProvider 来提供数据。
在这里,entry 会接收来自 TimelineProvider 的数据,并交给 BankletWidgetEntryView 来渲染视图。
4)小组件的视图渲染
struct BankletWidgetEntryView : View {
var entry: BankletWidgetEntry
var SavingProgress:Double {
// 防止除以零的错误
guard entry.piggyBankTargetAmount != 0 else { return 0 }
return max(min(entry.piggyBankAmount / entry.piggyBankTargetAmount * 100,100),0)
}
var body: some View {
GeometryReader { geometry in
// 通过 `geometry` 获取布局信息
let height = geometry.size.height * 0.5
VStack(spacing: 0) {
VStack(spacing: 0) {
// 显示存钱罐图标
Circle()
.frame(width: 24,height: 24)
.foregroundColor(.white)
.overlay {
Image(systemName: entry.piggyBankIcon)
.imageScale(.small)
.foregroundColor(Color(hex:"FF4B00"))
}
Spacer()
.frame(height: 5)
// 读取主应用存储的存钱罐数据
Text(entry.piggyBankName)
.foregroundColor(.white)
.widgetAccentable()
}
.frame(height: height)
.frame(maxWidth: .infinity)
Spacer()
HStack(spacing: 0) {
Rectangle()
.frame(width: 60, height: 40)
.foregroundColor(Color(hex:"FF4B00"))
.cornerRadius(10)
.overlay {
Text("\(SavingProgress.formattedWithTwoDecimalPlaces()) %")
.foregroundColor(.white)
}
RightTriangle()
.frame(width: 10,height:10)
.foregroundColor(Color(hex:"FF4B00"))
Image(entry.loopAnimation) // 显示存钱罐图标
.resizable()
.scaledToFit()
.imageScale(.large)
}
}
.font(.footnote)
.frame(maxWidth: .infinity,maxHeight: .infinity)
.padding(0)
}
}
}
在 BankletWidgetEntryView 中,通过 entry 获取数据(如存钱罐的名称、金额等),然后根据这些数据渲染视图。
entry就是在第一步定义的数据结构 BankletWidgetEntry。
最后,在真机上运行正常。

总结
1、数据来源:数据通过 TimelineProvider 的 getTimeline 方法提供。每个 TimelineEntry 保存小组件在某一时刻的数据。
2、时间更新:TimelineProvider 还定义了数据更新时间的策略,如 .atEnd 表示每次时间结束时更新,.after 表示延迟更新。
3、视图渲染:视图渲染通过获取每个时间点的数据 (TimelineEntry),然后在视图中展示这些数据。
TimelineProvider 提供数据,而视图根据这些数据渲染,不要直接在视图中做数据操作,数据应该由 TimelineProvider 提供并传递给视图。
扩展知识
TimelineEntry 的实际应用
TimelineEntry 是用于将数据结构化并与时间绑定的。在小组件中,数据并不是一次性加载的,而是根据时间轴(Timeline)来逐步更新的。小组件的生命周期和更新机制跟普通的 iOS 应用有所不同,它并不是一直在后台实时运行,而是依据系统的策略和时间策略来更新。TimelineEntry 是每一个时间点的数据快照,它承载了小组件在某一时刻需要展示的内容。
小组件的更新主要分为两种类型:
1、定时更新:如每小时、每天更新一次数据。
2、事件触发更新:如用户交互后、系统事件触发时更新。
而Timeline和TimelineEntry的设计是为了在这些情况下提供一种高效的更新机制。
为什么需要 TimelineEntry?
1、与时间绑定:
TimelineEntry 与时间轴(Timeline)紧密结合。一个 Timeline 由多个 TimelineEntry 组成,每个条目对应一个时间点。在不同的时间点,条目会显示不同的数据。这就是 TimelineEntry 的核心作用:它帮助描述某个时刻的“小组件状态”。
2、分时更新:
不可能一次性展示所有数据,而是需要在不同的时刻更新小组件的内容。TimelineEntry 确保每个时间点展示的数据是独立且正确的,并且能支持动态更新。比如希望每小时更新一次存钱进度,TimelineEntry 就会根据每个小时的数据来提供新的条目。
3、提供数据:
TimelineProvider 通过 getTimeline(in:completion:) 方法提供一个时间轴,时间轴由多个 TimelineEntry 构成。每个条目包含当前时间的相关数据,并被传递给视图进行展示。这里的数据结构和视图相分离,数据的变化是根据时间更新的,而视图会根据这些更新显示不同的内容。
为什么需要时间轴(Timeline)来逐步更新数据?
1、节省资源:
小组件不会像普通应用一样持续运行,它会在系统允许的情况下刷新。每次刷新是由系统决定的(比如系统空闲时或者当显示该小组件的屏幕切换到前台时)。而不是每秒钟都实时拉取数据,否则可能造成过度的电池消耗和资源浪费。
如果小组件总是实时显示最新数据,它就会不断地请求数据源,这不仅对电池耗电有影响,且会导致频繁的数据加载,造成性能问题。通过时间轴(Timeline)来控制更新时间,就能减少不必要的频繁请求。
2、减少更新频率:
小组件的内容并不需要时刻实时变化。例如,存钱进度每小时更新一次,这并不会对用户造成很大的影响,因此可以通过TimelineEntry来控制每小时更新一次。
Timeline提供了设定刷新间隔的能力。可以根据需求设置更新周期(例如每小时更新、每天更新或根据某些事件更新),而不是每次都拉取最新数据。
为什么说数据不是一次性加载的,而是逐步更新的?
每个 TimelineEntry 表示在某一时刻的“快照”数据。不需要为每一秒钟或每一瞬间的变化都提供数据,而是可以将这些变化按时间分批次更新。例如:
每小时或者每天系统会去刷新小组件,查看当前数据,更新相关显示。通过TimelineEntry,告诉系统下一个更新的时间和更新的内容。
Timeline 和 TimelineProvider 负责按时间给小组件提供新的数据,这样在每个时间点,小组件都能显示正确的信息。
TimelineEntry 和视图中定义数据结构的区别
TimelineEntry 负责“组织”与“时间绑定”的数据,而视图(例如 BankletWidgetEntryView)负责渲染和展示这些数据。

TimelineEntry:
用于提供在特定时间点展示的数据。
包含了数据结构和时间信息。
每个条目都是不可变的,且会随着时间的推移而更新。
它是时间轴的一部分,不直接管理视图,而是通过时间轴为视图提供数据。
视图中的数据结构:
主要用于视图的显示,不涉及时间更新。
在视图中,使用 TimelineEntry 提供的数据来展示内容,通常是 UI 的一部分。
具体的运作流程
1、TimelineProvider 提供数据:
TimelineProvider 的 getTimeline(in:completion:) 方法返回一系列 TimelineEntry,这些条目带有数据,并且每个条目都带有一个时间(即该条目的 date 属性)。这意味着每个条目包含的数据是某个具体时间点的状态。
2、视图使用这些数据:
视图组件(例如 BankletWidgetEntryView)通过传入的 TimelineEntry 数据来展示具体的 UI 内容。视图通过 entry 参数获取 TimelineEntry 的数据,进行 UI 渲染。
3、时间更新:
随着时间的流逝,TimelineProvider 会返回新的 TimelineEntry,更新小组件的显示内容。这样,小组件就会根据时间的变化更新显示的数据。