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 /// For reading
private struct ReadTypes { private struct ReadTypes {
static let sleep = HKObjectType.categoryType(forIdentifier: .sleepAnalysis)! static var sleep: HKCategoryType? {
static let stepCount = HKObjectType.quantityType(forIdentifier: .stepCount)! return HKObjectType.categoryType(forIdentifier: .sleepAnalysis)
static let heartRate = HKObjectType.quantityType(forIdentifier: .heartRate)! }
static let heartRateVariability = HKObjectType.quantityType(forIdentifier: .heartRateVariabilitySDNN)! static var stepCount: HKQuantityType? {
static let activeEnergyBurned = HKObjectType.quantityType(forIdentifier: .activeEnergyBurned)! return HKObjectType.quantityType(forIdentifier: .stepCount)
static let basalEnergyBurned = HKObjectType.quantityType(forIdentifier: .basalEnergyBurned)! }
static let appleExerciseTime = HKObjectType.quantityType(forIdentifier: .appleExerciseTime)! static var heartRate: HKQuantityType? {
static let appleStandTime = HKObjectType.categoryType(forIdentifier: .appleStandHour)! return HKObjectType.quantityType(forIdentifier: .heartRate)
static let oxygenSaturation = HKObjectType.quantityType(forIdentifier: .oxygenSaturation)! }
static let activitySummary = HKObjectType.activitySummaryType() static var heartRateVariability: HKQuantityType? {
static let dietaryWater = HKObjectType.quantityType(forIdentifier: .dietaryWater)! return HKObjectType.quantityType(forIdentifier: .heartRateVariabilitySDNN)
static let workout = HKObjectType.workoutType() }
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> { 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 { static var workoutType: HKWorkoutType {
@@ -47,11 +82,18 @@ class HealthKitManager: RCTEventEmitter {
/// For writing (if needed) /// For writing (if needed)
private struct WriteTypes { private struct WriteTypes {
static let bodyMass = HKObjectType.quantityType(forIdentifier: .bodyMass)! static var bodyMass: HKQuantityType? {
static let dietaryWater = HKObjectType.quantityType(forIdentifier: .dietaryWater)! return HKObjectType.quantityType(forIdentifier: .bodyMass)
}
static var dietaryWater: HKQuantityType? {
return HKObjectType.quantityType(forIdentifier: .dietaryWater)
}
static var all: Set<HKSampleType> { 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 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 // Parse options
let startDate: Date let startDate: Date
if let startString = options["startDate"] as? String, let d = parseDate(from: startString) { if let startString = options["startDate"] as? String, let d = parseDate(from: startString) {
startDate = d startDate = d
} else { } 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 let endDate: Date
@@ -231,7 +275,10 @@ class HealthKitManager: RCTEventEmitter {
return 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 let startDate: Date
if let startString = options["startDate"] as? String, let d = parseDate(from: startString) { if let startString = options["startDate"] as? String, let d = parseDate(from: startString) {
@@ -312,7 +359,10 @@ class HealthKitManager: RCTEventEmitter {
return 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 let startDate: Date
if let startString = options["startDate"] as? String, let d = parseDate(from: startString) { if let startString = options["startDate"] as? String, let d = parseDate(from: startString) {
@@ -373,7 +423,10 @@ class HealthKitManager: RCTEventEmitter {
return 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 let startDate: Date
if let startString = options["startDate"] as? String, let d = parseDate(from: startString) { if let startString = options["startDate"] as? String, let d = parseDate(from: startString) {
@@ -454,7 +507,10 @@ class HealthKitManager: RCTEventEmitter {
return 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 let startDate: Date
if let startString = options["startDate"] as? String, let d = parseDate(from: startString) { if let startString = options["startDate"] as? String, let d = parseDate(from: startString) {
@@ -571,9 +627,10 @@ class HealthKitManager: RCTEventEmitter {
return return
} }
let summaryData = summaries.map { summary in let summaryData = summaries.enumerated().map { (index, summary) in
// DateComponents // summary
let summaryDateComponents = calendar.dateComponents([.day, .month, .year], from: startDate) let summaryDate = calendar.date(byAdding: .day, value: index, to: startDate) ?? startDate
let summaryDateComponents = calendar.dateComponents([.day, .month, .year], from: summaryDate)
return [ return [
"activeEnergyBurned": summary.activeEnergyBurned.doubleValue(for: HKUnit.kilocalorie()), "activeEnergyBurned": summary.activeEnergyBurned.doubleValue(for: HKUnit.kilocalorie()),
@@ -607,7 +664,10 @@ class HealthKitManager: RCTEventEmitter {
return 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 let startDate: Date
if let startString = options["startDate"] as? String, let d = parseDate(from: startString) { if let startString = options["startDate"] as? String, let d = parseDate(from: startString) {
@@ -696,7 +756,10 @@ class HealthKitManager: RCTEventEmitter {
return 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 let startDate: Date
if let startString = options["startDate"] as? String, let d = parseDate(from: startString) { if let startString = options["startDate"] as? String, let d = parseDate(from: startString) {
@@ -773,7 +836,10 @@ class HealthKitManager: RCTEventEmitter {
return 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 let startDate: Date
if let startString = options["startDate"] as? String, let d = parseDate(from: startString) { if let startString = options["startDate"] as? String, let d = parseDate(from: startString) {
@@ -850,7 +916,10 @@ class HealthKitManager: RCTEventEmitter {
return 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 let startDate: Date
if let startString = options["startDate"] as? String, let d = parseDate(from: startString) { if let startString = options["startDate"] as? String, let d = parseDate(from: startString) {
@@ -911,7 +980,10 @@ class HealthKitManager: RCTEventEmitter {
return 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 let startDate: Date
if let startString = options["startDate"] as? String, let d = parseDate(from: startString) { if let startString = options["startDate"] as? String, let d = parseDate(from: startString) {
@@ -1182,7 +1254,10 @@ class HealthKitManager: RCTEventEmitter {
return 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 let startDate: Date
if let startString = options["startDate"] as? String, let d = parseDate(from: startString) { if let startString = options["startDate"] as? String, let d = parseDate(from: startString) {
@@ -1268,7 +1343,10 @@ class HealthKitManager: RCTEventEmitter {
return 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 let startDate: Date
if let startString = options["startDate"] as? String, let d = parseDate(from: startString) { if let startString = options["startDate"] as? String, let d = parseDate(from: startString) {
@@ -1354,7 +1432,10 @@ class HealthKitManager: RCTEventEmitter {
return 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 let startDate: Date
if let startString = options["startDate"] as? String, let d = parseDate(from: startString) { if let startString = options["startDate"] as? String, let d = parseDate(from: startString) {
@@ -1399,8 +1480,11 @@ class HealthKitManager: RCTEventEmitter {
// //
for hour in 0..<24 { for hour in 0..<24 {
let hourStart = calendar.date(byAdding: .hour, value: hour, to: calendar.startOfDay(for: startDate))! guard let dayStart = calendar.startOfDay(for: startDate) as Date?,
let hourEnd = calendar.date(byAdding: .hour, value: hour + 1, to: calendar.startOfDay(for: startDate))! 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 let standSamplesInHour = standSamples.filter { sample in
@@ -1460,7 +1544,10 @@ class HealthKitManager: RCTEventEmitter {
recordedAt = Date() 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 // Create quantity sample
let quantity = HKQuantity(unit: HKUnit.literUnit(with: .milli), doubleValue: amount) let quantity = HKQuantity(unit: HKUnit.literUnit(with: .milli), doubleValue: amount)
@@ -1505,7 +1592,10 @@ class HealthKitManager: RCTEventEmitter {
return 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 // Parse date range
let startDate: Date let startDate: Date
@@ -1600,7 +1690,7 @@ class HealthKitManager: RCTEventEmitter {
startDate = d startDate = d
} else { } else {
// 30 // 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 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 sleepObserverQuery = HKObserverQuery(sampleType: sleepType, predicate: nil) { [weak self] (query, completionHandler, error) in
if let error = error { 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]) resolver(["success": true])
} }
@@ -1842,11 +1939,13 @@ func stopSleepObserver(
sleepObserverQuery = nil sleepObserverQuery = nil
// //
healthStore.disableBackgroundDelivery(for: ReadTypes.sleep) { (success, error) in if let sleepType = ReadTypes.sleep {
healthStore.disableBackgroundDelivery(for: sleepType) { (success, error) in
if let error = error { if let error = error {
print("Failed to disable background delivery for sleep: \(error.localizedDescription)") print("Failed to disable background delivery for sleep: \(error.localizedDescription)")
} }
} }
}
resolver(["success": true]) resolver(["success": true])
} else { } else {
@@ -1879,7 +1978,11 @@ func startHRVObserver(
hrvObserverQuery = nil 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 hrvObserverQuery = HKObserverQuery(sampleType: hrvType, predicate: nil) { [weak self] (_, completionHandler, error) in
if let error = error { if let error = error {
print("HRV observer error: \(error.localizedDescription)") print("HRV observer error: \(error.localizedDescription)")
@@ -1892,6 +1995,7 @@ func startHRVObserver(
completionHandler() completionHandler()
} }
if let hrvType = ReadTypes.heartRateVariability {
healthStore.enableBackgroundDelivery(for: hrvType, frequency: .immediate) { success, error in healthStore.enableBackgroundDelivery(for: hrvType, frequency: .immediate) { success, error in
if let error = error { if let error = error {
print("Failed to enable background delivery for HRV: \(error.localizedDescription)") print("Failed to enable background delivery for HRV: \(error.localizedDescription)")
@@ -1899,11 +2003,14 @@ func startHRVObserver(
print("Background delivery for HRV enabled successfully") 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]) resolver(["success": true])
} }
@@ -1916,12 +2023,14 @@ func stopHRVObserver(
healthStore.stop(query) healthStore.stop(query)
hrvObserverQuery = nil hrvObserverQuery = nil
healthStore.disableBackgroundDelivery(for: ReadTypes.heartRateVariability) { success, error in if let hrvType = ReadTypes.heartRateVariability {
healthStore.disableBackgroundDelivery(for: hrvType) { success, error in
if let error = error { if let error = error {
print("Failed to disable background delivery for HRV: \(error.localizedDescription)") print("Failed to disable background delivery for HRV: \(error.localizedDescription)")
} }
} }
} }
}
resolver(["success": true]) resolver(["success": true])
} }

View File

@@ -27,7 +27,7 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string> <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>1.0.26</string> <string>1.0.27</string>
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleURLTypes</key> <key>CFBundleURLTypes</key>

View File

@@ -1,16 +1,15 @@
import { logger } from '@/utils/logger';
import AsyncStorage from '@/utils/kvStore';
import dayjs from 'dayjs';
import { NativeEventEmitter, NativeModules } from 'react-native';
import { analyzeHRVData, fetchHRVWithStatus } from '@/utils/health'; import { analyzeHRVData, fetchHRVWithStatus } from '@/utils/health';
import AsyncStorage from '@/utils/kvStore';
import { logger } from '@/utils/logger';
import { convertHrvToStressIndex, getStressLevelInfo, StressLevel } from '@/utils/stress'; import { convertHrvToStressIndex, getStressLevelInfo, StressLevel } from '@/utils/stress';
import { NativeEventEmitter, NativeModules } from 'react-native';
import { sendHRVStressNotification } from './hrvNotificationService'; import { sendHRVStressNotification } from './hrvNotificationService';
const { HealthKitManager } = NativeModules; const { HealthKitManager } = NativeModules;
const HRV_EVENT_NAME = 'hrvUpdate'; const HRV_EVENT_NAME = 'hrvUpdate';
const HRV_NOTIFICATION_STATE_KEY = '@hrv_stress_notification_state'; const HRV_NOTIFICATION_STATE_KEY = '@hrv_stress_notification_state';
const MIN_NOTIFICATION_INTERVAL_HOURS = 4; const MIN_NOTIFICATION_INTERVAL_HOURS = 2;
interface HrvEventData { interface HrvEventData {
timestamp: number; timestamp: number;
@@ -137,19 +136,13 @@ class HRVMonitorService {
const elapsed = now - state.lastSentAt; const elapsed = now - state.lastSentAt;
const minIntervalMs = MIN_NOTIFICATION_INTERVAL_HOURS * 60 * 60 * 1000; const minIntervalMs = MIN_NOTIFICATION_INTERVAL_HOURS * 60 * 60 * 1000;
// 只检查最小间隔时间2小时不再限制每天只能发送一次
if (elapsed < minIntervalMs) { if (elapsed < minIntervalMs) {
const hoursLeft = ((minIntervalMs - elapsed) / (1000 * 60 * 60)).toFixed(1); const hoursLeft = ((minIntervalMs - elapsed) / (1000 * 60 * 60)).toFixed(1);
logger.info(`[HRVMonitor] Cooldown active, ${hoursLeft}h remaining`); logger.info(`[HRVMonitor] Cooldown active, ${hoursLeft}h remaining`);
return false; return false;
} }
const lastSentDay = dayjs(state.lastSentAt).format('YYYY-MM-DD');
const today = dayjs().format('YYYY-MM-DD');
if (lastSentDay === today && state.lastStressLevel !== 'high') {
logger.info('[HRVMonitor] Already sent HRV notification today');
return false;
}
return true; return true;
} catch (error) { } catch (error) {
logger.warn('[HRVMonitor] Failed to read notification state:', error); logger.warn('[HRVMonitor] Failed to read notification state:', error);