如何在React Native(Expo)中实现跨平台后台定时通知功能
Expo React Native 后台锁屏休息计时器实现方案
针对健身应用中后台/锁屏状态下持续运行休息计时器并触发通知的需求,以下是基于Expo生态的完整实现方案,同时支持iOS和Android平台。
核心技术栈
expo-notifications:通知发送与权限管理expo-task-manager+expo-background-fetch:后台任务调度AsyncStorage:计时器状态持久化react-native-reanimated:UI动画expo-haptics+expo-vibration:触觉反馈
完整实现代码
const TIMER_BACKGROUND_TASK = 'WORKOUT_TIMER_BACKGROUND_TASK'; const STORAGE_KEY = '@workout_timer:data'; const TIMER_NOTIFICATION_ID = 'workout-timer-complete'; // 通知全局配置 Notifications.setNotificationHandler({ handleNotification: async () => ({ shouldShowAlert: true, shouldPlaySound: true, shouldSetBadge: false, priority: Notifications.AndroidNotificationPriority.MAX, }), }); // Android通知渠道配置(必须) if (Platform.OS === 'android') { Notifications.setNotificationChannelAsync('workout-timer-complete', { name: '计时器已完成', importance: Notifications.AndroidImportance.MAX, vibrationPattern: [0, 400, 200, 400], lockscreenVisibility: Notifications.AndroidNotificationVisibility.PUBLIC, sound: 'default', enableVibrate: true, }); } // 秒数转MM:SS格式 function formatTime(seconds: number): string { const mins = Math.floor(seconds / 60); const secs = seconds % 60; return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; } // 后台任务:定期检查计时器状态 TaskManager.defineTask(TIMER_BACKGROUND_TASK, async () => { try { const timerDataString = await AsyncStorage.getItem(STORAGE_KEY); if (!timerDataString) { return BackgroundFetch.BackgroundFetchResult.NoData; } const timerData = JSON.parse(timerDataString); const { endTime } = timerData; const now = Date.now(); const remainingMs = Math.max(0, endTime - now); // 计时器到期时发送通知并清理状态 if (remainingMs <= 0) { await sendCompletionNotification(); await AsyncStorage.removeItem(STORAGE_KEY); return BackgroundFetch.BackgroundFetchResult.NewData; } return BackgroundFetch.BackgroundFetchResult.NoData; } catch (error) { console.error('后台任务错误:', error); return BackgroundFetch.BackgroundFetchResult.Failed; } }); // 发送完成通知 async function sendCompletionNotification() { try { let title = "计时器已完成!"; // 读取本地化标题 try { const timerDataString = await AsyncStorage.getItem(STORAGE_KEY); if (timerDataString) { const { translatedTitle } = JSON.parse(timerDataString); if (translatedTitle) title = translatedTitle; } } catch (e) {} const notificationContent = { title, data: { type: 'timer-complete' }, sound: true, }; // 平台专属配置 if (Platform.OS === 'android') { Object.assign(notificationContent, { channelId: 'workout-timer-complete', priority: 'max', category: 'alarm', vibrationPattern: [0, 400, 200, 400, 200, 400], }); } else if (Platform.OS === 'ios') { Object.assign(notificationContent, { interruptionLevel: 'timeSensitive', sound: true, }); } await Notifications.dismissAllNotificationsAsync(); await Notifications.scheduleNotificationAsync({ content: notificationContent, trigger: null, identifier: TIMER_NOTIFICATION_ID, }); // 振动提示 const vibrationPattern = Platform.OS === 'android' ? [0, 400, 200, 400, 200, 400] : [0, 500, 200, 500]; Vibration.vibrate(vibrationPattern); } catch (error) { console.error('发送通知错误:', error); } } // 组件Props定义 interface WorkoutTimerProps { duration: number | null; isVisible: boolean; onComplete: () => void; onDismiss: () => void; exerciseName?: string; currentSetNumber?: number; totalSets?: number; } // 计时器组件 const WorkoutTimer: React.FC<WorkoutTimerProps> = ({ duration, isVisible, onComplete, onDismiss, exerciseName, currentSetNumber, totalSets, }) => { const [timeRemaining, setTimeRemaining] = useState<number | null>(null); const [appState, setAppState] = useState(AppState.currentState); const timerRef = useRef<NodeJS.Timeout | null>(null); const backgroundTimeRef = useRef<number | null>(null); const endTimeRef = useRef<number | null>(null); const scale = useSharedValue(1); const { t } = useLanguage(); // 请求通知权限 useEffect(() => { const requestPermissions = async () => { const { status: existingStatus } = await Notifications.getPermissionsAsync(); let finalStatus = existingStatus; if (existingStatus !== 'granted') { const { status } = await Notifications.requestPermissionsAsync({ ios: { allowAlert: true, allowBadge: true, allowSound: true, allowCriticalAlerts: true, provideAppNotificationSettings: true, }, }); finalStatus = status; } if (finalStatus !== 'granted') { console.log('通知权限未授予'); } }; requestPermissions(); // 监听通知接收与点击事件 const notificationListener = Notifications.addNotificationReceivedListener(() => completeTimer()); const responseListener = Notifications.addNotificationResponseReceivedListener(() => completeTimer()); return () => { notificationListener.remove(); responseListener.remove(); cleanupResources(); }; }, []); // 组件可见时初始化计时器 useEffect(() => { if (isVisible && duration !== null) { Notifications.dismissAllNotificationsAsync(); const now = Date.now(); const endTime = now + duration * 1000; endTimeRef.current = endTime; setTimeRemaining(duration); startTimer(); registerBackgroundTask(); } return () => cleanupResources(); }, [isVisible, duration]); // 监听应用前后台切换 useEffect(() => { const subscription = AppState.addEventListener('change', nextAppState => { if (appState === 'active' && nextAppState.match(/inactive|background/)) { backgroundTimeRef.current = Date.now(); } else if (appState.match(/inactive|background/) && nextAppState === 'active') { syncTimerWithStoredEndTime(); } setAppState(nextAppState); }); return () => subscription.remove(); }, [appState]); // 注册后台任务 const registerBackgroundTask = async () => { try { if (duration !== null && endTimeRef.current) { await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify({ endTime: endTimeRef.current, translatedTitle: t.screens.workoutDetails.timer.timerComplete })); const isRegistered = await TaskManager.isTaskRegisteredAsync(TIMER_BACKGROUND_TASK); if (!isRegistered) { await BackgroundFetch.registerTaskAsync(TIMER_BACKGROUND_TASK, { minimumInterval: 1, stopOnTerminate: false, startOnBoot: true }); } } } catch (error) { console.error('注册后台任务错误:', error); } }; // 前台恢复时同步计时器状态 const syncTimerWithStoredEndTime = async () => { try { if (backgroundTimeRef.current && endTimeRef.current) { const now = Date.now(); const newTimeRemaining = Math.max(0, Math.floor((endTimeRef.current - now) / 1000)); setTimeRemaining(newTimeRemaining); newTimeRemaining <= 0 ? completeTimer() : startTimer(); } } catch (error) { console.error('同步计时器错误:', error); } }; // 清理资源 const cleanupResources = async () => { stopTimer(); try { await Notifications.cancelScheduledNotificationAsync(TIMER_NOTIFICATION_ID); if (!isVisible) await AsyncStorage.removeItem(STORAGE_KEY); } catch (error) { console.error('清理资源错误:', error); } }; // 启动前台计时器 const startTimer = () => { stopTimer(); timerRef.current = setInterval(() => { setTimeRemaining(prev => { if (!prev || prev <= 0) { completeTimer(); return 0; } const newTime = prev - 1; // 最后5秒动画与振动提示 if (newTime <= 5 && newTime > 0) { scale.value = withSequence( withTiming(1.2, { duration: 300 }), withTiming(1, { duration: 300 }) ); if (appState === 'active') Vibration.vibrate(40); } return newTime; }); }, 1000); }; // 停止计时器 const stopTimer = () => { if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null; } }; // 计时器完成逻辑 const completeTimer = async () => { stopTimer(); setTimeRemaining(0); if (appState !== 'active') { await sendCompletionNotification(); } else { Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); const vibrationPattern = Platform.OS === 'android' ? [0, 400, 200, 400, 200, 400] : [0, 500, 200, 500]; Vibration.vibrate(vibrationPattern); } await cleanupResources(); onComplete(); }; // 动画样式 const animatedStyle = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }], })); // 跳过计时器 const handleSkipOrDismiss = () => { stopTimer(); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); cleanupResources(); onDismiss(); }; if (!isVisible) return null; return ( <Portal> <Dialog visible={isVisible} onDismiss={handleSkipOrDismiss} dismissable={false} style={{ borderRadius: 16, backgroundColor: Theme.secondaryBackground, margin: 24, padding: 16, }} > <View style={{ alignItems: 'center', justifyContent: 'center', padding: 16 }}> {exerciseName && ( <Text style={{ fontSize: 18, fontWeight: '600', color: Theme.primaryText, textAlign: 'center', marginBottom: 16, }}> {exerciseName} {currentSetNumber && totalSets && ` - ${t.screens.workoutDetails.set.title.replace('{{number}}', String(currentSetNumber))} (${currentSetNumber}/${totalSets})`} </Text> )} <View style={{ alignItems: 'center', width: '100%', gap: 16 }}> <Animated.View style={animatedStyle}> <Text style={{ fontSize: 56, fontWeight: 'bold', color: timeRemaining && timeRemaining <= 5 ? Theme.error : Theme.primary, fontVariant: ['tabular-nums'], }}> {formatTime(timeRemaining ?? 0)} </Text> </Animated.View> {duration !== null && ( <ProgressBar progress={timeRemaining !== null ? 1 - timeRemaining / duration : 0} style={{ height: 8, width: '100%', borderRadius: 4 }} color={Theme.primary} /> )} <Button mode="contained" onPress={handleSkipOrDismiss} style={{ marginTop: 16, borderRadius: 8, width: '100%', backgroundColor: Theme.primary }} textColor={Theme.background} > {t.screens.workoutDetails.timer.skipRest} </Button> </View> </View> </Dialog> </Portal> ); }; export default WorkoutTimer;
关键实现细节说明
通知权限配置
- iOS需请求
allowCriticalAlerts权限,确保通知能在锁屏/专注模式下显示 - Android必须创建通知渠道,设置最高优先级与锁屏可见性
- iOS需请求
后台任务机制
- 用
BackgroundFetch定期触发后台检查(系统可能调整最小间隔,1分钟为理论最小值) - 后台任务仅在计时器到期时发送通知,避免不必要的资源消耗
- 用
状态持久化
- 将计时器结束时间存储在
AsyncStorage,确保应用重启/后台仍能追踪状态 - 计时器完成或手动关闭时清理存储数据
- 将计时器结束时间存储在
前后台同步
- 监听
AppState变化,应用回到前台时重新计算剩余时间,保证计时器准确性
- 监听
跨平台适配
- Android使用通知渠道与
alarm分类,提升通知优先级 - iOS设置
interruptionLevel: 'timeSensitive',确保通知能打断当前操作
- Android使用通知渠道与
内容的提问来源于stack exchange,提问作者Mark.
相关产品推荐
相关产品推荐

