diff --git a/app/(tabs)/statistics.tsx b/app/(tabs)/statistics.tsx index 93ca108..5bbb8f1 100644 --- a/app/(tabs)/statistics.tsx +++ b/app/(tabs)/statistics.tsx @@ -14,7 +14,7 @@ import { Colors } from '@/constants/Colors'; import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAuthGuard } from '@/hooks/useAuthGuard'; import { BackgroundTaskManager } from '@/services/backgroundTaskManager'; -import { selectHealthDataByDate, setHealthData } from '@/store/healthSlice'; +import { setHealthData } from '@/store/healthSlice'; import { fetchDailyMoodCheckins, selectLatestMoodRecordByDate } from '@/store/moodSlice'; import { fetchTodayWaterStats } from '@/store/waterSlice'; import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date'; diff --git a/components/BasalMetabolismCard.tsx b/components/BasalMetabolismCard.tsx index a3ada7a..c15b9ba 100644 --- a/components/BasalMetabolismCard.tsx +++ b/components/BasalMetabolismCard.tsx @@ -5,7 +5,7 @@ import { fetchBasalEnergyBurned } from '@/utils/health'; import dayjs from 'dayjs'; import { Image } from 'expo-image'; 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'; interface BasalMetabolismCardProps { @@ -22,8 +22,13 @@ export function BasalMetabolismCard({ selectedDate, style }: BasalMetabolismCard const userProfile = useAppSelector(selectUserProfile); const userAge = useAppSelector(selectUserAge); - // 计算基础代谢率范围 - const calculateBMRRange = () => { + // 缓存和防抖相关 + const cacheRef = useRef>(new Map()); + const loadingRef = useRef>>(new Map()); + const CACHE_DURATION = 5 * 60 * 1000; // 5分钟缓存 + + // 使用 useMemo 缓存 BMR 计算,避免每次渲染重复计算 + const bmrRange = useMemo(() => { const { gender, weight, height } = userProfile; // 检查是否有足够的信息来计算BMR @@ -52,35 +57,83 @@ export function BasalMetabolismCard({ selectedDate, style }: BasalMetabolismCard const maxBMR = Math.round(bmr * 1.15); 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 => { + 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(() => { - const loadBasalMetabolismData = async () => { - if (!selectedDate) return; + if (!selectedDate) return; + let isCancelled = false; + + const loadData = async () => { + setLoading(true); try { - setLoading(true); - const options = { - startDate: dayjs(selectedDate).startOf('day').toDate().toISOString(), - endDate: dayjs(selectedDate).endOf('day').toDate().toISOString() - }; - const basalEnergy = await fetchBasalEnergyBurned(options); - setBasalMetabolism(basalEnergy || null); - } catch (error) { - console.error('BasalMetabolismCard: 获取基础代谢数据失败:', error); - setBasalMetabolism(null); + const result = await fetchBasalMetabolismData(selectedDate); + if (!isCancelled) { + setBasalMetabolism(result); + } } finally { - setLoading(false); + if (!isCancelled) { + setLoading(false); + } } }; - loadBasalMetabolismData(); - }, [selectedDate]); - // 获取基础代谢状态描述 - const getMetabolismStatus = () => { + loadData(); + + // 清理函数,防止组件卸载后的状态更新 + return () => { + isCancelled = true; + }; + }, [selectedDate, fetchBasalMetabolismData]); + // 使用 useMemo 优化状态描述计算 + const status = useMemo(() => { if (basalMetabolism === null || basalMetabolism === 0) { return { text: '未知', color: '#9AA3AE' }; } @@ -95,9 +148,7 @@ export function BasalMetabolismCard({ selectedDate, style }: BasalMetabolismCard } else { return { text: '较低', color: '#EF4444' }; } - }; - - const status = getMetabolismStatus(); + }, [basalMetabolism]); return ( <> diff --git a/components/NutritionRadarCard.tsx b/components/NutritionRadarCard.tsx index 6d5736d..c08e64c 100644 --- a/components/NutritionRadarCard.tsx +++ b/components/NutritionRadarCard.tsx @@ -1,8 +1,9 @@ import { AnimatedNumber } from '@/components/AnimatedNumber'; import { ROUTES } from '@/constants/Routes'; +import { useActiveCalories } from '@/hooks/useActiveCalories'; import { useAppDispatch, useAppSelector } from '@/hooks/redux'; 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 { calculateRemainingCalories } from '@/utils/nutrition'; import dayjs from 'dayjs'; @@ -102,17 +103,24 @@ export function NutritionRadarCard({ return selectedDate ? dayjs(selectedDate).format('YYYY-MM-DD') : dayjs().format('YYYY-MM-DD'); }, [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(() => { const loadNutritionCardData = async () => { const targetDate = selectedDate || new Date(); try { setLoading(true); - await dispatch(fetchCompleteNutritionCardData(targetDate)).unwrap(); + await Promise.all([ + dispatch(fetchDailyNutritionData(targetDate)).unwrap(), + dispatch(fetchDailyBasalMetabolism(targetDate)).unwrap(), + ]); } catch (error) { console.error('NutritionRadarCard: 获取营养卡片数据失败:', error); } finally { @@ -139,7 +147,6 @@ export function NutritionRadarCard({ // 使用从HealthKit获取的数据,如果没有则使用默认值 const effectiveBasalMetabolism = basalMetabolism || 0; // 基础代谢默认值 - const effectiveActiveCalories = healthData?.activeCalories || 0; // 运动消耗卡路里 const remainingCalories = calculateRemainingCalories({ basalMetabolism: effectiveBasalMetabolism, @@ -171,8 +178,8 @@ export function NutritionRadarCard({ @@ -195,10 +202,10 @@ export function NutritionRadarCard({ 还能吃 loading ? '--' : Math.round(v).toString()} + format={(v) => (loading || activeCaloriesLoading) ? '--' : Math.round(v).toString()} /> 千卡 @@ -217,10 +224,10 @@ export function NutritionRadarCard({ 运动 loading ? '--' : Math.round(v).toString()} + format={(v) => activeCaloriesLoading ? '--' : Math.round(v).toString()} /> - diff --git a/components/StepsCard.tsx b/components/StepsCard.tsx index a03efe2..043bfd9 100644 --- a/components/StepsCard.tsx +++ b/components/StepsCard.tsx @@ -1,12 +1,12 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Animated, + InteractionManager, StyleSheet, Text, TouchableOpacity, View, - ViewStyle, - InteractionManager + ViewStyle } from 'react-native'; import { fetchHourlyStepSamples, fetchStepCount, HourlyStepData } from '@/utils/health'; @@ -39,18 +39,12 @@ const StepsCard: React.FC = ({ logger.info('获取步数数据...'); // 先获取步数,立即更新UI - const steps = await fetchStepCount(date); + const [steps, hourly] = await Promise.all([ + fetchStepCount(date), + fetchHourlyStepSamples(date) + ]); setStepCount(steps); - - // 使用 InteractionManager 在空闲时获取更复杂的小时数据 - InteractionManager.runAfterInteractions(async () => { - try { - const hourly = await fetchHourlyStepSamples(date); - setHourSteps(hourly); - } catch (error) { - logger.error('获取小时步数数据失败:', error); - } - }); + setHourSteps(hourly); } catch (error) { logger.error('获取步数数据失败:', error); diff --git a/hooks/useActiveCalories.ts b/hooks/useActiveCalories.ts new file mode 100644 index 0000000..3c6a764 --- /dev/null +++ b/hooks/useActiveCalories.ts @@ -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(0); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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()) + }; +} \ No newline at end of file diff --git a/ios/OutLive/HealthKitManager.swift b/ios/OutLive/HealthKitManager.swift index 7329c09..e285d28 100644 --- a/ios/OutLive/HealthKitManager.swift +++ b/ios/OutLive/HealthKitManager.swift @@ -326,22 +326,20 @@ class HealthKitManager: NSObject, RCTBridgeModule { endDate = Date() } + // 使用 HKStatisticsQuery 代替 HKSampleQuery 来直接获取总和,避免处理大量样本 let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate) - let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false) - let query = HKSampleQuery(sampleType: basalEnergyType, - predicate: predicate, - limit: HKObjectQueryNoLimit, - sortDescriptors: [sortDescriptor]) { [weak self] (query, samples, error) in + let query = HKStatisticsQuery(quantityType: basalEnergyType, + quantitySamplePredicate: predicate, + options: .cumulativeSum) { [weak self] (query, statistics, error) in DispatchQueue.main.async { if let error = error { rejecter("QUERY_ERROR", "Failed to query basal energy: \(error.localizedDescription)", error) return } - guard let energySamples = samples as? [HKQuantitySample] else { + guard let statistics = statistics else { resolver([ - "data": [], "totalValue": 0, "startDate": self?.dateToISOString(startDate) ?? "", "endDate": self?.dateToISOString(endDate) ?? "" @@ -349,28 +347,10 @@ class HealthKitManager: NSObject, RCTBridgeModule { return } - let energyData = energySamples.map { sample in - [ - "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 totalValue = statistics.sumQuantity()?.doubleValue(for: HKUnit.kilocalorie()) ?? 0 let result: [String: Any] = [ - "data": energyData, "totalValue": totalValue, - "count": energyData.count, "startDate": self?.dateToISOString(startDate) ?? "", "endDate": self?.dateToISOString(endDate) ?? "" ] @@ -872,22 +852,20 @@ class HealthKitManager: NSObject, RCTBridgeModule { endDate = Date() } + // 使用 HKStatisticsQuery 代替 HKSampleQuery 来直接获取步数总和,提高性能 let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate) - let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false) - let query = HKSampleQuery(sampleType: stepType, - predicate: predicate, - limit: HKObjectQueryNoLimit, - sortDescriptors: [sortDescriptor]) { [weak self] (query, samples, error) in + let query = HKStatisticsQuery(quantityType: stepType, + quantitySamplePredicate: predicate, + options: .cumulativeSum) { [weak self] (query, statistics, error) in DispatchQueue.main.async { if let error = error { rejecter("QUERY_ERROR", "Failed to query step count: \(error.localizedDescription)", error) return } - guard let stepSamples = samples as? [HKQuantitySample] else { + guard let statistics = statistics else { resolver([ - "data": [], "totalValue": 0, "startDate": self?.dateToISOString(startDate) ?? "", "endDate": self?.dateToISOString(endDate) ?? "" @@ -895,28 +873,10 @@ class HealthKitManager: NSObject, RCTBridgeModule { return } - let stepData = stepSamples.map { sample in - [ - "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 totalValue = statistics.sumQuantity()?.doubleValue(for: HKUnit.count()) ?? 0 let result: [String: Any] = [ - "data": stepData, "totalValue": totalValue, - "count": stepData.count, "startDate": self?.dateToISOString(startDate) ?? "", "endDate": self?.dateToISOString(endDate) ?? "" ] diff --git a/ios/Podfile b/ios/Podfile index cd1c8ee..87dc0fe 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -35,7 +35,7 @@ target 'OutLive' do use_react_native!( :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. :app_path => "#{Pod::Config.instance.installation_root}/..", :privacy_file_aggregation_enabled => podfile_properties['apple.privacyManifestAggregationEnabled'] != 'false', diff --git a/ios/Podfile.lock b/ios/Podfile.lock index b5c9bb6..50b8233 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -2770,6 +2770,6 @@ SPEC CHECKSUMS: Yoga: 051f086b5ccf465ff2ed38a2cf5a558ae01aaaa1 ZXingObjC: 8898711ab495761b2dbbdec76d90164a6d7e14c5 -PODFILE CHECKSUM: 857afe46eb91e5007e03cd06568df19c8c00dc3e +PODFILE CHECKSUM: 78eca51725b1f0fcd006b70b9a09e3fb4f960d03 COCOAPODS: 1.16.2