小组件、灵动岛Live Activities与App Intents:MeteoHealth的应用生态扩展实践#
很长一段时间里,"一个好应用"意味着"应用内的一个好界面"。打开、查看、点击、关闭。但从iOS 16到iOS 18,这个定义已经不够用了:用户越来越多地在不打开应用的情况下,就能触及你的应用逻辑——从主屏幕、从灵动岛、通过Siri语音,或者在控制中心里轻轻一点。
在打磨MeteoHealth——一款把天气、睡眠、心率和真实体感连接在一起的应用——的过程中,我逐渐意识到,用户最频繁的操作("记一杯水"、"看看恢复预测"、"瞄一眼训练进度")根本不值得完整启动一次应用。于是,交互式小组件、灵动岛中的Live Activities、基于App Intents构建的Siri指令,以及带有多种复杂功能的完整Apple Watch应用,就这样进入了这款产品。这篇文章会从代码和架构的层面,讲讲它们是怎么搭起来的,以及哪些决定真正带来了回报。
为什么这不只是一个"打勾功能"#
三年前,小组件还只是一张由计时器每隔15到30分钟刷新一次的静态图片。从iOS 17开始,小组件获得了按钮和开关,可以在原地直接执行代码,无需打开应用——这背后是App Intents框架。iOS 18又在此之上加了第三条通道:控制(Controls),它们存在于控制中心、锁屏,还能被指定到操作按钮上。
对MeteoHealth这样的应用来说,这不是装饰,而是消除"我想到要做X"和"X已经完成"之间摩擦的方式。记录喝水、瞄一眼24小时体感风险预测、训练途中查看心率——只要不需要打开应用,这一切都会更快。
交互式小组件:把AppIntent直接放到主屏幕上#
交互式小组件的核心思路是:小组件里的按钮或开关并不会打开应用,而是直接执行一个遵循AppIntent协议的结构体。至于是在后台启动进程,还是复用共享数据容器,这些都由系统决定;从代码的角度看,你只需要描述应该发生什么。
import AppIntents
import WidgetKit
struct LogWaterIntent: AppIntent {
static var title: LocalizedStringResource = "Log a Glass of Water"
static var description = IntentDescription("Adds 250 ml to today's water intake")
@Parameter(title: "Amount (ml)", default: 250)
var amountML: Int
func perform() async throws -> some IntentResult {
try await HydrationStore.shared.addWater(milliliters: amountML)
WidgetCenter.shared.reloadTimelines(ofKind: "HydrationWidget")
return .result()
}
}下面这段代码展示了同一个intent如何直接接入小组件的SwiftUI布局——不需要代理,不需要openURL,也不需要中间页面。
struct HydrationWidgetView: View {
var entry: HydrationEntry
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Water today")
.font(.caption)
.foregroundStyle(.secondary)
Text("\(entry.totalML) ml")
.font(.title2.bold())
Button(intent: LogWaterIntent(amountML: 250)) {
Label("+250 ml", systemImage: "drop.fill")
}
.buttonStyle(.borderedProminent)
.tint(.cyan)
}
.padding()
}
}我在第一版实现里踩过一个坑:如果应用和小组件的存储没有同步(MeteoHealth用的是App Group加Core Data,而不是单独的数据库),点击按钮后,小组件显示的状态就会落后于应用本身。在perform()里显式调用WidgetCenter.shared.reloadTimelines不是可选项——没有它,小组件的界面根本无从得知数据已经变化,只能等到下一次计划中的时间线刷新。
Live Activities与灵动岛:训练状态时刻可见#
Live Activities解决的是另一个问题——不是"一次快速操作",而是"针对一个有时限的事件,持续更新的状态"。在MeteoHealth里,这对应的是训练:跑步或力量训练进行时,锁屏和灵动岛会持续显示实时心率、时长和消耗的卡路里,用户完全不需要解锁手机。
一切从用ActivityAttributes描述状态开始。
import ActivityKit
struct WorkoutAttributes: ActivityAttributes {
struct ContentState: Codable, Hashable {
var heartRate: Int
var elapsedSeconds: Int
var caloriesBurned: Int
}
var workoutType: String
}接下来是从主应用启动这个活动——通常发生在用户在Apple Watch或应用本身开始一次训练的那一刻。
func startWorkoutActivity(type: String) {
let attributes = WorkoutAttributes(workoutType: type)
let initialState = WorkoutAttributes.ContentState(
heartRate: 0,
elapsedSeconds: 0,
caloriesBurned: 0
)
do {
let activity = try Activity<WorkoutAttributes>.request(
attributes: attributes,
content: .init(state: initialState, staleDate: nil),
pushType: .token
)
print("Started Live Activity: \(activity.id)")
} catch {
print("Failed to start Live Activity: \(error)")
}
}最后是灵动岛本身的布局——分别定义紧凑、极简和展开三种呈现方式。
struct WorkoutLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: WorkoutAttributes.self) { context in
WorkoutLockScreenView(context: context)
} dynamicIsland: { context in
DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
Label("\(context.state.heartRate)", systemImage: "heart.fill")
}
DynamicIslandExpandedRegion(.trailing) {
Text(context.state.elapsedSeconds.formattedDuration)
}
DynamicIslandExpandedRegion(.bottom) {
Text("\(context.state.caloriesBurned) kcal")
}
} compactLeading: {
Image(systemName: "heart.fill")
} compactTrailing: {
Text("\(context.state.heartRate)")
} minimal: {
Image(systemName: "heart.fill")
}
}
}
}有一个实践中的细节:每秒更新几十次ContentState并不是个好主意。Live Activities会限制更新频率,过于频繁的调用会被系统直接丢弃或合并。在MeteoHealth里,只要应用或Watch伴侣应用处于活跃状态,灵动岛里的心率就会每隔几秒在本地刷新一次——这已经足以维持"数据是活的"这种感觉,同时又不会给系统带来过大压力。
App Intents与Siri:"嘿Siri,帮我记一杯水"#
驱动小组件按钮的这套AppIntent协议,同样也是语音指令、快捷指令(Shortcuts)以及聚焦搜索(Spotlight)建议的基础。区别在于AppShortcutsProvider这个封装,它告诉系统哪些自然语言短语应该触发这个intent。
struct LogWaterGlassIntent: AppIntent {
static var title: LocalizedStringResource = "Log a Glass of Water"
static var openAppWhenRun = false
func perform() async throws -> some IntentResult & ProvidesDialog {
try await HydrationStore.shared.addWater(milliliters: 250)
return .result(dialog: "Logged a glass of water")
}
}
struct MeteoHealthShortcuts: AppShortcutsProvider {
static var appShortcuts: [AppShortcut] {
AppShortcut(
intent: LogWaterGlassIntent(),
phrases: [
"Log a glass of water in \(.applicationName)",
"Add water in \(.applicationName)"
],
shortTitle: "Log Water",
systemImageName: "drop.fill"
)
}
}这里的openAppWhenRun = false标志至关重要:没有它,Siri会先打开应用,然后才执行动作——而这恰恰是我们想要去掉的那个多余步骤。设为false之后,整个指令都是即时完成的:用户说出短语,通过ProvidesDialog听到确认反馈,而应用本身根本不会出现在屏幕上。
iOS 18控制:控制中心里的快捷操作#
第三条通道随iOS 18而来:控制(Controls),存在于控制中心、锁屏,还能被指定到操作按钮上。从技术上讲,这是在WidgetKit之上又叠加的一层,但它使用了不同的展示模板——ControlWidgetButton或开关——并且对即时响应的要求也严格得多。
struct HydrationControl: ControlWidget {
var body: some ControlWidgetConfiguration {
StaticControlConfiguration(
kind: "com.meteohealth.hydration-control"
) {
ControlWidgetButton(action: LogWaterIntent(amountML: 250)) {
Label("Log Water", systemImage: "drop.fill")
}
}
.displayName("Log Water")
.description("Quickly add a glass of water from Control Center")
}
}注意,这里复用了和小组件完全相同的LogWaterIntent。这不是巧合,而是刻意的设计:同一个AppIntent可以在三个界面(小组件、Siri指令、控制)之间复用,而不需要重复业务逻辑。正是这一点,让App Intents从"Siri的一个功能"变成了整个应用统一的操作层。
Watch应用与复杂功能:手腕上同样的intent语言#
MeteoHealth自带一款完整的Apple Watch应用,而不只是手机屏幕的镜像——它为不同表盘提供了复杂功能,可以显示当前的体感风险预测或心率。watchOS的复杂功能使用的是和iPhone小组件相同的WidgetKit加时间线提供者的组合,因此大部分时间线代码只需要写一次,就能在两个平台上运行,只有针对不同表盘外形的布局存在细微差异。
从手腕上快速记录数据——比如训练结束后立刻记一个症状或喝水情况——用的也是和iPhone上完全相同的AppIntent结构体。SwiftUI代码是各平台特有的,但业务逻辑和intent的定义是共享的。这明显缩小了出bug的范围:只要HydrationStore.shared.addWater正确工作一次,它在被调用的任何地方都会正确工作。
该选哪个:小组件、Live Activity还是控制#
这三种机制覆盖的是不同的场景,而把它们混为一谈,正是一个功能始终无法真正被用户接受的常见原因。
| 判断标准 | 小组件(WidgetKit) | Live Activity(ActivityKit) | 控制(iOS 18 Controls) |
|---|---|---|---|
| 适用场景 | 变化缓慢的持续性摘要信息 | 有时限的活跃事件(训练、计时器、配送) | 一次快速操作,一次点击完成 |
| 显示位置 | 主屏幕、锁屏、随倚显示 | 锁屏、灵动岛 | 控制中心、锁屏、操作按钮 |
| 生命周期 | 数小时到数天,按计划刷新 | 有限——长时间没有更新后活动会消失 | 持续存在,状态按需获取 |
| 数据更新方式 | 时间线提供者 + WidgetCenter.reloadTimelines | 通过ActivityKit推送,或本地更新ContentState | 点击时执行的AppIntent |
| 交互性 | 通过AppIntent实现的按钮和开关(iOS 17+) | 以展示为主,交互很少 | 完整——按钮和开关本身就是核心 |
我遵循的经验法则是:如果数据需要被"看到",用小组件;如果需要"跟随一个正在发生的过程",用Live Activity;如果需要"以最快速度完成一件事",用控制。
我从这个项目中学到的#
MeteoHealth给我的最大教训是:这三种机制只有建立在一个共享的、已经存在的业务逻辑层之上时,才真正有意义。我并没有为小组件写一套"记水"的实现,为Siri再写一套,为控制又写一套——它们全都使用同一个AppIntent,而所有的存储逻辑都存在于一个共享的App Group里,应用、小组件和Watch复杂功能都能访问到它。
第二个教训是状态同步。WidgetKit和ActivityKit并不会自己发现变化:必须显式调用WidgetCenter.shared.reloadTimelines并更新ContentState——而这正是"点了按钮,屏幕上却什么都没变"这类bug最常见的源头。
第三个教训是:围绕应用构建的这套生态,只有在用户操作真正简短的地方才能奏效——一杯水、瞄一眼预测、开始一次训练。一旦perform()里的逻辑开始需要复杂的界面或多个步骤,那就是一个信号:这个功能应该留在应用内部,而不是搬到小组件或语音指令里去。



