← 所有文章

iOS 26 上的 HealthKit + SwiftUI:来自两款已上架应用的授权流程、样本类型与跨平台模式

HealthKit 是在 SwiftUI 应用中正确上架时较为棘手的 Apple 框架之一。其授权流程存在一个瞬时失败陷阱,可能会永久性地将用户拒之门外。样本类型在定量数据(用于饮水量、步数、卡路里的 HKQuantitySample)与分类数据(用于正念会话、睡眠、月经流量的 HKCategorySample)之间被尴尬地分割开来。async API 接口要求使用 withCheckedThrowingContinuation 来封装基于回调的 HealthKit 调用。而在 watchOS 上,模式又会再次变化。

我已经在两款生产应用中上架了 HealthKit:Water(饮水量追踪,约 192 行的 HealthKitService1 和 Return(正念会话记录,约 171 行的 HealthKitManager 加上 155 行的 HealthKitPermissionSheet)。2 二者共同覆盖了 HealthKit 两种主要的样本形态、两个方向(读+写 vs. 仅写)以及单平台与跨平台部署。

本文将讲解经受住生产考验的模式:预权限 UX、SwiftUI .healthDataAccessRequest 修饰符 vs. 旧版 requestAuthorization API、用于样本查询的 async 封装,以及 watchOS 的特定差异。

TL;DR

  • 授权状态报告是不对称的。Apple 的 API 会可靠地告诉您”分享已授权”,但出于隐私原因,不会告诉您”读取被拒绝”。本文涵盖了如何在不推断读取状态的情况下检测”用户已被询问但未授权”。
  • 定量数据使用 HKQuantitySampleHKQuantityTypeHKUnit(Water 使用 .literUnit(with: .milli) 表示饮水量)。分类数据使用 HKCategorySampleHKCategoryType(Return 使用 .mindfulSession)。
  • 预权限弹窗是最常被跳过的模式。Apple 的系统权限对话框枯燥乏味;先展示一个自定义的 View 来说明其价值,可以显著提高授权率。
  • watchOS HealthKit 需要单独的 HKHealthStore 实例,具有更严格的授权重新提示规则,且无法显示 SwiftUI 弹窗用于预权限 UX。
  • Return 中的 recordAuthorizationAttempt() 模式可防止瞬时呈现失败被视为永久拒绝。

两种样本形态

HealthKit 沿着一个影响您所写每一行代码的轴线划分了其样本模型:3

样本形态 典型用途 API
HKQuantitySample 饮水、步数、卡路里、体重、心率 HKQuantityType + HKUnit + HKQuantity
HKCategorySample 正念会话、睡眠、月经流量、性活动 HKCategoryType + HKCategoryValue
HKWorkout 结构化运动(跑步、游泳) HKWorkoutBuilderHKWorkoutSession

饮水属于定量。来自 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
    }
}

来自生产环境的三个细节:

  1. .literUnit(with: .milli) 是表示毫升级饮水量的规范单位。HealthKit 会接受任何单位(美制液量盎司、升),但构造器很重要,因为 Apple 在内部将所有内容标准化为升。选择 .milli 可让您存储整数值(240、500)而不是分数升(0.240、0.500)。
  2. HKMetadataKeyWasUserEntered: true 将该样本标记为手动输入而非测量得出。Health 应用会在这些样本上显示一个小的”手动输入”指示器;用户对手动输入数据的信任度与秤测量数据不同。
  3. 对于即时样本,startend 是同一个 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) ...
    }
}

两个细节:

  1. HKCategoryValue.notApplicable.rawValue 是承重的哨兵值。正念会话没有有意义的”值”(它们是时长标记),因此 HealthKit 要求一个哨兵分类值(Int(0))来满足该字段的类型系统。其他分类样本则有更丰富的值:睡眠使用 HKCategoryValueSleepAnalysis.asleep 等。
  2. 对分类样本而言,start/end 窗口是真实存在的。一次 10 分钟的正念会话其 startend 相距 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 的屏幕上拒绝。结果是一个只有一个前进操作、没有逃生出口的弹窗。文案和好处行必须赢得这次点击;不存在”我再考虑一下”的分支。

流程:

  1. 用户在 Return 的设置中点击”连接 Health”。
  2. 预权限弹窗出现。用户阅读说明。
  3. 用户点击 Continue。当 requestAuthorization 运行且系统对话框出现时,弹窗保持挂载状态。
  4. 用户在系统对话框中接受(或拒绝)。Return 调用 recordAuthorizationAttempt() 来捕获结果,然后弹窗关闭。

为何要这样做。 Apple 没有发布关于预权限弹窗在授权率提升方面的官方数据,但每一位与我交谈过的、同时进行了 A/B 测试并有耐心运行该测试的 iOS 开发者都报告了相同的方向:预权限弹窗会显著提高分享授权率。这一模式现已足够普遍,以至于 Apple 自己的模板(照片、相机、定位)也越来越多地包含其自己的预权限 UX。

watchOS 变体

