fix(ios): 修复HealthKit类型安全性并优化HRV通知频率

将HealthKit数据类型从强制解包改为可选类型,避免潜在的运行时崩溃。所有数据类型访问现在都通过guard语句进行安全检查,当类型不可用时返回明确的错误信息。同时修复了活动摘要日期计算错误,确保每个摘要使用正确的日期。

HRV压力通知的最小间隔从4小时缩短至2小时,并移除了每日一次的限制,允许更及时的压力状态提醒。

BREAKING CHANGE: HealthKit数据类型API现在可能返回"TYPE_NOT_AVAILABLE"错误,调用方需要处理此新错误类型
This commit is contained in:
richarjiang
2025-11-19 09:23:42 +08:00
parent 9d424c7bd2
commit f43cfe7ac6
3 changed files with 170 additions and 68 deletions

View File

@@ -23,21 +23,56 @@ class HealthKitManager: RCTEventEmitter {
/// For reading
private struct ReadTypes {
static let sleep = HKObjectType.categoryType(forIdentifier: .sleepAnalysis)!
static let stepCount = HKObjectType.quantityType(forIdentifier: .stepCount)!
static let heartRate = HKObjectType.quantityType(forIdentifier: .heartRate)!
static let heartRateVariability = HKObjectType.quantityType(forIdentifier: .heartRateVariabilitySDNN)!
static let activeEnergyBurned = HKObjectType.quantityType(forIdentifier: .activeEnergyBurned)!
static let basalEnergyBurned = HKObjectType.quantityType(forIdentifier: .basalEnergyBurned)!
static let appleExerciseTime = HKObjectType.quantityType(forIdentifier: .appleExerciseTime)!
static let appleStandTime = HKObjectType.categoryType(forIdentifier: .appleStandHour)!
static let oxygenSaturation = HKObjectType.quantityType(forIdentifier: .oxygenSaturation)!
static let activitySummary = HKObjectType.activitySummaryType()
static let dietaryWater = HKObjectType.quantityType(forIdentifier: .dietaryWater)!
static let workout = HKObjectType.workoutType()
static var sleep: HKCategoryType? {
return HKObjectType.categoryType(forIdentifier: .sleepAnalysis)
}
static var stepCount: HKQuantityType? {
return HKObjectType.quantityType(forIdentifier: .stepCount)
}
static var heartRate: HKQuantityType? {
return HKObjectType.quantityType(forIdentifier: .heartRate)
}
static var heartRateVariability: HKQuantityType? {
return HKObjectType.quantityType(forIdentifier: .heartRateVariabilitySDNN)
}
static var activeEnergyBurned: HKQuantityType? {
return HKObjectType.quantityType(forIdentifier: .activeEnergyBurned)
}
static var basalEnergyBurned: HKQuantityType? {
return HKObjectType.quantityType(forIdentifier: .basalEnergyBurned)
}
static var appleExerciseTime: HKQuantityType? {
return HKObjectType.quantityType(forIdentifier: .appleExerciseTime)
}
static var appleStandTime: HKCategoryType? {
return HKObjectType.categoryType(forIdentifier: .appleStandHour)
}
static var oxygenSaturation: HKQuantityType? {
return HKObjectType.quantityType(forIdentifier: .oxygenSaturation)
}
static var activitySummary: HKActivitySummaryType {
return HKObjectType.activitySummaryType()
}
static var dietaryWater: HKQuantityType? {
return HKObjectType.quantityType(forIdentifier: .dietaryWater)
}
static var workout: HKWorkoutType {
return HKObjectType.workoutType()
}
static var all: Set<HKObjectType> {
return [sleep, stepCount, heartRate, heartRateVariability, activeEnergyBurned, basalEnergyBurned, appleExerciseTime, appleStandTime, oxygenSaturation, activitySummary, dietaryWater, workout]
var types: Set<HKObjectType> = [activitySummary, workout]
if let sleep = sleep { types.insert(sleep) }
if let stepCount = stepCount { types.insert(stepCount) }
if let heartRate = heartRate { types.insert(heartRate) }
if let heartRateVariability = heartRateVariability { types.insert(heartRateVariability) }
if let activeEnergyBurned = activeEnergyBurned { types.insert(activeEnergyBurned) }
if let basalEnergyBurned = basalEnergyBurned { types.insert(basalEnergyBurned) }
if let appleExerciseTime = appleExerciseTime { types.insert(appleExerciseTime) }
if let appleStandTime = appleStandTime { types.insert(appleStandTime) }
if let oxygenSaturation = oxygenSaturation { types.insert(oxygenSaturation) }
if let dietaryWater = dietaryWater { types.insert(dietaryWater) }
return types
}
static var workoutType: HKWorkoutType {
@@ -47,11 +82,18 @@ class HealthKitManager: RCTEventEmitter {
/// For writing (if needed)
private struct WriteTypes {
static let bodyMass = HKObjectType.quantityType(forIdentifier: .bodyMass)!
static let dietaryWater = HKObjectType.quantityType(forIdentifier: .dietaryWater)!
static var bodyMass: HKQuantityType? {
return HKObjectType.quantityType(forIdentifier: .bodyMass)
}
static var dietaryWater: HKQuantityType? {
return HKObjectType.quantityType(forIdentifier: .dietaryWater)
}
static var all: Set<HKSampleType> {
return [bodyMass, dietaryWater]
var types: Set<HKSampleType> = []
if let bodyMass = bodyMass { types.insert(bodyMass) }
if let dietaryWater = dietaryWater { types.insert(dietaryWater) }
return types
}
}
@@ -147,15 +189,17 @@ class HealthKitManager: RCTEventEmitter {
return
}
let sleepType = HKObjectType.categoryType(forIdentifier: .sleepAnalysis)!
guard let sleepType = ReadTypes.sleep else {
rejecter("TYPE_NOT_AVAILABLE", "Sleep type is not available", nil)
return
}
// Parse options
let startDate: Date
if let startString = options["startDate"] as? String, let d = parseDate(from: startString) {
startDate = d
} else {
startDate = Calendar.current.date(byAdding: .day, value: -7, to: Date())!
startDate = Calendar.current.date(byAdding: .day, value: -7, to: Date()) ?? Date()
}
let endDate: Date
@@ -231,7 +275,10 @@ class HealthKitManager: RCTEventEmitter {
return
}
let activeEnergyType = ReadTypes.activeEnergyBurned
guard let activeEnergyType = ReadTypes.activeEnergyBurned else {
rejecter("TYPE_NOT_AVAILABLE", "Active energy type is not available", nil)
return
}
let startDate: Date
if let startString = options["startDate"] as? String, let d = parseDate(from: startString) {
@@ -312,7 +359,10 @@ class HealthKitManager: RCTEventEmitter {
return
}
let basalEnergyType = ReadTypes.basalEnergyBurned
guard let basalEnergyType = ReadTypes.basalEnergyBurned else {
rejecter("TYPE_NOT_AVAILABLE", "Basal energy type is not available", nil)
return
}
let startDate: Date
if let startString = options["startDate"] as? String, let d = parseDate(from: startString) {
@@ -373,7 +423,10 @@ class HealthKitManager: RCTEventEmitter {
return
}
let exerciseType = ReadTypes.appleExerciseTime
guard let exerciseType = ReadTypes.appleExerciseTime else {
rejecter("TYPE_NOT_AVAILABLE", "Exercise time type is not available", nil)
return
}
let startDate: Date
if let startString = options["startDate"] as? String, let d = parseDate(from: startString) {
@@ -454,7 +507,10 @@ class HealthKitManager: RCTEventEmitter {
return
}
let standType = ReadTypes.appleStandTime
guard let standType = ReadTypes.appleStandTime else {
rejecter("TYPE_NOT_AVAILABLE", "Stand time type is not available", nil)
return
}
let startDate: Date
if let startString = options["startDate"] as? String, let d = parseDate(from: startString) {
@@ -571,9 +627,10 @@ class HealthKitManager: RCTEventEmitter {
return
}
let summaryData = summaries.map { summary in
// DateComponents
let summaryDateComponents = calendar.dateComponents([.day, .month, .year], from: startDate)
let summaryData = summaries.enumerated().map { (index, summary) in
// summary
let summaryDate = calendar.date(byAdding: .day, value: index, to: startDate) ?? startDate
let summaryDateComponents = calendar.dateComponents([.day, .month, .year], from: summaryDate)
return [
"activeEnergyBurned": summary.activeEnergyBurned.doubleValue(for: HKUnit.kilocalorie()),
@@ -607,7 +664,10 @@ class HealthKitManager: RCTEventEmitter {
return
}
let hrvType = ReadTypes.heartRateVariability
guard let hrvType = ReadTypes.heartRateVariability else {
rejecter("TYPE_NOT_AVAILABLE", "HRV type is not available", nil)
return
}
let startDate: Date
if let startString = options["startDate"] as? String, let d = parseDate(from: startString) {
@@ -696,7 +756,10 @@ class HealthKitManager: RCTEventEmitter {
return
}
let oxygenType = ReadTypes.oxygenSaturation
guard let oxygenType = ReadTypes.oxygenSaturation else {
rejecter("TYPE_NOT_AVAILABLE", "Oxygen saturation type is not available", nil)
return
}
let startDate: Date
if let startString = options["startDate"] as? String, let d = parseDate(from: startString) {
@@ -773,7 +836,10 @@ class HealthKitManager: RCTEventEmitter {
return
}
let heartRateType = ReadTypes.heartRate
guard let heartRateType = ReadTypes.heartRate else {
rejecter("TYPE_NOT_AVAILABLE", "Heart rate type is not available", nil)
return
}
let startDate: Date
if let startString = options["startDate"] as? String, let d = parseDate(from: startString) {
@@ -850,7 +916,10 @@ class HealthKitManager: RCTEventEmitter {
return
}
let stepType = ReadTypes.stepCount
guard let stepType = ReadTypes.stepCount else {
rejecter("TYPE_NOT_AVAILABLE", "Step count type is not available", nil)
return
}
let startDate: Date
if let startString = options["startDate"] as? String, let d = parseDate(from: startString) {
@@ -911,7 +980,10 @@ class HealthKitManager: RCTEventEmitter {
return
}
let stepType = ReadTypes.stepCount
guard let stepType = ReadTypes.stepCount else {
rejecter("TYPE_NOT_AVAILABLE", "Step count type is not available", nil)
return
}
let startDate: Date
if let startString = options["startDate"] as? String, let d = parseDate(from: startString) {
@@ -1182,7 +1254,10 @@ class HealthKitManager: RCTEventEmitter {
return
}
let activeEnergyType = ReadTypes.activeEnergyBurned
guard let activeEnergyType = ReadTypes.activeEnergyBurned else {
rejecter("TYPE_NOT_AVAILABLE", "Active energy type is not available", nil)
return
}
let startDate: Date
if let startString = options["startDate"] as? String, let d = parseDate(from: startString) {
@@ -1268,7 +1343,10 @@ class HealthKitManager: RCTEventEmitter {
return
}
let exerciseType = ReadTypes.appleExerciseTime
guard let exerciseType = ReadTypes.appleExerciseTime else {
rejecter("TYPE_NOT_AVAILABLE", "Exercise time type is not available", nil)
return
}
let startDate: Date
if let startString = options["startDate"] as? String, let d = parseDate(from: startString) {
@@ -1354,7 +1432,10 @@ class HealthKitManager: RCTEventEmitter {
return
}
let standType = ReadTypes.appleStandTime
guard let standType = ReadTypes.appleStandTime else {
rejecter("TYPE_NOT_AVAILABLE", "Stand time type is not available", nil)
return
}
let startDate: Date
if let startString = options["startDate"] as? String, let d = parseDate(from: startString) {
@@ -1399,8 +1480,11 @@ class HealthKitManager: RCTEventEmitter {
//
for hour in 0..<24 {
let hourStart = calendar.date(byAdding: .hour, value: hour, to: calendar.startOfDay(for: startDate))!
let hourEnd = calendar.date(byAdding: .hour, value: hour + 1, to: calendar.startOfDay(for: startDate))!
guard let dayStart = calendar.startOfDay(for: startDate) as Date?,
let hourStart = calendar.date(byAdding: .hour, value: hour, to: dayStart),
let hourEnd = calendar.date(byAdding: .hour, value: hour + 1, to: dayStart) else {
continue
}
//
let standSamplesInHour = standSamples.filter { sample in
@@ -1460,7 +1544,10 @@ class HealthKitManager: RCTEventEmitter {
recordedAt = Date()
}
let waterType = WriteTypes.dietaryWater
guard let waterType = WriteTypes.dietaryWater else {
rejecter("TYPE_NOT_AVAILABLE", "Water intake type is not available", nil)
return
}
// Create quantity sample
let quantity = HKQuantity(unit: HKUnit.literUnit(with: .milli), doubleValue: amount)
@@ -1505,7 +1592,10 @@ class HealthKitManager: RCTEventEmitter {
return
}
let waterType = HKObjectType.quantityType(forIdentifier: .dietaryWater)!
guard let waterType = ReadTypes.dietaryWater else {
rejecter("TYPE_NOT_AVAILABLE", "Water intake type is not available", nil)
return
}
// Parse date range
let startDate: Date
@@ -1600,7 +1690,7 @@ class HealthKitManager: RCTEventEmitter {
startDate = d
} else {
// 30
startDate = Calendar.current.date(byAdding: .day, value: -30, to: Date())!
startDate = Calendar.current.date(byAdding: .day, value: -30, to: Date()) ?? Date()
}
let endDate: Date
@@ -1802,7 +1892,10 @@ func startSleepObserver(
}
//
let sleepType = ReadTypes.sleep
guard let sleepType = ReadTypes.sleep else {
rejecter("TYPE_NOT_AVAILABLE", "Sleep type is not available", nil)
return
}
sleepObserverQuery = HKObserverQuery(sampleType: sleepType, predicate: nil) { [weak self] (query, completionHandler, error) in
if let error = error {
@@ -1827,7 +1920,11 @@ func startSleepObserver(
}
//
healthStore.execute(sleepObserverQuery!)
guard let query = sleepObserverQuery else {
rejecter("QUERY_ERROR", "Failed to create sleep observer", nil)
return
}
healthStore.execute(query)
resolver(["success": true])
}
@@ -1842,9 +1939,11 @@ func stopSleepObserver(
sleepObserverQuery = nil
//
healthStore.disableBackgroundDelivery(for: ReadTypes.sleep) { (success, error) in
if let error = error {
print("Failed to disable background delivery for sleep: \(error.localizedDescription)")
if let sleepType = ReadTypes.sleep {
healthStore.disableBackgroundDelivery(for: sleepType) { (success, error) in
if let error = error {
print("Failed to disable background delivery for sleep: \(error.localizedDescription)")
}
}
}
@@ -1879,7 +1978,11 @@ func startHRVObserver(
hrvObserverQuery = nil
}
let hrvType = ReadTypes.heartRateVariability
guard let hrvType = ReadTypes.heartRateVariability else {
rejecter("TYPE_NOT_AVAILABLE", "HRV type is not available", nil)
return
}
hrvObserverQuery = HKObserverQuery(sampleType: hrvType, predicate: nil) { [weak self] (_, completionHandler, error) in
if let error = error {
print("HRV observer error: \(error.localizedDescription)")
@@ -1892,17 +1995,21 @@ func startHRVObserver(
completionHandler()
}
healthStore.enableBackgroundDelivery(for: hrvType, frequency: .immediate) { success, error in
if let error = error {
print("Failed to enable background delivery for HRV: \(error.localizedDescription)")
} else if success {
print("Background delivery for HRV enabled successfully")
if let hrvType = ReadTypes.heartRateVariability {
healthStore.enableBackgroundDelivery(for: hrvType, frequency: .immediate) { success, error in
if let error = error {
print("Failed to enable background delivery for HRV: \(error.localizedDescription)")
} else if success {
print("Background delivery for HRV enabled successfully")
}
}
}
if let query = hrvObserverQuery {
healthStore.execute(query)
guard let query = hrvObserverQuery else {
rejecter("QUERY_ERROR", "Failed to create HRV observer", nil)
return
}
healthStore.execute(query)
resolver(["success": true])
}
@@ -1916,9 +2023,11 @@ func stopHRVObserver(
healthStore.stop(query)
hrvObserverQuery = nil
healthStore.disableBackgroundDelivery(for: ReadTypes.heartRateVariability) { success, error in
if let error = error {
print("Failed to disable background delivery for HRV: \(error.localizedDescription)")
if let hrvType = ReadTypes.heartRateVariability {
healthStore.disableBackgroundDelivery(for: hrvType) { success, error in
if let error = error {
print("Failed to disable background delivery for HRV: \(error.localizedDescription)")
}
}
}
}