You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何在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;

关键实现细节说明

  1. 通知权限配置

    • iOS需请求allowCriticalAlerts权限,确保通知能在锁屏/专注模式下显示
    • Android必须创建通知渠道,设置最高优先级与锁屏可见性
  2. 后台任务机制

    • 用BackgroundFetch定期触发后台检查(系统可能调整最小间隔,1分钟为理论最小值)
    • 后台任务仅在计时器到期时发送通知,避免不必要的资源消耗
  3. 状态持久化

    • 将计时器结束时间存储在AsyncStorage,确保应用重启/后台仍能追踪状态
    • 计时器完成或手动关闭时清理存储数据
  4. 前后台同步

    • 监听AppState变化,应用回到前台时重新计算剩余时间,保证计时器准确性
  5. 跨平台适配

    • Android使用通知渠道与alarm分类,提升通知优先级
    • iOS设置interruptionLevel: 'timeSensitive',确保通知能打断当前操作

内容的提问来源于stack exchange,提问作者Mark.

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.13 17:39:49