iOS 26 上的 HealthKit + SwiftUI:来自两款已上架应用的授权流程、样本类型与跨平台模式
HealthKit 是在 SwiftUI 应用中正确上架时较为棘手的 Apple 框架之一。其授权流程存在一个瞬时失败陷阱,可能会永久性地将用户拒之门外。样本类型在定量数据(用于饮水量、步数、卡路里的 HKQuantitySample)与分类数据(用于正念会话、睡眠、月经流量的 HKCategorySample)之间被尴尬地分割开来。async API 接口要求使用 withCheckedThrowingContinuation 来封装基于回调的 HealthKit 调用。而在 watchOS 上,模式又会再次变化。
我已经在两款生产应用中上架了 HealthKit:Water(饮水量追踪,约 192 行的 HealthKitService)1 和 Return(正念会话记录,约 171 行的 HealthKitManager 加上 155 行的 HealthKitPermissionSheet)。2 二者共同覆盖了 HealthKit 两种主要的样本形态、两个方向(读+写 vs. 仅写)以及单平台与跨平台部署。
本文将讲解经受住生产考验的模式:预权限 UX、SwiftUI .healthDataAccessRequest 修饰符 vs. 旧版 requestAuthorization API、用于样本查询的 async 封装,以及 watchOS 的特定差异。
TL;DR
- 授权状态报告是不对称的。Apple 的 API 会可靠地告诉您”分享已授权”,但出于隐私原因,不会告诉您”读取被拒绝”。本文涵盖了如何在不推断读取状态的情况下检测”用户已被询问但未授权”。
- 定量数据使用
HKQuantitySample、HKQuantityType与HKUnit(Water 使用.literUnit(with: .milli)表示饮水量)。分类数据使用HKCategorySample与HKCategoryType(Return 使用.mindfulSession)。 - 预权限弹窗是最常被跳过的模式。Apple 的系统权限对话框枯燥乏味;先展示一个自定义的
View来说明其价值,可以显著提高授权率。 - watchOS HealthKit 需要单独的
HKHealthStore实例,具有更严格的授权重新提示规则,且无法显示 SwiftUI 弹窗用于预权限 UX。 - Return 中的
recordAuthorizationAttempt()模式可防止瞬时呈现失败被视为永久拒绝。
两种样本形态
HealthKit 沿着一个影响您所写每一行代码的轴线划分了其样本模型:3
| 样本形态 | 典型用途 | API |
|---|---|---|
HKQuantitySample |
饮水、步数、卡路里、体重、心率 | HKQuantityType + HKUnit + HKQuantity |
HKCategorySample |
正念会话、睡眠、月经流量、性活动 | HKCategoryType + HKCategoryValue |
HKWorkout |
结构化运动(跑步、游泳) | HKWorkoutBuilder、HKWorkoutSession |
饮水属于定量。来自 HealthKitService.swift 的真实生产代码:1
import HealthKit
@Observable
final class HealthKitService {
static let shared = HealthKitService()
private let healthStore = HKHealthStore()
private let waterType = HKQuantityType(.dietaryWater)
func logWater(amount: Double, date: Date = .now) async throws -> UUID {
guard isAuthorized else { throw HealthKitError.notAuthorized }
let quantity = HKQuantity(unit: .literUnit(with: .milli), doubleValue: amount)
let sample = HKQuantitySample(
type: waterType,
quantity: quantity,
start: date,
end: date,
metadata: [HKMetadataKeyWasUserEntered: true]
)
try await healthStore.save(sample)
return sample.uuid
}
}
来自生产环境的三个细节:
.literUnit(with: .milli)是表示毫升级饮水量的规范单位。HealthKit 会接受任何单位(美制液量盎司、升),但构造器很重要,因为 Apple 在内部将所有内容标准化为升。选择.milli可让您存储整数值(240、500)而不是分数升(0.240、0.500)。HKMetadataKeyWasUserEntered: true将该样本标记为手动输入而非测量得出。Health 应用会在这些样本上显示一个小的”手动输入”指示器;用户对手动输入数据的信任度与秤测量数据不同。- 对于即时样本,
start和end是同一个Date。数量在[start, end]窗口内累积,但对于”我刚刚喝了 240 毫升”这种情况,窗口收缩为一个时间点。
正念会话则属于分类。来自 Return 的 HealthKitManager.swift 的真实生产代码:2
import HealthKit
@MainActor
class HealthKitManager {
static let shared = HealthKitManager()
let healthStore = HKHealthStore()
let mindfulType = HKCategoryType(.mindfulSession)
func saveMindfulSession(start: Date, end: Date) async -> Bool {
guard isAvailable else { return false }
guard end > start else { return false }
let sample = HKCategorySample(
type: mindfulType,
value: HKCategoryValue.notApplicable.rawValue,
start: start,
end: end
)
// ... save via healthStore.save(sample) ...
}
}
两个细节:
HKCategoryValue.notApplicable.rawValue是承重的哨兵值。正念会话没有有意义的”值”(它们是时长标记),因此 HealthKit 要求一个哨兵分类值(Int(0))来满足该字段的类型系统。其他分类样本则有更丰富的值:睡眠使用HKCategoryValueSleepAnalysis.asleep等。- 对分类样本而言,
start/end窗口是真实存在的。一次 10 分钟的正念会话其start和end相距 10 分钟,而 HealthKit 的每日正念分钟统计会汇总这些时长。
授权流程(及其陷阱)
HealthKit 的授权设计上是不对称的。authorizationStatus(for:) 会真实地报告分享/写入状态(.sharingAuthorized、.sharingDenied、.notDetermined),但读取的授予或拒绝无法通过该 API 直接观察。Apple 故意隐藏读取状态,以防止应用推断用户 Health 资料中存在哪些数据。4 您只能间接得知读取决定:如果用户授予了读取权限,查询会返回数据;如果用户拒绝了读取,查询会返回空结果。您的代码无法基于”读取被拒绝”这样的信号进行分支判断。
两款应用以不同方式来应对这一点。
Water 读取自己的样本。 由于 Water 既写入也读取饮水条目(用于填充”今日历史记录”),其授权流程必须处理读取拒绝不可见的情况:1
private var typesToShare: Set<HKSampleType> { [waterType] }
private var typesToRead: Set<HKSampleType> { [waterType] }
func requestAuthorization() async throws {
guard isHealthDataAvailable else { throw HealthKitError.notAvailable }
try await healthStore.requestAuthorization(toShare: typesToShare, read: typesToRead)
await MainActor.run { checkAuthorizationStatus() }
}
func checkAuthorizationStatus() {
authorizationStatus = healthStore.authorizationStatus(for: waterType)
isAuthorized = authorizationStatus == .sharingAuthorized
}
请注意 checkAuthorizationStatus 没有做的事:询问读取授权状态。Water 仅检查分享状态。如果用户授予了分享但拒绝了读取,Water 的 UI 将显示”无条目”,因为读取返回空(而不是因为有显式错误)。Water 信任分享决定,并让数据缺失自己说话。如果用户在意,可以在”设置”中修复。
Return 仅写入。 Return 记录正念会话但从不回读它们;它显示的会话列表来自其自己的 NSUbiquitousKeyValueStore,而非 HealthKit。因此 Return 的授权请求是仅写入的:2
func requestAuthorization() async -> Bool {
guard isAvailable else { return false }
do {
try await healthStore.requestAuthorization(
toShare: [mindfulType],
read: [] // We only need write access
)
hasRequestedHealthKit = authorizationStatus != .notDetermined
return isAuthorizedToWrite()
} catch {
hasRequestedHealthKit = authorizationStatus != .notDetermined
return false
}
}
read: [] 这个空集合是有意为之。请求您不需要的读取访问权限会扩大您必须在 App Review 中说明的权限范围,并且会让用户感到困惑——他们看到”Return 想要读取您的正念会话”,而显然该应用并不需要这样做。
陷阱所在。 两款应用都收敛到了同一个防御性模式:仅在状态实际越过 .notDetermined 时,才将授权尝试标记为”已完成”。简单粗暴的代码是:
hasRequestedHealthKit = true // ❌ wrong: treats transient failures as denial
来自 Return 的正确模式:2
hasRequestedHealthKit = authorizationStatus != .notDetermined // ✓ checks real state
这为何重要:SwiftUI 的 .healthDataAccessRequest 修饰符以及底层的 requestAuthorization API,在某些条件下(弹窗冲突、视图生命周期中断、瞬时操作系统状态)可能无法呈现系统对话框。如果您在检查实际状态之前就将该尝试标记为”已完成”,就会把用户困在这样一种状态中:您的应用认为他们已拒绝,但系统对话框从未出现过。除非他们前往”设置 → 隐私 → 健康”手动授权,否则他们将无路可退——而由于他们从未看到您的提示,他们不会想到这样做。Return 的 recordAuthorizationAttempt() 正是为这种情况而存在的。
预权限弹窗
Apple 的 HealthKit 权限对话框正确但缺乏说服力。它显示一个您的应用想要分享或读取的类型列表,带有切换开关。没有说明应用为什么需要这些数据的上下文,没有好处说明,没有应用品牌。用户会点击”不允许”,因为该提示脱离了上下文。
Return 提供了一个 HealthKitPermissionSheet,它出现在系统对话框之前。该弹窗在 Apple Health 图标旁显示 Return 应用图标(符合 HIG:Apple Health 图标不得被裁剪或加阴影),5 陈述好处(”追踪您的练习”/”将您的冥想会话作为正念分钟保存到 Apple Health”),列出三行好处(”在 Apple Health 中查看您的练习”、”在所有设备间同步”、”与其他健康应用配合使用”),并以单一的前进 Continue 按钮结束,该按钮会触发系统请求。来自 HealthKitPermissionSheet.swift 的真实结构:6
struct HealthKitPermissionSheet: View {
var onEnableRequested: () -> Void
var theme: Theme
var body: some View {
VStack(spacing: 0) {
HStack(spacing: 16) {
Image("ReturnAppIcon")
.resizable().scaledToFit().frame(width: 80, height: 80)
.clipShape(RoundedRectangle(cornerRadius: 18))
Text("+").font(.title)
Image("AppleHealthIcon")
.resizable().scaledToFit().frame(width: 80, height: 80)
// No clipShape; Apple HIG forbids altering the Health icon
}
// Title + subtitle + benefits list
// (See discussion below for the production comment.)
Button { onEnableRequested() } label: {
Text("Continue")
}
}
.interactiveDismissDisabled(true)
}
}
interactiveDismissDisabled(true) 加上没有任何取消按钮是一个有意为之的设计选择。Apple 的《App Review 指南》5.1.1 要求应用尊重用户隐私设置,并禁止操纵、欺骗或强迫用户同意。10 生产代码中 Continue 按钮上方的注释写道:
单一前进操作:系统 HealthKit 对话框拥有真正的 yes/no 选择权。Apple 指南 5.1.1(iv):引导屏幕不得包含任何绕过系统权限请求的退出/关闭路径。
这种表述比指南字面文本更严格(指南谈论的是尊重同意以及不强迫同意,但并未以这些字眼规定引导屏幕的 UX)。这是 Return 的解读,并被编入了源代码。其意图是:拒绝的用户应当在 Apple 的屏幕上拒绝,而不是在 Return 的屏幕上拒绝。结果是一个只有一个前进操作、没有逃生出口的弹窗。文案和好处行必须赢得这次点击;不存在”我再考虑一下”的分支。
流程:
- 用户在 Return 的设置中点击”连接 Health”。
- 预权限弹窗出现。用户阅读说明。
- 用户点击 Continue。当
requestAuthorization运行且系统对话框出现时,弹窗保持挂载状态。 - 用户在系统对话框中接受(或拒绝)。Return 调用
recordAuthorizationAttempt()来捕获结果,然后弹窗关闭。
为何要这样做。 Apple 没有发布关于预权限弹窗在授权率提升方面的官方数据,但每一位与我交谈过的、同时进行了 A/B 测试并有耐心运行该测试的 iOS 开发者都报告了相同的方向:预权限弹窗会显著提高分享授权率。这一模式现已足够普遍,以至于 Apple 自己的模板(照片、相机、定位)也越来越多地包含其自己的预权限 UX。
watchOS 变体
watchOS HealthKit 与 iOS HealthKit 共享相同的 API 接口(HKHealthStore、样本类型、授权),但有三个结构性差异:
- 每个手表应用都有一个新的
HKHealthStore。 手表应用和配对的 iPhone 应用各自拥有自己的HKHealthStore实例。两者都可以写入用户的 HealthKit 数据库,两者都可以读取自己的样本。该 store 在配对设备间不共享。 - 没有用于预权限的 SwiftUI 弹窗。 watchOS 视图层级结构不像 iOS 那样支持弹窗。预权限 UX 必须是全屏的。
- 更严格的重新提示规则。 如果用户先前拒绝过,watchOS 的
requestAuthorization调用在重新呈现系统对话框方面更为保守;您可能需要引导用户前往 iPhone 上的 Watch 应用来更改设置,因为手表本身没有”设置 → 隐私 → 健康”UI。
来自 WatchHealthKitManager.swift 的真实生产代码:7
@MainActor
class WatchHealthKitManager {
static let shared = WatchHealthKitManager()
let healthStore = HKHealthStore()
let mindfulType = HKCategoryType(.mindfulSession)
private init() {}
var isAvailable: Bool { HKHealthStore.isHealthDataAvailable() }
/// Returns true if the system request completed (success or already-authorized).
/// Caller checks isAuthorizedToWrite() separately for the actual share state.
func requestAuthorization() async -> Bool {
guard isAvailable else { return false }
do {
try await healthStore.requestAuthorization(toShare: [mindfulType], read: [])
return true
} catch {
return false
}
}
func isAuthorizedToWrite() -> Bool {
guard isAvailable else { return false }
return healthStore.authorizationStatus(for: mindfulType) == .sharingAuthorized
}
// ... saveMindfulSession identical to iOS variant ...
}
这种拆分是有意的:requestAuthorization 报告系统调用是否成功,而 isAuthorizedToWrite 报告用户所选择的结果。在手表上拆分这两者使代码更易于推理;在 iOS 上,等效的拆分体现为 HealthKitManager 上 recordAuthorizationAttempt() 加 isAuthorizedToWrite() 这一对。
Watch 类的体积大约是 iOS 类的一半,因为它不需要:
hasRequestedHealthKitUserDefaults 标志(手表 UX 需要支持的二次尝试路径较少)。HealthKitPermissionSheet管道(watchOS 上没有弹窗 UI)。recordAuthorizationAttempt()方法(手表更狭窄的流程瞬时失败边缘情况较少)。
权衡之处在于:对于那些拒绝了系统对话框但没有意识到可以从 iPhone 上的 Watch 应用修复的用户,watchOS 应用有时会陷入无救济的拒绝状态。在这种情况下,Return 会显示一条小的应用内说明(”在 iPhone 上打开 Watch 应用 → 我的手表 → 隐私 → 健康”),而不是尝试重新提示。
我会以何种方式重新构建
来自在两款生产应用中上架 HealthKit 的三条经验。
两种 API 都可用;选择取决于呈现上下文。 SwiftUI 的 .healthDataAccessRequest 修饰符将旧版 API 包装成更声明式的形态,并能在 iPadOS 上正确处理呈现上下文。Return 使用该修饰符,通过在 HealthKitManager 上公开一个 mindfulShareTypes 公共属性来公开其分享类型,以便 SwiftUI 父视图可以将其连接起来。Water 直接使用旧版 healthStore.requestAuthorization,因为 Water 的授权流程是从非 View 上下文(一个 @Observable 服务)运行的。这种拆分是一个有用的模式:当请求来自 SwiftUI 生命周期事件(弹窗内的按钮点击)时,优先使用修饰符;当请求来自服务时,回退到旧版 API。
预权限弹窗在 iOS 上值得付出工程成本,在 watchOS 上则不值得。 预权限弹窗大概增加四小时的工作量(弹窗视图、文案、主题、集成)。iOS 上的授权率提升足够大,使得这四小时是显而易见的投资。在 watchOS 上,等效的全屏预权限更具侵扰性(它会接管整个手表屏幕,而不是作为一个弹窗),用户在小屏幕上阅读长文案的可能性更低,而且手表 UX 流程中要求 HealthKit 功能的入口点也更少。我在 watchOS 上发布 Return 时没有添加这个,也并不后悔。
将 hasRequestedHealthKit 与实时授权状态分开追踪。 HealthKit API 会告知您当前的授权状态,但不会告知您是否曾经询问过。这个区别很重要,因为正确的二次点击行为取决于此:第一次点击应调用 requestAuthorization;如果用户先前已拒绝,第二次点击应显示”设置 → 隐私 → 健康”提示,而不是重新调用 API(在拒绝状态下会静默地无操作)。hasRequestedHealthKit UserDefaults 标志正是让二次点击有用的关键。
何时不应使用 HealthKit
拒绝也是设计的一部分。
不要仅仅因为可以就向 HealthKit 写入。 一个专注计时器应用不需要写入锻炼记录。一个笔记应用不需要记录正念。仅仅因为 HealthKit 是”免费的集成”就添加它,会扩大您的隐私足迹、授权摩擦以及 App Review 问卷范围,而无法为用户带来实质性价值。
如能避免,不要从 HealthKit 读取。 读取访问权限在 App Review 中更难证明合理性,也更难向用户解释。许多读取 HealthKit 的应用其实可以仅写入,让用户在 Apple Health 中查看其数据;读取流程会让表面积加倍,而 UX 收益微乎其微。
对于跨设备专属数据,不要在 Mac 上使用 HealthKit。 macOS 自 macOS 13 起支持 HealthKit,但大多数 Health 数据源自 iPhone 或 Apple Watch。如果您的 Mac 应用需要相同的数据,请在 iPhone 上写入 HealthKit,然后让 Apple 的跨设备同步将其呈现到 Mac 上。从 Mac 直接写入 HealthKit 是有效的,但在实践中很少见。
不要在没有测试”拒绝后再撤销”路径的情况下发布。 用户授予权限,然后从”设置 → 隐私 → 健康”中撤销。您的应用需要优雅地处理撤销,通常是在他们下次尝试使用该功能时显示”设置”深链接说明。Water 和 Return 都发布了这条路径;两者第一次都没做对。
这对于在 iOS 26+ 上发布 HealthKit 的 SwiftUI 应用意味着什么
三个要点。
- 在设计 UI 之前决定仅写入还是读+写。 仅写入的表面积更小,App Review 也更快。读+写更灵活,但增加了”读取拒绝不可见”的不对称性。
- 在 iOS 上发布预权限弹窗。 授权率提升是真实存在的。正确使用 Apple 的图标(不裁剪、不加阴影)、陈述好处,然后调用系统 API。
- 将 watchOS HealthKit 视为 iOS 模式的更小、更简单的变体。 仪式更少、没有弹窗、单一目的的授权。引导用户前往 iPhone 上的 Watch 应用进行重新授予。
请将本文与我为同一系列应用所写的先前文章配合阅读:用于 Apple Intelligence 的类型化 App Intents;用于跨 LLM 代理的 MCP 服务器;用于视觉层的 Liquid Glass 模式;用于跨设备覆盖的多平台发布。HealthKit 是数据源层,位于视觉层和集成表面之下。8
FAQ
我可以在 iOS 应用与其 watchOS 扩展之间共享授权吗?
不可以。iOS 的 HKHealthStore 与 watchOS 的 HKHealthStore 是相互独立的。用户在每个平台上通过单独的系统对话框分别授予授权。每一侧的代码各自检查自身的授权状态;您无法从手表读取 iOS 的状态,反之亦然。
如果用户撤销写入访问权限,我的样本会怎样?
现有样本仍保留在 HealthKit 中。如果用户愿意,可以从 Health 应用中手动删除它们,但撤销您的应用访问权限只会停止新的写入。您的应用无法再保存或修改样本,但用户的历史数据会被保留。
.healthDataAccessRequest SwiftUI 修饰符在生产环境中使用安全吗?
它在 iOS 17.4+ 上有效,并已在后续版本中得到完善。Return 使用该修饰符(在 HealthKitManager 上公开 mindfulShareTypes 供 SwiftUI 使用)。Water 直接使用旧版 healthStore.requestAuthorization,因为 Water 的请求源自非 View 的 @Observable 服务。请根据请求发起的位置来选择:SwiftUI 生命周期事件 → 修饰符;服务或非 View 上下文 → 旧版 API。
为什么即使我从未请求过分享访问权限,HealthKit 授权状态也会显示 .sharingAuthorized?
并不会。状态是按 HKObjectType 划分的。如果您检查 authorizationStatus(for: heartRateType),而您从未请求过心率,您将得到 .notDetermined。状态只有在该特定类型成功授权后才会变为 .sharingAuthorized。
HealthKit 需要隐私清单吗?
需要。涉及 HealthKit 的应用必须在 PrivacyInfo.xcprivacy(隐私清单)以及 App Store Connect 隐私问卷中声明用途。相关条目是 Info.plist 中的 NSHealthShareUsageDescription 和 NSHealthUpdateUsageDescription,以及隐私清单中的相应声明。9
参考资料
-
生产代码位于
Water/Water/Services/HealthKitService.swift(192 行)。作者的 Water,一款 SwiftUI 饮水追踪应用,可在 iOS、iPadOS、macOS、watchOS 和 visionOS 上使用。使用HKQuantitySample、HKQuantityType(.dietaryWater)和HKMetadataKeyWasUserEntered标志。 ↩↩↩ -
生产代码位于
Return/Return/HealthKitManager.swift(171 行)。作者的 Return,一款 SwiftUI 冥想计时器应用,可在 iOS、iPadOS、macOS、watchOS 和 tvOS 上使用。使用HKCategorySample、HKCategoryType(.mindfulSession)和HKCategoryValue.notApplicable.rawValue。 ↩↩↩↩ -
Apple Developer,“HealthKit framework”。该框架的两种主要样本类型是
HKQuantitySample(用于可测量的数量,如饮水、步数、卡路里)和HKCategorySample(用于非定量事件,如正念会话、睡眠、月经流量)。HKWorkout涵盖结构化运动。 ↩ -
Apple Developer,“Authorizing access to health data”。Apple 故意隐藏读取拒绝状态,以防止应用推断用户 Health 资料中存在哪些数据。
authorizationStatus(for:)方法仅对分享访问返回真实结果。 ↩ -
Apple Developer,“Apple Health icon usage” Human Interface Guidelines。Apple 的 Health 图标不得被修改、裁剪、加阴影或重新着色;在推广和预权限 UI 中需以 1:1 保真度复现。 ↩
-
生产代码位于
Return/Return/HealthKitPermissionSheet.swift(155 行)。在触发系统 HealthKit 对话框之前显示的预权限View。将 Return 应用图标与 Apple Health 图标配对,解释好处,并通过用户点击时的回调闭包触发授权。 ↩ -
生产代码位于
Return/ReturnWatch Watch App/WatchHealthKitManager.swift(86 行)。HealthKitManager的 watchOS 变体。独立的HKHealthStore、没有hasRequestedHealthKit标志、没有权限弹窗管道。 ↩ -
作者的分析:HealthKit 是 iOS 应用的数据源层,App Intents 是系统 AI 表面,MCP 是跨 LLM 代理表面,Liquid Glass 是视觉表面。这四层组合成在所有五个 Apple 平台上发布的单一产品。 ↩
-
Apple Developer,“Privacy manifest files”。HealthKit 用途必须在
PrivacyInfo.xcprivacy以及Info.plist中的NSHealthShareUsageDescription和NSHealthUpdateUsageDescription键中声明。 ↩ -
Apple Developer,“App Review Guideline 5.1.1”。应用必须尊重用户隐私设置,在收集个人数据之前请求同意,且不得操纵、欺骗或强迫同意。本文中关于引导屏幕退出路径的具体表述反映了 Return 的解读,而非指南的字面文本。 ↩