perf(app): 添加登录状态检查并优化性能

- 在多个页面添加 isLoggedIn 检查,防止未登录时进行不必要的数据获取
- 使用 React.memo 和 useMemo 优化个人页面徽章渲染性能
- 为 badges API 添加节流机制,避免频繁请求
- 优化图片缓存策略和字符串处理
- 移除调试日志并改进推送通知的认证检查
This commit is contained in:
richarjiang
2025-11-25 15:35:30 +08:00
parent 6f2b7eb45e
commit 3ad0e08d58
9 changed files with 138 additions and 67 deletions

View File

@@ -3,6 +3,7 @@ import dayjs from 'dayjs';
import ChallengeProgressCard from '@/components/challenges/ChallengeProgressCard'; import ChallengeProgressCard from '@/components/challenges/ChallengeProgressCard';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useColorScheme } from '@/hooks/useColorScheme'; import { useColorScheme } from '@/hooks/useColorScheme';
import { import {
fetchChallenges, fetchChallenges,
@@ -45,6 +46,8 @@ export default function ChallengesScreen() {
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { isLoggedIn } = useAuthGuard()
const colorTokens = Colors[theme]; const colorTokens = Colors[theme];
const router = useRouter(); const router = useRouter();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();

View File

@@ -48,7 +48,7 @@ export default function MedicationsScreen() {
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colors: ThemeColors = Colors[theme]; const colors: ThemeColors = Colors[theme];
const userProfile = useAppSelector((state) => state.user.profile); const userProfile = useAppSelector((state) => state.user.profile);
const { ensureLoggedIn } = useAuthGuard(); const { ensureLoggedIn, isLoggedIn } = useAuthGuard();
const { checkServiceAccess } = useVipService(); const { checkServiceAccess } = useVipService();
const { openMembershipModal } = useMembershipModal(); const { openMembershipModal } = useMembershipModal();
const [selectedDate, setSelectedDate] = useState<Dayjs>(dayjs()); const [selectedDate, setSelectedDate] = useState<Dayjs>(dayjs());
@@ -144,9 +144,11 @@ export default function MedicationsScreen() {
// 加载药物和记录数据 // 加载药物和记录数据
useEffect(() => { useEffect(() => {
if (!isLoggedIn) return;
dispatch(fetchMedications()); dispatch(fetchMedications());
dispatch(fetchMedicationRecords({ date: selectedKey })); dispatch(fetchMedicationRecords({ date: selectedKey }));
}, [dispatch, selectedKey]); }, [dispatch, selectedKey, isLoggedIn]);
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -159,6 +161,8 @@ export default function MedicationsScreen() {
// 页面聚焦时刷新数据,确保从添加页面返回时能看到最新数据 // 页面聚焦时刷新数据,确保从添加页面返回时能看到最新数据
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
if (!isLoggedIn) return;
// 重新安排药品通知并刷新数据 // 重新安排药品通知并刷新数据
const refreshDataAndRescheduleNotifications = async () => { const refreshDataAndRescheduleNotifications = async () => {
try { try {
@@ -188,7 +192,7 @@ export default function MedicationsScreen() {
}; };
refreshDataAndRescheduleNotifications(); refreshDataAndRescheduleNotifications();
}, [dispatch, selectedKey]) }, [dispatch, selectedKey, isLoggedIn])
); );
useEffect(() => { useEffect(() => {

View File

@@ -60,6 +60,7 @@ export default function PersonalScreen() {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const router = useRouter(); const router = useRouter();
const isLgAvaliable = isLiquidGlassAvailable(); const isLgAvaliable = isLiquidGlassAvailable();
const [languageModalVisible, setLanguageModalVisible] = useState(false); const [languageModalVisible, setLanguageModalVisible] = useState(false);
const [isSwitchingLanguage, setIsSwitchingLanguage] = useState(false); const [isSwitchingLanguage, setIsSwitchingLanguage] = useState(false);
@@ -79,7 +80,7 @@ export default function PersonalScreen() {
]), [t]); ]), [t]);
const activeLanguageCode = getNormalizedLanguage(i18n.language); const activeLanguageCode = getNormalizedLanguage(i18n.language);
const activeLanguageLabel = languageOptions.find((option) => option.code === activeLanguageCode)?.label ?? ''; const activeLanguageLabel = languageOptions.find((option) => option.code === activeLanguageCode)?.label || '';
const handleLanguageSelect = useCallback(async (language: AppLanguage) => { const handleLanguageSelect = useCallback(async (language: AppLanguage) => {
setLanguageModalVisible(false); setLanguageModalVisible(false);
@@ -164,22 +165,25 @@ export default function PersonalScreen() {
} }
}, [showcaseBadge]); }, [showcaseBadge]);
console.log('badgePreview', badgePreview);
// 首次加载时获取用户信息和数据 // 首次加载时获取用户信息和数据
useEffect(() => { useEffect(() => {
dispatch(fetchAvailableBadges());
if (!isLoggedIn) return;
dispatch(fetchMyProfile()); dispatch(fetchMyProfile());
dispatch(fetchActivityHistory()); dispatch(fetchActivityHistory());
dispatch(fetchAvailableBadges()); }, [dispatch, isLoggedIn]);
}, [dispatch]);
// 页面聚焦时智能刷新(依赖 Redux 的缓存策略) // 页面聚焦时智能刷新(依赖 Redux 的缓存策略)
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
// 徽章数据由 Redux 的缓存策略控制,只有过期才会重新请求 // 徽章数据由 Redux 的缓存策略控制,只有过期才会重新请求
dispatch(fetchAvailableBadges()); dispatch(fetchAvailableBadges());
}, [dispatch]) }, [dispatch, isLoggedIn])
); );
// 手动刷新处理 // 手动刷新处理
@@ -300,11 +304,11 @@ export default function PersonalScreen() {
<TouchableOpacity onPress={handleUserNamePress} activeOpacity={0.7}> <TouchableOpacity onPress={handleUserNamePress} activeOpacity={0.7}>
<Text style={styles.userName}>{displayName}</Text> <Text style={styles.userName}>{displayName}</Text>
</TouchableOpacity> </TouchableOpacity>
{userProfile.memberNumber && ( {userProfile.memberNumber && String(userProfile.memberNumber).trim().length > 0 ? (
<Text style={styles.userMemberNumber}> <Text style={styles.userMemberNumber}>
{t('personal.memberNumber', { number: userProfile.memberNumber })} {t('personal.memberNumber', { number: userProfile.memberNumber })}
</Text> </Text>
)} ) : null}
{userProfile.freeUsageCount !== undefined && ( {userProfile.freeUsageCount !== undefined && (
<View style={styles.aiUsageContainer}> <View style={styles.aiUsageContainer}>
<Ionicons name="sparkles-outline" as any size={12} color="#9370DB" /> <Ionicons name="sparkles-outline" as any size={12} color="#9370DB" />
@@ -365,8 +369,8 @@ export default function PersonalScreen() {
} }
const planName = const planName =
activeMembershipPlanName?.trim() || (activeMembershipPlanName && activeMembershipPlanName.trim()) ||
userProfile.vipPlanName?.trim() || (userProfile.vipPlanName && userProfile.vipPlanName.trim()) ||
t('personal.membership.planFallback'); t('personal.membership.planFallback');
return ( return (
@@ -420,7 +424,7 @@ export default function PersonalScreen() {
const StatsSection = () => ( const StatsSection = () => (
<View style={styles.sectionContainer}> <View style={styles.sectionContainer}>
<View style={[styles.cardContainer, { <View style={[styles.cardContainer, {
backgroundColor: 'unset' backgroundColor: 'transparent'
}]}> }]}>
<View style={styles.statsContainer}> <View style={styles.statsContainer}>
<View style={styles.statItem}> <View style={styles.statItem}>
@@ -440,48 +444,34 @@ export default function PersonalScreen() {
</View> </View>
); );
const BadgesPreviewSection = () => { // 优化性能:使用 useMemo 缓存计算结果,避免每次渲染都重新计算
const BadgesPreviewSection = React.memo(() => {
// 使用 useMemo 缓存切片和计算结果,只有当 badgePreview 或 badgeCounts 变化时才重新计算
const { previewBadges, hasBadges, extraCount } = useMemo(() => {
const previewBadges = badgePreview.slice(0, 3); const previewBadges = badgePreview.slice(0, 3);
const hasBadges = previewBadges.length > 0; const hasBadges = previewBadges.length > 0;
const extraCount = Math.max(0, badgeCounts.total - previewBadges.length); const extraCount = Math.max(0, badgeCounts.total - previewBadges.length);
return { previewBadges, hasBadges, extraCount };
}, [badgePreview, badgeCounts]);
// 使用 useMemo 缓存标题文本,避免每次渲染都调用 t() 函数
const titleText = useMemo(() => t('personal.badgesPreview.title'), [t]);
const emptyText = useMemo(() => t('personal.badgesPreview.empty'), [t]);
return ( return (
<View style={styles.sectionContainer}> <View style={styles.sectionContainer}>
<TouchableOpacity style={[styles.cardContainer, styles.badgesRowCard]} onPress={handleBadgesPress} activeOpacity={0.85}> <TouchableOpacity style={[styles.cardContainer, styles.badgesRowCard]} onPress={handleBadgesPress} activeOpacity={0.85}>
<Text style={styles.badgesRowTitle}>{t('personal.badgesPreview.title')}</Text> <Text style={styles.badgesRowTitle}>{titleText}</Text>
{hasBadges ? ( {hasBadges ? (
<View style={styles.badgesRowContent}> <View style={styles.badgesRowContent}>
<View style={styles.badgesStack}> <View style={styles.badgesStack}>
{previewBadges.map((badge, index) => ( {previewBadges.map((badge, index) => (
<View <BadgeCompactItem
key={badge.code} key={badge.code}
style={[ badge={badge}
styles.badgeCompactBubble, index={index}
badge.isAwarded ? styles.badgeCompactBubbleEarned : styles.badgeCompactBubbleLocked, totalBadges={previewBadges.length}
{
marginLeft: index === 0 ? 0 : -12,
zIndex: previewBadges.length - index,
},
]}
>
{badge.imageUrl ? (
<Image
source={{ uri: badge.imageUrl }}
style={styles.badgeCompactImage}
contentFit="cover"
transition={200}
/> />
) : (
<View style={styles.badgeCompactFallback}>
<Text style={styles.badgeCompactFallbackText}>{badge.icon ?? '🏅'}</Text>
</View>
)}
{!badge.isAwarded && (
<View style={styles.badgeCompactOverlay}>
<Ionicons name="lock-closed" as any size={16} color="#FFFFFF" />
</View>
)}
</View>
))} ))}
</View> </View>
{extraCount > 0 && ( {extraCount > 0 && (
@@ -491,12 +481,60 @@ export default function PersonalScreen() {
)} )}
</View> </View>
) : ( ) : (
<Text style={styles.badgesRowEmpty}>{t('personal.badgesPreview.empty')}</Text> <Text style={styles.badgesRowEmpty}>{emptyText}</Text>
)} )}
</TouchableOpacity> </TouchableOpacity>
</View> </View>
); );
}; });
// 将徽章项提取为独立的 memo 组件,减少重复渲染
const BadgeCompactItem = React.memo(({ badge, index, totalBadges }: {
badge: BadgeDto;
index: number;
totalBadges: number;
}) => {
// 使用 useMemo 缓存样式计算,避免每次渲染都重新计算
const badgeStyle = useMemo(() => [
styles.badgeCompactBubble,
badge.isAwarded ? styles.badgeCompactBubbleEarned : styles.badgeCompactBubbleLocked,
{
marginLeft: index === 0 ? 0 : -12,
zIndex: totalBadges - index,
},
], [badge.isAwarded, index, totalBadges]);
// 使用 useMemo 缓存图标文本,避免每次渲染都重新计算
const iconText = useMemo(() =>
(badge.icon && String(badge.icon).trim()) || '🏅',
[badge.icon]
);
return (
<View style={badgeStyle}>
{badge.imageUrl ? (
<Image
source={{ uri: badge.imageUrl }}
style={styles.badgeCompactImage}
contentFit="cover"
transition={200}
cachePolicy="memory-disk"
/>
) : (
<View style={styles.badgeCompactFallback}>
<Text style={styles.badgeCompactFallbackText}>
{iconText}
</Text>
</View>
)}
{!badge.isAwarded && (
<View style={styles.badgeCompactOverlay}>
<Ionicons name="lock-closed" as any size={16} color="#FFFFFF" />
</View>
)}
</View>
);
});
// 菜单项组件 // 菜单项组件
const MenuSection = ({ title, items }: { title: string; items: MenuItem[] }) => ( const MenuSection = ({ title, items }: { title: string; items: MenuItem[] }) => (
@@ -532,7 +570,7 @@ export default function PersonalScreen() {
/> />
) : ( ) : (
<View style={styles.menuRight}> <View style={styles.menuRight}>
{item.rightText ? ( {item.rightText && String(item.rightText).trim() ? (
<Text style={styles.menuRightText}>{item.rightText}</Text> <Text style={styles.menuRightText}>{item.rightText}</Text>
) : null} ) : null}
<Ionicons name="chevron-forward" as any size={20} color="#CCCCCC" /> <Ionicons name="chevron-forward" as any size={20} color="#CCCCCC" />
@@ -583,7 +621,7 @@ export default function PersonalScreen() {
icon: 'language-outline' as React.ComponentProps<typeof Ionicons>['name'], icon: 'language-outline' as React.ComponentProps<typeof Ionicons>['name'],
title: t('personal.language.menuTitle'), title: t('personal.language.menuTitle'),
onPress: () => setLanguageModalVisible(true), onPress: () => setLanguageModalVisible(true),
rightText: activeLanguageLabel, rightText: activeLanguageLabel || '',
}, },
], ],
}, },
@@ -682,8 +720,12 @@ export default function PersonalScreen() {
disabled={isSwitchingLanguage} disabled={isSwitchingLanguage}
> >
<View style={styles.languageOptionTextGroup}> <View style={styles.languageOptionTextGroup}>
<Text style={styles.languageOptionLabel}>{option.label}</Text> <Text style={styles.languageOptionLabel}>
<Text style={styles.languageOptionDescription}>{option.description}</Text> {(option.label && String(option.label).trim()) || ''}
</Text>
<Text style={styles.languageOptionDescription}>
{(option.description && String(option.description).trim()) || ''}
</Text>
</View> </View>
{isSelected && ( {isSelected && (
<Ionicons name="checkmark-circle" as any size={20} color="#9370DB" /> <Ionicons name="checkmark-circle" as any size={20} color="#9370DB" />

View File

@@ -133,9 +133,11 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
try { try {
logger.info('🚀 开始初始化基础服务(不需要权限)...'); logger.info('🚀 开始初始化基础服务(不需要权限)...');
if (isLoggedIn) {
// 1. 加载用户数据(首屏展示需要) // 1. 加载用户数据(首屏展示需要)
await dispatch(fetchMyProfile()); await dispatch(fetchMyProfile());
logger.info('✅ 用户数据加载完成'); logger.info('✅ 用户数据加载完成');
}
// 2. 初始化 HealthKit 权限系统(不请求权限,仅初始化) // 2. 初始化 HealthKit 权限系统(不请求权限,仅初始化)
initializeHealthPermissions(); initializeHealthPermissions();
@@ -181,7 +183,6 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
permissionInitializedRef.current = true; permissionInitializedRef.current = true;
const delay = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms));
// ==================== 辅助函数 ==================== // ==================== 辅助函数 ====================

View File

@@ -5,6 +5,7 @@ import { NutritionRecordCard } from '@/components/NutritionRecordCard';
import { HeaderBar } from '@/components/ui/HeaderBar'; import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useColorScheme } from '@/hooks/useColorScheme'; import { useColorScheme } from '@/hooks/useColorScheme';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding'; import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import { DietRecord } from '@/services/dietRecords'; import { DietRecord } from '@/services/dietRecords';
@@ -43,6 +44,8 @@ export default function NutritionRecordsScreen() {
const colorTokens = Colors[theme]; const colorTokens = Colors[theme];
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { isLoggedIn } = useAuthGuard()
// 日期相关状态 - 使用与统计页面相同的日期逻辑 // 日期相关状态 - 使用与统计页面相同的日期逻辑
const days = getMonthDaysZh(); const days = getMonthDaysZh();
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth()); const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
@@ -90,7 +93,8 @@ export default function NutritionRecordsScreen() {
// 页面聚焦时自动刷新数据 // 页面聚焦时自动刷新数据
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
console.log('营养记录页面聚焦,刷新数据...'); if (!isLoggedIn) return;
if (viewMode === 'daily') { if (viewMode === 'daily') {
dispatch(fetchDailyNutritionData(currentSelectedDate)); dispatch(fetchDailyNutritionData(currentSelectedDate));
} else { } else {

View File

@@ -100,6 +100,8 @@ export function NutritionRadarCard({
const [currentMealType] = useState<'breakfast' | 'lunch' | 'dinner' | 'snack'>('breakfast'); const [currentMealType] = useState<'breakfast' | 'lunch' | 'dinner' | 'snack'>('breakfast');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const { isLoggedIn } = useAuthGuard()
const { pushIfAuthedElseLogin } = useAuthGuard(); const { pushIfAuthedElseLogin } = useAuthGuard();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@@ -121,10 +123,11 @@ export function NutritionRadarCard({
try { try {
setLoading(true); setLoading(true);
await Promise.all([
dispatch(fetchDailyNutritionData(targetDate)).unwrap(), if (isLoggedIn) {
dispatch(fetchDailyBasalMetabolism(targetDate)).unwrap(), await dispatch(fetchDailyNutritionData(targetDate)).unwrap()
]); }
await dispatch(fetchDailyBasalMetabolism(targetDate)).unwrap()
} catch (error) { } catch (error) {
console.error('NutritionRadarCard: Failed to get nutrition card data:', error); console.error('NutritionRadarCard: Failed to get nutrition card data:', error);
} finally { } finally {
@@ -133,7 +136,7 @@ export function NutritionRadarCard({
}; };
loadNutritionCardData(); loadNutritionCardData();
}, [selectedDate, dispatch]); }, [selectedDate, dispatch, isLoggedIn]);
const nutritionStats = useMemo(() => { const nutritionStats = useMemo(() => {
return [ return [

View File

@@ -119,6 +119,7 @@ async function doFetch<T>(path: string, options: ApiRequestOptions = {}): Promis
headers['Authorization'] = `Bearer ${token}`; headers['Authorization'] = `Bearer ${token}`;
} }
const response = await fetch(url, { const response = await fetch(url, {
method: options.method ?? 'GET', method: options.method ?? 'GET',
headers, headers,
@@ -128,8 +129,6 @@ async function doFetch<T>(path: string, options: ApiRequestOptions = {}): Promis
const json = await response.json() const json = await response.json()
console.log('json', json);
if (!response.ok) { if (!response.ok) {
// 检查是否为401未授权 // 检查是否为401未授权
if (response.status === 401) { if (response.status === 401) {

View File

@@ -1,3 +1,4 @@
import { getAuthToken } from '@/services/api';
import { logger } from '@/utils/logger'; import { logger } from '@/utils/logger';
import Constants from 'expo-constants'; import Constants from 'expo-constants';
import * as Notifications from 'expo-notifications'; import * as Notifications from 'expo-notifications';
@@ -167,9 +168,13 @@ export class PushNotificationManager {
if (!isRegistered || storedToken !== token) { if (!isRegistered || storedToken !== token) {
await this.registerDeviceToken(token); await this.registerDeviceToken(token);
} else { } else {
// 令牌已注册且未变化更新用户ID绑定关系 // 令牌已注册且未变化
// 只有在用户已登录的情况下才更新用户ID绑定关系
const authToken = await getAuthToken();
if (authToken) {
await this.updateTokenUserId(token); await this.updateTokenUserId(token);
} }
}
} catch (error) { } catch (error) {
console.error('检查和注册设备令牌失败:', error); console.error('检查和注册设备令牌失败:', error);
this.config.onError?.(error as Error); this.config.onError?.(error as Error);

View File

@@ -2,6 +2,7 @@ import type { BadgeDto, BadgeRarity } from '@/services/badges';
import { getAvailableBadges } from '@/services/badges'; import { getAvailableBadges } from '@/services/badges';
import { createAsyncThunk, createSelector, createSlice } from '@reduxjs/toolkit'; import { createAsyncThunk, createSelector, createSlice } from '@reduxjs/toolkit';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { throttle } from 'lodash';
import type { RootState } from './index'; import type { RootState } from './index';
@@ -19,11 +20,20 @@ const initialState: BadgesState = {
lastFetched: null, lastFetched: null,
}; };
// 创建节流版本的 fetchAvailableBadges 内部函数
const throttledFetchAvailableBadges = throttle(
async (): Promise<BadgeDto[]> => {
return await getAvailableBadges();
},
2000, // 2秒节流
{ leading: true, trailing: false }
);
export const fetchAvailableBadges = createAsyncThunk<BadgeDto[], void, { rejectValue: string }>( export const fetchAvailableBadges = createAsyncThunk<BadgeDto[], void, { rejectValue: string }>(
'badges/fetchAvailable', 'badges/fetchAvailable',
async (_, { rejectWithValue }) => { async (_, { rejectWithValue }) => {
try { try {
return await getAvailableBadges(); return await throttledFetchAvailableBadges();
} catch (error: any) { } catch (error: any) {
const message = error?.message ?? '获取勋章列表失败'; const message = error?.message ?? '获取勋章列表失败';
return rejectWithValue(message); return rejectWithValue(message);