feat(health): 重构营养卡片数据获取逻辑,支持基础代谢与运动消耗分离

- 新增 fetchCompleteNutritionCardData 异步 action,统一拉取营养、健康与基础代谢数据
- NutritionRadarCard 改用 Redux 数据源,移除 props 透传,自动根据日期刷新
- BasalMetabolismCard 新增详情弹窗,展示 BMR 计算公式、正常区间及提升策略
- StepsCard 与 StepsCardOptimized 引入 InteractionManager 与动画懒加载,减少 UI 阻塞
- HealthKitManager 新增饮水读写接口,支持将饮水记录同步至 HealthKit
- 移除 statistics 页面冗余 mock 与 nutrition/health 重复请求,缓存时间统一为 5 分钟
This commit is contained in:
richarjiang
2025-09-23 10:01:50 +08:00
parent d082c66b72
commit e6dfd4d59a
11 changed files with 1115 additions and 203 deletions

View File

@@ -1,11 +1,12 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Animated,
StyleSheet,
Text,
TouchableOpacity,
View,
ViewStyle
ViewStyle,
InteractionManager
} from 'react-native';
import { fetchHourlyStepSamples, fetchStepCount, HourlyStepData } from '@/utils/health';
@@ -33,21 +34,28 @@ const StepsCard: React.FC<StepsCardProps> = ({
const [hourlySteps, setHourSteps] = useState<HourlyStepData[]>([])
const getStepData = async (date: Date) => {
const getStepData = useCallback(async (date: Date) => {
try {
logger.info('获取步数数据...');
const [steps, hourly] = await Promise.all([
fetchStepCount(date),
fetchHourlyStepSamples(date)
])
setStepCount(steps)
setHourSteps(hourly)
// 先获取步数立即更新UI
const steps = await fetchStepCount(date);
setStepCount(steps);
// 使用 InteractionManager 在空闲时获取更复杂的小时数据
InteractionManager.runAfterInteractions(async () => {
try {
const hourly = await fetchHourlyStepSamples(date);
setHourSteps(hourly);
} catch (error) {
logger.error('获取小时步数数据失败:', error);
}
});
} catch (error) {
logger.error('获取步数数据失败:', error);
}
}
}, []);
useEffect(() => {
if (curDate) {
@@ -55,55 +63,60 @@ const StepsCard: React.FC<StepsCardProps> = ({
}
}, [curDate]);
// 为每个柱体创建独立的动画
const animatedValues = useRef(
Array.from({ length: 24 }, () => new Animated.Value(0))
).current;
// 优化:减少动画值数量,只为有数据的小时创建动画
const animatedValues = useRef<Map<number, Animated.Value>>(new Map()).current;
// 计算柱状图数据
// 优化:简化柱状图数据计算,减少计算量
const chartData = useMemo(() => {
if (!hourlySteps || hourlySteps.length === 0) {
return Array.from({ length: 24 }, (_, i) => ({ hour: i, steps: 0, height: 0 }));
}
// 找到最大步数用于计算高度比例
const maxSteps = Math.max(...hourlySteps.map(data => data.steps), 1);
const maxHeight = 20; // 柱状图最大高度(缩小一半)
// 优化:只计算有数据的小时的最大步数
const activeSteps = hourlySteps.filter(data => data.steps > 0);
if (activeSteps.length === 0) {
return Array.from({ length: 24 }, (_, i) => ({ hour: i, steps: 0, height: 0 }));
}
const maxSteps = Math.max(...activeSteps.map(data => data.steps));
const maxHeight = 20;
return hourlySteps.map(data => ({
...data,
height: maxSteps > 0 ? (data.steps / maxSteps) * maxHeight : 0
height: data.steps > 0 ? (data.steps / maxSteps) * maxHeight : 0
}));
}, [hourlySteps]);
// 获取当前小时
const currentHour = new Date().getHours();
// 触发柱体动画
// 优化延迟执行动画减少UI阻塞
useEffect(() => {
// 检查是否有实际数据(不只是空数组)
const hasData = chartData && chartData.length > 0 && chartData.some(data => data.steps > 0);
if (hasData) {
// 重置所有动画值
animatedValues.forEach(animValue => animValue.setValue(0));
// 使用 setTimeout 确保在下一个事件循环中执行动画,保证组件已完全渲染
const timeoutId = setTimeout(() => {
// 同时启动所有柱体的弹性动画,有步数的柱体才执行动画
// 使用 InteractionManager 确保动画不会阻塞用户交互
InteractionManager.runAfterInteractions(() => {
// 只为有数据的小时创建和执行动画
chartData.forEach((data, index) => {
if (data.steps > 0) {
Animated.spring(animatedValues[index], {
// 懒创建动画值
if (!animatedValues.has(index)) {
animatedValues.set(index, new Animated.Value(0));
}
const animValue = animatedValues.get(index)!;
animValue.setValue(0);
// 使用更高性能的timing动画替代spring
Animated.timing(animValue, {
toValue: 1,
tension: 150,
friction: 8,
duration: 300,
useNativeDriver: false,
}).start();
}
});
}, 50); // 添加小延迟确保渲染完成
return () => clearTimeout(timeoutId);
});
}
}, [chartData, animatedValues]);
@@ -127,17 +140,22 @@ const StepsCard: React.FC<StepsCardProps> = ({
const isActive = data.steps > 0;
const isCurrent = index <= currentHour;
// 动画变换缩放从0到实际高度
const animatedScale = animatedValues[index].interpolate({
inputRange: [0, 1],
outputRange: [0, 1],
});
// 优化:只为有数据的柱体创建动画插值
const animValue = animatedValues.get(index);
let animatedScale: Animated.AnimatedInterpolation<number> | undefined;
let animatedOpacity: Animated.AnimatedInterpolation<number> | undefined;
// 动画变换透明度从0到1
const animatedOpacity = animatedValues[index].interpolate({
inputRange: [0, 1],
outputRange: [0, 1],
});
if (animValue && isActive) {
animatedScale = animValue.interpolate({
inputRange: [0, 1],
outputRange: [0, 1],
});
animatedOpacity = animValue.interpolate({
inputRange: [0, 1],
outputRange: [0, 1],
});
}
return (
<View key={`bar-container-${index}`} style={styles.barContainer}>
@@ -160,8 +178,8 @@ const StepsCard: React.FC<StepsCardProps> = ({
{
height: data.height,
backgroundColor: isCurrent ? '#FFC365' : '#FFEBCB',
transform: [{ scaleY: animatedScale }],
opacity: animatedOpacity,
transform: animatedScale ? [{ scaleY: animatedScale }] : undefined,
opacity: animatedOpacity || 1,
}
]}
/>