feat: 修复健康数据

This commit is contained in:
2025-09-24 09:43:17 +08:00
parent e6dfd4d59a
commit 028ef56caf
8 changed files with 175 additions and 105 deletions

View File

@@ -14,7 +14,7 @@ import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard'; import { useAuthGuard } from '@/hooks/useAuthGuard';
import { BackgroundTaskManager } from '@/services/backgroundTaskManager'; import { BackgroundTaskManager } from '@/services/backgroundTaskManager';
import { selectHealthDataByDate, setHealthData } from '@/store/healthSlice'; import { setHealthData } from '@/store/healthSlice';
import { fetchDailyMoodCheckins, selectLatestMoodRecordByDate } from '@/store/moodSlice'; import { fetchDailyMoodCheckins, selectLatestMoodRecordByDate } from '@/store/moodSlice';
import { fetchTodayWaterStats } from '@/store/waterSlice'; import { fetchTodayWaterStats } from '@/store/waterSlice';
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date'; import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';

View File

@@ -5,7 +5,7 @@ import { fetchBasalEnergyBurned } from '@/utils/health';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { Image } from 'expo-image'; import { Image } from 'expo-image';
import { router } from 'expo-router'; import { router } from 'expo-router';
import React, { useEffect, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Modal, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { Modal, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
interface BasalMetabolismCardProps { interface BasalMetabolismCardProps {
@@ -22,8 +22,13 @@ export function BasalMetabolismCard({ selectedDate, style }: BasalMetabolismCard
const userProfile = useAppSelector(selectUserProfile); const userProfile = useAppSelector(selectUserProfile);
const userAge = useAppSelector(selectUserAge); const userAge = useAppSelector(selectUserAge);
// 计算基础代谢率范围 // 缓存和防抖相关
const calculateBMRRange = () => { const cacheRef = useRef<Map<string, { data: number | null; timestamp: number }>>(new Map());
const loadingRef = useRef<Map<string, Promise<number | null>>>(new Map());
const CACHE_DURATION = 5 * 60 * 1000; // 5分钟缓存
// 使用 useMemo 缓存 BMR 计算,避免每次渲染重复计算
const bmrRange = useMemo(() => {
const { gender, weight, height } = userProfile; const { gender, weight, height } = userProfile;
// 检查是否有足够的信息来计算BMR // 检查是否有足够的信息来计算BMR
@@ -52,35 +57,83 @@ export function BasalMetabolismCard({ selectedDate, style }: BasalMetabolismCard
const maxBMR = Math.round(bmr * 1.15); const maxBMR = Math.round(bmr * 1.15);
return { min: minBMR, max: maxBMR, base: Math.round(bmr) }; return { min: minBMR, max: maxBMR, base: Math.round(bmr) };
}; }, [userProfile.gender, userProfile.weight, userProfile.height, userAge]);
const bmrRange = calculateBMRRange(); // 优化的数据获取函数,包含缓存和去重复请求
const fetchBasalMetabolismData = useCallback(async (date: Date): Promise<number | null> => {
const dateKey = dayjs(date).format('YYYY-MM-DD');
const now = Date.now();
// 检查缓存
const cached = cacheRef.current.get(dateKey);
if (cached && (now - cached.timestamp) < CACHE_DURATION) {
return cached.data;
}
// 检查是否已经在请求中(防止重复请求)
const existingRequest = loadingRef.current.get(dateKey);
if (existingRequest) {
return existingRequest;
}
// 创建新的请求
const request = (async () => {
try {
const options = {
startDate: dayjs(date).startOf('day').toDate().toISOString(),
endDate: dayjs(date).endOf('day').toDate().toISOString()
};
const basalEnergy = await fetchBasalEnergyBurned(options);
const result = basalEnergy || null;
// 更新缓存
cacheRef.current.set(dateKey, { data: result, timestamp: now });
return result;
} catch (error) {
console.error('BasalMetabolismCard: 获取基础代谢数据失败:', error);
return null;
} finally {
// 清理请求记录
loadingRef.current.delete(dateKey);
}
})();
// 记录请求
loadingRef.current.set(dateKey, request);
return request;
}, []);
// 获取基础代谢数据 // 获取基础代谢数据
useEffect(() => { useEffect(() => {
const loadBasalMetabolismData = async () => { if (!selectedDate) return;
if (!selectedDate) return;
let isCancelled = false;
const loadData = async () => {
setLoading(true);
try { try {
setLoading(true); const result = await fetchBasalMetabolismData(selectedDate);
const options = { if (!isCancelled) {
startDate: dayjs(selectedDate).startOf('day').toDate().toISOString(), setBasalMetabolism(result);
endDate: dayjs(selectedDate).endOf('day').toDate().toISOString() }
};
const basalEnergy = await fetchBasalEnergyBurned(options);
setBasalMetabolism(basalEnergy || null);
} catch (error) {
console.error('BasalMetabolismCard: 获取基础代谢数据失败:', error);
setBasalMetabolism(null);
} finally { } finally {
setLoading(false); if (!isCancelled) {
setLoading(false);
}
} }
}; };
loadBasalMetabolismData(); loadData();
}, [selectedDate]);
// 获取基础代谢状态描述 // 清理函数,防止组件卸载后的状态更新
const getMetabolismStatus = () => { return () => {
isCancelled = true;
};
}, [selectedDate, fetchBasalMetabolismData]);
// 使用 useMemo 优化状态描述计算
const status = useMemo(() => {
if (basalMetabolism === null || basalMetabolism === 0) { if (basalMetabolism === null || basalMetabolism === 0) {
return { text: '未知', color: '#9AA3AE' }; return { text: '未知', color: '#9AA3AE' };
} }
@@ -95,9 +148,7 @@ export function BasalMetabolismCard({ selectedDate, style }: BasalMetabolismCard
} else { } else {
return { text: '较低', color: '#EF4444' }; return { text: '较低', color: '#EF4444' };
} }
}; }, [basalMetabolism]);
const status = getMetabolismStatus();
return ( return (
<> <>

View File

@@ -1,8 +1,9 @@
import { AnimatedNumber } from '@/components/AnimatedNumber'; import { AnimatedNumber } from '@/components/AnimatedNumber';
import { ROUTES } from '@/constants/Routes'; import { ROUTES } from '@/constants/Routes';
import { useActiveCalories } from '@/hooks/useActiveCalories';
import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard'; import { useAuthGuard } from '@/hooks/useAuthGuard';
import { fetchCompleteNutritionCardData, selectNutritionCardDataByDate } from '@/store/nutritionSlice'; import { fetchDailyBasalMetabolism, fetchDailyNutritionData, selectBasalMetabolismByDate, selectNutritionSummaryByDate } from '@/store/nutritionSlice';
import { triggerLightHaptic } from '@/utils/haptics'; import { triggerLightHaptic } from '@/utils/haptics';
import { calculateRemainingCalories } from '@/utils/nutrition'; import { calculateRemainingCalories } from '@/utils/nutrition';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
@@ -102,17 +103,24 @@ export function NutritionRadarCard({
return selectedDate ? dayjs(selectedDate).format('YYYY-MM-DD') : dayjs().format('YYYY-MM-DD'); return selectedDate ? dayjs(selectedDate).format('YYYY-MM-DD') : dayjs().format('YYYY-MM-DD');
}, [selectedDate]); }, [selectedDate]);
const cardData = useAppSelector(selectNutritionCardDataByDate(dateKey)); // 使用专用的选择器获取营养数据和基础代谢
const { nutritionSummary, healthData, basalMetabolism } = cardData; const nutritionSummary = useAppSelector(selectNutritionSummaryByDate(dateKey));
const basalMetabolism = useAppSelector(selectBasalMetabolismByDate(dateKey));
// 获取营养和健康数据 // 使用专用的hook获取运动消耗卡路里
const { activeCalories: effectiveActiveCalories, loading: activeCaloriesLoading } = useActiveCalories(selectedDate);
// 获取营养数据和基础代谢数据
useEffect(() => { useEffect(() => {
const loadNutritionCardData = async () => { const loadNutritionCardData = async () => {
const targetDate = selectedDate || new Date(); const targetDate = selectedDate || new Date();
try { try {
setLoading(true); setLoading(true);
await dispatch(fetchCompleteNutritionCardData(targetDate)).unwrap(); await Promise.all([
dispatch(fetchDailyNutritionData(targetDate)).unwrap(),
dispatch(fetchDailyBasalMetabolism(targetDate)).unwrap(),
]);
} catch (error) { } catch (error) {
console.error('NutritionRadarCard: 获取营养卡片数据失败:', error); console.error('NutritionRadarCard: 获取营养卡片数据失败:', error);
} finally { } finally {
@@ -139,7 +147,6 @@ export function NutritionRadarCard({
// 使用从HealthKit获取的数据如果没有则使用默认值 // 使用从HealthKit获取的数据如果没有则使用默认值
const effectiveBasalMetabolism = basalMetabolism || 0; // 基础代谢默认值 const effectiveBasalMetabolism = basalMetabolism || 0; // 基础代谢默认值
const effectiveActiveCalories = healthData?.activeCalories || 0; // 运动消耗卡路里
const remainingCalories = calculateRemainingCalories({ const remainingCalories = calculateRemainingCalories({
basalMetabolism: effectiveBasalMetabolism, basalMetabolism: effectiveBasalMetabolism,
@@ -171,8 +178,8 @@ export function NutritionRadarCard({
<View style={styles.contentContainer}> <View style={styles.contentContainer}>
<View style={styles.radarContainer}> <View style={styles.radarContainer}>
<SimpleRingProgress <SimpleRingProgress
remainingCalories={loading ? 0 : remainingCalories} remainingCalories={(loading || activeCaloriesLoading) ? 0 : remainingCalories}
totalAvailable={loading ? 0 : effectiveBasalMetabolism + effectiveActiveCalories} totalAvailable={(loading || activeCaloriesLoading) ? 0 : effectiveBasalMetabolism + effectiveActiveCalories}
/> />
</View> </View>
@@ -195,10 +202,10 @@ export function NutritionRadarCard({
<Text style={styles.calorieSubtitle}></Text> <Text style={styles.calorieSubtitle}></Text>
<View style={styles.remainingCaloriesContainer}> <View style={styles.remainingCaloriesContainer}>
<AnimatedNumber <AnimatedNumber
value={loading ? 0 : remainingCalories} value={(loading || activeCaloriesLoading) ? 0 : remainingCalories}
resetToken={resetToken} resetToken={resetToken}
style={styles.mainValue} style={styles.mainValue}
format={(v) => loading ? '--' : Math.round(v).toString()} format={(v) => (loading || activeCaloriesLoading) ? '--' : Math.round(v).toString()}
/> />
<Text style={styles.calorieUnit}></Text> <Text style={styles.calorieUnit}></Text>
</View> </View>
@@ -217,10 +224,10 @@ export function NutritionRadarCard({
<Text style={styles.calculationLabel}></Text> <Text style={styles.calculationLabel}></Text>
</View> </View>
<AnimatedNumber <AnimatedNumber
value={loading ? 0 : effectiveActiveCalories} value={activeCaloriesLoading ? 0 : effectiveActiveCalories}
resetToken={resetToken} resetToken={resetToken}
style={styles.calculationValue} style={styles.calculationValue}
format={(v) => loading ? '--' : Math.round(v).toString()} format={(v) => activeCaloriesLoading ? '--' : Math.round(v).toString()}
/> />
<Text style={styles.calculationText}> - </Text> <Text style={styles.calculationText}> - </Text>
<View style={styles.calculationItem}> <View style={styles.calculationItem}>

View File

@@ -1,12 +1,12 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { import {
Animated, Animated,
InteractionManager,
StyleSheet, StyleSheet,
Text, Text,
TouchableOpacity, TouchableOpacity,
View, View,
ViewStyle, ViewStyle
InteractionManager
} from 'react-native'; } from 'react-native';
import { fetchHourlyStepSamples, fetchStepCount, HourlyStepData } from '@/utils/health'; import { fetchHourlyStepSamples, fetchStepCount, HourlyStepData } from '@/utils/health';
@@ -39,18 +39,12 @@ const StepsCard: React.FC<StepsCardProps> = ({
logger.info('获取步数数据...'); logger.info('获取步数数据...');
// 先获取步数立即更新UI // 先获取步数立即更新UI
const steps = await fetchStepCount(date); const [steps, hourly] = await Promise.all([
fetchStepCount(date),
fetchHourlyStepSamples(date)
]);
setStepCount(steps); setStepCount(steps);
setHourSteps(hourly);
// 使用 InteractionManager 在空闲时获取更复杂的小时数据
InteractionManager.runAfterInteractions(async () => {
try {
const hourly = await fetchHourlyStepSamples(date);
setHourSteps(hourly);
} catch (error) {
logger.error('获取小时步数数据失败:', error);
}
});
} catch (error) { } catch (error) {
logger.error('获取步数数据失败:', error); logger.error('获取步数数据失败:', error);

View File

@@ -0,0 +1,58 @@
import dayjs from 'dayjs';
import { useCallback, useEffect, useState } from 'react';
import { NativeModules } from 'react-native';
const { HealthKitManager } = NativeModules;
type HealthDataOptions = {
startDate: string;
endDate: string;
};
/**
* 专用于获取运动消耗卡路里的hook
* 避免使用完整的healthData对象提升性能
*/
export function useActiveCalories(selectedDate?: Date) {
const [activeCalories, setActiveCalories] = useState<number>(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchActiveCalories = useCallback(async (date: Date) => {
try {
setLoading(true);
setError(null);
const options: HealthDataOptions = {
startDate: dayjs(date).startOf('day').toDate().toISOString(),
endDate: dayjs(date).endOf('day').toDate().toISOString()
};
const result = await HealthKitManager.getActiveEnergyBurned(options);
if (result && result.totalValue !== undefined) {
setActiveCalories(Math.round(result.totalValue));
} else {
setActiveCalories(0);
}
} catch (err) {
console.error('获取运动消耗卡路里失败:', err);
setError(err instanceof Error ? err.message : '获取运动消耗卡路里失败');
setActiveCalories(0);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
const targetDate = selectedDate || new Date();
fetchActiveCalories(targetDate);
}, [selectedDate, fetchActiveCalories]);
return {
activeCalories,
loading,
error,
refetch: () => fetchActiveCalories(selectedDate || new Date())
};
}

View File

@@ -326,22 +326,20 @@ class HealthKitManager: NSObject, RCTBridgeModule {
endDate = Date() endDate = Date()
} }
// 使 HKStatisticsQuery HKSampleQuery
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate) let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false)
let query = HKSampleQuery(sampleType: basalEnergyType, let query = HKStatisticsQuery(quantityType: basalEnergyType,
predicate: predicate, quantitySamplePredicate: predicate,
limit: HKObjectQueryNoLimit, options: .cumulativeSum) { [weak self] (query, statistics, error) in
sortDescriptors: [sortDescriptor]) { [weak self] (query, samples, error) in
DispatchQueue.main.async { DispatchQueue.main.async {
if let error = error { if let error = error {
rejecter("QUERY_ERROR", "Failed to query basal energy: \(error.localizedDescription)", error) rejecter("QUERY_ERROR", "Failed to query basal energy: \(error.localizedDescription)", error)
return return
} }
guard let energySamples = samples as? [HKQuantitySample] else { guard let statistics = statistics else {
resolver([ resolver([
"data": [],
"totalValue": 0, "totalValue": 0,
"startDate": self?.dateToISOString(startDate) ?? "", "startDate": self?.dateToISOString(startDate) ?? "",
"endDate": self?.dateToISOString(endDate) ?? "" "endDate": self?.dateToISOString(endDate) ?? ""
@@ -349,28 +347,10 @@ class HealthKitManager: NSObject, RCTBridgeModule {
return return
} }
let energyData = energySamples.map { sample in let totalValue = statistics.sumQuantity()?.doubleValue(for: HKUnit.kilocalorie()) ?? 0
[
"id": sample.uuid.uuidString,
"startDate": self?.dateToISOString(sample.startDate) ?? "",
"endDate": self?.dateToISOString(sample.endDate) ?? "",
"value": sample.quantity.doubleValue(for: HKUnit.kilocalorie()),
"source": [
"name": sample.sourceRevision.source.name,
"bundleIdentifier": sample.sourceRevision.source.bundleIdentifier
],
"metadata": sample.metadata ?? [:]
] as [String : Any]
}
let totalValue = energySamples.reduce(0.0) { total, sample in
return total + sample.quantity.doubleValue(for: HKUnit.kilocalorie())
}
let result: [String: Any] = [ let result: [String: Any] = [
"data": energyData,
"totalValue": totalValue, "totalValue": totalValue,
"count": energyData.count,
"startDate": self?.dateToISOString(startDate) ?? "", "startDate": self?.dateToISOString(startDate) ?? "",
"endDate": self?.dateToISOString(endDate) ?? "" "endDate": self?.dateToISOString(endDate) ?? ""
] ]
@@ -872,22 +852,20 @@ class HealthKitManager: NSObject, RCTBridgeModule {
endDate = Date() endDate = Date()
} }
// 使 HKStatisticsQuery HKSampleQuery
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate) let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false)
let query = HKSampleQuery(sampleType: stepType, let query = HKStatisticsQuery(quantityType: stepType,
predicate: predicate, quantitySamplePredicate: predicate,
limit: HKObjectQueryNoLimit, options: .cumulativeSum) { [weak self] (query, statistics, error) in
sortDescriptors: [sortDescriptor]) { [weak self] (query, samples, error) in
DispatchQueue.main.async { DispatchQueue.main.async {
if let error = error { if let error = error {
rejecter("QUERY_ERROR", "Failed to query step count: \(error.localizedDescription)", error) rejecter("QUERY_ERROR", "Failed to query step count: \(error.localizedDescription)", error)
return return
} }
guard let stepSamples = samples as? [HKQuantitySample] else { guard let statistics = statistics else {
resolver([ resolver([
"data": [],
"totalValue": 0, "totalValue": 0,
"startDate": self?.dateToISOString(startDate) ?? "", "startDate": self?.dateToISOString(startDate) ?? "",
"endDate": self?.dateToISOString(endDate) ?? "" "endDate": self?.dateToISOString(endDate) ?? ""
@@ -895,28 +873,10 @@ class HealthKitManager: NSObject, RCTBridgeModule {
return return
} }
let stepData = stepSamples.map { sample in let totalValue = statistics.sumQuantity()?.doubleValue(for: HKUnit.count()) ?? 0
[
"id": sample.uuid.uuidString,
"startDate": self?.dateToISOString(sample.startDate) ?? "",
"endDate": self?.dateToISOString(sample.endDate) ?? "",
"value": sample.quantity.doubleValue(for: HKUnit.count()),
"source": [
"name": sample.sourceRevision.source.name,
"bundleIdentifier": sample.sourceRevision.source.bundleIdentifier
],
"metadata": sample.metadata ?? [:]
] as [String : Any]
}
let totalValue = stepSamples.reduce(0.0) { total, sample in
return total + sample.quantity.doubleValue(for: HKUnit.count())
}
let result: [String: Any] = [ let result: [String: Any] = [
"data": stepData,
"totalValue": totalValue, "totalValue": totalValue,
"count": stepData.count,
"startDate": self?.dateToISOString(startDate) ?? "", "startDate": self?.dateToISOString(startDate) ?? "",
"endDate": self?.dateToISOString(endDate) ?? "" "endDate": self?.dateToISOString(endDate) ?? ""
] ]

View File

@@ -35,7 +35,7 @@ target 'OutLive' do
use_react_native!( use_react_native!(
:path => config[:reactNativePath], :path => config[:reactNativePath],
:hermes_enabled => podfile_properties['expo.jsEngine'] == nil || podfile_properties['expo.jsEngine'] == 'hermes', :hermes_enabled => false,
# An absolute path to your application root. # An absolute path to your application root.
:app_path => "#{Pod::Config.instance.installation_root}/..", :app_path => "#{Pod::Config.instance.installation_root}/..",
:privacy_file_aggregation_enabled => podfile_properties['apple.privacyManifestAggregationEnabled'] != 'false', :privacy_file_aggregation_enabled => podfile_properties['apple.privacyManifestAggregationEnabled'] != 'false',

View File

@@ -2770,6 +2770,6 @@ SPEC CHECKSUMS:
Yoga: 051f086b5ccf465ff2ed38a2cf5a558ae01aaaa1 Yoga: 051f086b5ccf465ff2ed38a2cf5a558ae01aaaa1
ZXingObjC: 8898711ab495761b2dbbdec76d90164a6d7e14c5 ZXingObjC: 8898711ab495761b2dbbdec76d90164a6d7e14c5
PODFILE CHECKSUM: 857afe46eb91e5007e03cd06568df19c8c00dc3e PODFILE CHECKSUM: 78eca51725b1f0fcd006b70b9a09e3fb4f960d03
COCOAPODS: 1.16.2 COCOAPODS: 1.16.2