watchOS HealthKit 与 iOS HealthKit 共享相同的 API 接口(HKHealthStore、样本类型、授权),但有三个结构性差异:

  1. 每个手表应用都有一个新的 HKHealthStore 手表应用和配对的 iPhone 应用各自拥有自己的 HKHealthStore 实例。两者都可以写入用户的 HealthKit 数据库,两者都可以读取自己的样本。该 store 在配对设备间共享。
  2. 没有用于预权限的 SwiftUI 弹窗。 watchOS 视图层级结构不像 iOS 那样支持弹窗。预权限 UX 必须是全屏的。
  3. 更严格的重新提示规则。 如果用户先前拒绝过,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 上,等效的拆分体现为 HealthKitManagerrecordAuthorizationAttempt()isAuthorizedToWrite() 这一对。

Watch 类的体积大约是 iOS 类的一半,因为它不需要:

  • hasRequestedHealthKit UserDefaults 标志(手表 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 应用意味着什么

三个要点。

  1. 在设计 UI 之前决定仅写入还是读+写。 仅写入的表面积更小,App Review 也更快。读+写更灵活,但增加了”读取拒绝不可见”的不对称性。
  2. 在 iOS 上发布预权限弹窗。 授权率提升是真实存在的。正确使用 Apple 的图标(不裁剪、不加阴影)、陈述好处,然后调用系统 API。
  3. 将 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 中的 NSHealthShareUsageDescriptionNSHealthUpdateUsageDescription,以及隐私清单中的相应声明。9

参考资料


  1. 生产代码位于 Water/Water/Services/HealthKitService.swift(192 行)。作者的 Water,一款 SwiftUI 饮水追踪应用,可在 iOS、iPadOS、macOS、watchOS 和 visionOS 上使用。使用 HKQuantitySampleHKQuantityType(.dietaryWater)HKMetadataKeyWasUserEntered 标志。 

  2. 生产代码位于 Return/Return/HealthKitManager.swift(171 行)。作者的 Return,一款 SwiftUI 冥想计时器应用,可在 iOS、iPadOS、macOS、watchOS 和 tvOS 上使用。使用 HKCategorySampleHKCategoryType(.mindfulSession)HKCategoryValue.notApplicable.rawValue。 

  3. Apple Developer,“HealthKit framework”。该框架的两种主要样本类型是 HKQuantitySample(用于可测量的数量,如饮水、步数、卡路里)和 HKCategorySample(用于非定量事件,如正念会话、睡眠、月经流量)。HKWorkout 涵盖结构化运动。 

  4. Apple Developer,“Authorizing access to health data”。Apple 故意隐藏读取拒绝状态,以防止应用推断用户 Health 资料中存在哪些数据。authorizationStatus(for:) 方法仅对分享访问返回真实结果。 

  5. Apple Developer,“Apple Health icon usage” Human Interface Guidelines。Apple 的 Health 图标不得被修改、裁剪、加阴影或重新着色;在推广和预权限 UI 中需以 1:1 保真度复现。 

  6. 生产代码位于 Return/Return/HealthKitPermissionSheet.swift(155 行)。在触发系统 HealthKit 对话框之前显示的预权限 View。将 Return 应用图标与 Apple Health 图标配对,解释好处,并通过用户点击时的回调闭包触发授权。 

  7. 生产代码位于 Return/ReturnWatch Watch App/WatchHealthKitManager.swift(86 行)。HealthKitManager 的 watchOS 变体。独立的 HKHealthStore、没有 hasRequestedHealthKit 标志、没有权限弹窗管道。 

  8. 作者的分析:HealthKit 是 iOS 应用的数据源层,App Intents 是系统 AI 表面,MCP 是跨 LLM 代理表面,Liquid Glass 是视觉表面。这四层组合成在所有五个 Apple 平台上发布的单一产品。 

  9. Apple Developer,“Privacy manifest files”。HealthKit 用途必须在 PrivacyInfo.xcprivacy 以及 Info.plist 中的 NSHealthShareUsageDescriptionNSHealthUpdateUsageDescription 键中声明。 

  10. Apple Developer,“App Review Guideline 5.1.1”。应用必须尊重用户隐私设置,在收集个人数据之前请求同意,且不得操纵、欺骗或强迫同意。本文中关于引导屏幕退出路径的具体表述反映了 Return 的解读,而非指南的字面文本。 

相关文章

Liquid Glass in SwiftUI: Three Patterns From Shipping Return on iOS 26

Apple's Liquid Glass is a one-line SwiftUI API. Three patterns from Return go beyond .glassEffect(): glass on text via C…

17 分钟阅读

Five Apple Platforms, Three Shared Files: How Return Actually Ships Cross-Platform SwiftUI

Return runs on iPhone, iPad, Mac, Apple Watch, and Apple TV. Three Swift files are shared across all five targets out of…

18 分钟阅读

The Cleanup Layer Is the Real AI Agent Market

Charlie Labs pivoted from building agents to cleaning up after them. The AI agent market is moving from generation to pr…

15 分钟阅读