perf(app): 添加登录状态检查并优化性能
- 在多个页面添加 isLoggedIn 检查,防止未登录时进行不必要的数据获取 - 使用 React.memo 和 useMemo 优化个人页面徽章渲染性能 - 为 badges API 添加节流机制,避免频繁请求 - 优化图片缓存策略和字符串处理 - 移除调试日志并改进推送通知的认证检查
This commit is contained in:
@@ -3,6 +3,7 @@ import dayjs from 'dayjs';
|
||||
import ChallengeProgressCard from '@/components/challenges/ChallengeProgressCard';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import {
|
||||
fetchChallenges,
|
||||
@@ -45,6 +46,8 @@ export default function ChallengesScreen() {
|
||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const { isLoggedIn } = useAuthGuard()
|
||||
|
||||
const colorTokens = Colors[theme];
|
||||
const router = useRouter();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
@@ -48,7 +48,7 @@ export default function MedicationsScreen() {
|
||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||
const colors: ThemeColors = Colors[theme];
|
||||
const userProfile = useAppSelector((state) => state.user.profile);
|
||||
const { ensureLoggedIn } = useAuthGuard();
|
||||
const { ensureLoggedIn, isLoggedIn } = useAuthGuard();
|
||||
const { checkServiceAccess } = useVipService();
|
||||
const { openMembershipModal } = useMembershipModal();
|
||||
const [selectedDate, setSelectedDate] = useState<Dayjs>(dayjs());
|
||||
@@ -144,9 +144,11 @@ export default function MedicationsScreen() {
|
||||
|
||||
// 加载药物和记录数据
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
dispatch(fetchMedications());
|
||||
dispatch(fetchMedicationRecords({ date: selectedKey }));
|
||||
}, [dispatch, selectedKey]);
|
||||
}, [dispatch, selectedKey, isLoggedIn]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -159,6 +161,8 @@ export default function MedicationsScreen() {
|
||||
// 页面聚焦时刷新数据,确保从添加页面返回时能看到最新数据
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
// 重新安排药品通知并刷新数据
|
||||
const refreshDataAndRescheduleNotifications = async () => {
|
||||
try {
|
||||
@@ -188,7 +192,7 @@ export default function MedicationsScreen() {
|
||||
};
|
||||
|
||||
refreshDataAndRescheduleNotifications();
|
||||
}, [dispatch, selectedKey])
|
||||
}, [dispatch, selectedKey, isLoggedIn])
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -60,6 +60,7 @@ export default function PersonalScreen() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
|
||||
const isLgAvaliable = isLiquidGlassAvailable();
|
||||
const [languageModalVisible, setLanguageModalVisible] = useState(false);
|
||||
const [isSwitchingLanguage, setIsSwitchingLanguage] = useState(false);
|
||||
@@ -79,7 +80,7 @@ export default function PersonalScreen() {
|
||||
]), [t]);
|
||||
|
||||
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) => {
|
||||
setLanguageModalVisible(false);
|
||||
@@ -164,22 +165,25 @@ export default function PersonalScreen() {
|
||||
}
|
||||
}, [showcaseBadge]);
|
||||
|
||||
console.log('badgePreview', badgePreview);
|
||||
|
||||
|
||||
|
||||
// 首次加载时获取用户信息和数据
|
||||
useEffect(() => {
|
||||
dispatch(fetchAvailableBadges());
|
||||
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
dispatch(fetchMyProfile());
|
||||
dispatch(fetchActivityHistory());
|
||||
dispatch(fetchAvailableBadges());
|
||||
}, [dispatch]);
|
||||
}, [dispatch, isLoggedIn]);
|
||||
|
||||
// 页面聚焦时智能刷新(依赖 Redux 的缓存策略)
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
// 徽章数据由 Redux 的缓存策略控制,只有过期才会重新请求
|
||||
dispatch(fetchAvailableBadges());
|
||||
}, [dispatch])
|
||||
}, [dispatch, isLoggedIn])
|
||||
);
|
||||
|
||||
// 手动刷新处理
|
||||
@@ -300,11 +304,11 @@ export default function PersonalScreen() {
|
||||
<TouchableOpacity onPress={handleUserNamePress} activeOpacity={0.7}>
|
||||
<Text style={styles.userName}>{displayName}</Text>
|
||||
</TouchableOpacity>
|
||||
{userProfile.memberNumber && (
|
||||
{userProfile.memberNumber && String(userProfile.memberNumber).trim().length > 0 ? (
|
||||
<Text style={styles.userMemberNumber}>
|
||||
{t('personal.memberNumber', { number: userProfile.memberNumber })}
|
||||
</Text>
|
||||
)}
|
||||
) : null}
|
||||
{userProfile.freeUsageCount !== undefined && (
|
||||
<View style={styles.aiUsageContainer}>
|
||||
<Ionicons name="sparkles-outline" as any size={12} color="#9370DB" />
|
||||
@@ -365,8 +369,8 @@ export default function PersonalScreen() {
|
||||
}
|
||||
|
||||
const planName =
|
||||
activeMembershipPlanName?.trim() ||
|
||||
userProfile.vipPlanName?.trim() ||
|
||||
(activeMembershipPlanName && activeMembershipPlanName.trim()) ||
|
||||
(userProfile.vipPlanName && userProfile.vipPlanName.trim()) ||
|
||||
t('personal.membership.planFallback');
|
||||
|
||||
return (
|
||||
@@ -420,7 +424,7 @@ export default function PersonalScreen() {
|
||||
const StatsSection = () => (
|
||||
<View style={styles.sectionContainer}>
|
||||
<View style={[styles.cardContainer, {
|
||||
backgroundColor: 'unset'
|
||||
backgroundColor: 'transparent'
|
||||
}]}>
|
||||
<View style={styles.statsContainer}>
|
||||
<View style={styles.statItem}>
|
||||
@@ -440,48 +444,34 @@ export default function PersonalScreen() {
|
||||
</View>
|
||||
);
|
||||
|
||||
const BadgesPreviewSection = () => {
|
||||
const previewBadges = badgePreview.slice(0, 3);
|
||||
const hasBadges = previewBadges.length > 0;
|
||||
const extraCount = Math.max(0, badgeCounts.total - previewBadges.length);
|
||||
// 优化性能:使用 useMemo 缓存计算结果,避免每次渲染都重新计算
|
||||
const BadgesPreviewSection = React.memo(() => {
|
||||
// 使用 useMemo 缓存切片和计算结果,只有当 badgePreview 或 badgeCounts 变化时才重新计算
|
||||
const { previewBadges, hasBadges, extraCount } = useMemo(() => {
|
||||
const previewBadges = badgePreview.slice(0, 3);
|
||||
const hasBadges = previewBadges.length > 0;
|
||||
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 (
|
||||
<View style={styles.sectionContainer}>
|
||||
<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 ? (
|
||||
<View style={styles.badgesRowContent}>
|
||||
<View style={styles.badgesStack}>
|
||||
{previewBadges.map((badge, index) => (
|
||||
<View
|
||||
<BadgeCompactItem
|
||||
key={badge.code}
|
||||
style={[
|
||||
styles.badgeCompactBubble,
|
||||
badge.isAwarded ? styles.badgeCompactBubbleEarned : styles.badgeCompactBubbleLocked,
|
||||
{
|
||||
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>
|
||||
badge={badge}
|
||||
index={index}
|
||||
totalBadges={previewBadges.length}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
{extraCount > 0 && (
|
||||
@@ -491,12 +481,60 @@ export default function PersonalScreen() {
|
||||
)}
|
||||
</View>
|
||||
) : (
|
||||
<Text style={styles.badgesRowEmpty}>{t('personal.badgesPreview.empty')}</Text>
|
||||
<Text style={styles.badgesRowEmpty}>{emptyText}</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</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[] }) => (
|
||||
@@ -532,7 +570,7 @@ export default function PersonalScreen() {
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.menuRight}>
|
||||
{item.rightText ? (
|
||||
{item.rightText && String(item.rightText).trim() ? (
|
||||
<Text style={styles.menuRightText}>{item.rightText}</Text>
|
||||
) : null}
|
||||
<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'],
|
||||
title: t('personal.language.menuTitle'),
|
||||
onPress: () => setLanguageModalVisible(true),
|
||||
rightText: activeLanguageLabel,
|
||||
rightText: activeLanguageLabel || '',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -682,8 +720,12 @@ export default function PersonalScreen() {
|
||||
disabled={isSwitchingLanguage}
|
||||
>
|
||||
<View style={styles.languageOptionTextGroup}>
|
||||
<Text style={styles.languageOptionLabel}>{option.label}</Text>
|
||||
<Text style={styles.languageOptionDescription}>{option.description}</Text>
|
||||
<Text style={styles.languageOptionLabel}>
|
||||
{(option.label && String(option.label).trim()) || ''}
|
||||
</Text>
|
||||
<Text style={styles.languageOptionDescription}>
|
||||
{(option.description && String(option.description).trim()) || ''}
|
||||
</Text>
|
||||
</View>
|
||||
{isSelected && (
|
||||
<Ionicons name="checkmark-circle" as any size={20} color="#9370DB" />
|
||||
|
||||
@@ -133,9 +133,11 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
|
||||
try {
|
||||
logger.info('🚀 开始初始化基础服务(不需要权限)...');
|
||||
|
||||
// 1. 加载用户数据(首屏展示需要)
|
||||
await dispatch(fetchMyProfile());
|
||||
logger.info('✅ 用户数据加载完成');
|
||||
if (isLoggedIn) {
|
||||
// 1. 加载用户数据(首屏展示需要)
|
||||
await dispatch(fetchMyProfile());
|
||||
logger.info('✅ 用户数据加载完成');
|
||||
}
|
||||
|
||||
// 2. 初始化 HealthKit 权限系统(不请求权限,仅初始化)
|
||||
initializeHealthPermissions();
|
||||
@@ -181,7 +183,6 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
|
||||
|
||||
permissionInitializedRef.current = true;
|
||||
|
||||
const delay = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms));
|
||||
|
||||
// ==================== 辅助函数 ====================
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { NutritionRecordCard } from '@/components/NutritionRecordCard';
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||
import { DietRecord } from '@/services/dietRecords';
|
||||
@@ -43,6 +44,8 @@ export default function NutritionRecordsScreen() {
|
||||
const colorTokens = Colors[theme];
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { isLoggedIn } = useAuthGuard()
|
||||
|
||||
// 日期相关状态 - 使用与统计页面相同的日期逻辑
|
||||
const days = getMonthDaysZh();
|
||||
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
|
||||
@@ -90,7 +93,8 @@ export default function NutritionRecordsScreen() {
|
||||
// 页面聚焦时自动刷新数据
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
console.log('营养记录页面聚焦,刷新数据...');
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
if (viewMode === 'daily') {
|
||||
dispatch(fetchDailyNutritionData(currentSelectedDate));
|
||||
} else {
|
||||
|
||||
@@ -100,6 +100,8 @@ export function NutritionRadarCard({
|
||||
const [currentMealType] = useState<'breakfast' | 'lunch' | 'dinner' | 'snack'>('breakfast');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { isLoggedIn } = useAuthGuard()
|
||||
|
||||
const { pushIfAuthedElseLogin } = useAuthGuard();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
@@ -121,10 +123,11 @@ export function NutritionRadarCard({
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
await Promise.all([
|
||||
dispatch(fetchDailyNutritionData(targetDate)).unwrap(),
|
||||
dispatch(fetchDailyBasalMetabolism(targetDate)).unwrap(),
|
||||
]);
|
||||
|
||||
if (isLoggedIn) {
|
||||
await dispatch(fetchDailyNutritionData(targetDate)).unwrap()
|
||||
}
|
||||
await dispatch(fetchDailyBasalMetabolism(targetDate)).unwrap()
|
||||
} catch (error) {
|
||||
console.error('NutritionRadarCard: Failed to get nutrition card data:', error);
|
||||
} finally {
|
||||
@@ -133,7 +136,7 @@ export function NutritionRadarCard({
|
||||
};
|
||||
|
||||
loadNutritionCardData();
|
||||
}, [selectedDate, dispatch]);
|
||||
}, [selectedDate, dispatch, isLoggedIn]);
|
||||
|
||||
const nutritionStats = useMemo(() => {
|
||||
return [
|
||||
|
||||
@@ -119,6 +119,7 @@ async function doFetch<T>(path: string, options: ApiRequestOptions = {}): Promis
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: options.method ?? 'GET',
|
||||
headers,
|
||||
@@ -128,8 +129,6 @@ async function doFetch<T>(path: string, options: ApiRequestOptions = {}): Promis
|
||||
|
||||
const json = await response.json()
|
||||
|
||||
console.log('json', json);
|
||||
|
||||
if (!response.ok) {
|
||||
// 检查是否为401未授权
|
||||
if (response.status === 401) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getAuthToken } from '@/services/api';
|
||||
import { logger } from '@/utils/logger';
|
||||
import Constants from 'expo-constants';
|
||||
import * as Notifications from 'expo-notifications';
|
||||
@@ -167,8 +168,12 @@ export class PushNotificationManager {
|
||||
if (!isRegistered || storedToken !== token) {
|
||||
await this.registerDeviceToken(token);
|
||||
} else {
|
||||
// 令牌已注册且未变化,更新用户ID绑定关系
|
||||
await this.updateTokenUserId(token);
|
||||
// 令牌已注册且未变化
|
||||
// 只有在用户已登录的情况下才更新用户ID绑定关系
|
||||
const authToken = await getAuthToken();
|
||||
if (authToken) {
|
||||
await this.updateTokenUserId(token);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('检查和注册设备令牌失败:', error);
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { BadgeDto, BadgeRarity } from '@/services/badges';
|
||||
import { getAvailableBadges } from '@/services/badges';
|
||||
import { createAsyncThunk, createSelector, createSlice } from '@reduxjs/toolkit';
|
||||
import dayjs from 'dayjs';
|
||||
import { throttle } from 'lodash';
|
||||
|
||||
import type { RootState } from './index';
|
||||
|
||||
@@ -19,11 +20,20 @@ const initialState: BadgesState = {
|
||||
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 }>(
|
||||
'badges/fetchAvailable',
|
||||
async (_, { rejectWithValue }) => {
|
||||
try {
|
||||
return await getAvailableBadges();
|
||||
return await throttledFetchAvailableBadges();
|
||||
} catch (error: any) {
|
||||
const message = error?.message ?? '获取勋章列表失败';
|
||||
return rejectWithValue(message);
|
||||
|
||||
Reference in New Issue
Block a user