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

基于Expo与React Native开发闹钟应用的技术求助

解决方案:基于Expo实现持续响铃的闹钟应用

问题本质

你当前用Expo Calendar创建事件的方式,只是借助系统日历的提醒功能,仅能触发单次通知,无法实现持续响铃至用户处理的核心需求,也不支持通过手机移动停止响铃。要达成目标,需要结合Expo的通知、音频和传感器能力重构方案。

核心依赖

  • expo-notifications:处理定时触发,支持后台唤醒
  • expo-audio:播放循环铃声,支持后台运行
  • expo-sensors:监听设备加速度变化,实现移动停止功能
  • expo-task-manager:注册后台任务,确保闹钟在应用后台/关闭时仍能触发

实现步骤及代码

1. 安装依赖

在项目根目录执行:

expo install expo-notifications expo-audio expo-sensors expo-task-manager

2. 权限配置

在app.json中添加必要权限:

{
  "expo": {
    "ios": {
      "infoPlist": {
        "NSNotificationsUsageDescription": "用于发送闹钟通知",
        "NSMotionUsageDescription": "用于检测手机移动停止闹钟",
        "NSAudioUsageDescription": "用于播放闹钟铃声"
      },
      "backgroundModes": ["audio"]
    },
    "android": {
      "permissions": [
        "POST_NOTIFICATIONS",
        "VIBRATE",
        "ACTIVITY_RECOGNITION"
      ]
    }
  }
}

3. 完整组件代码

import React, { useEffect, useState, useRef } from "react";
import { TextInput, Text, TouchableOpacity, View, Button, Alert } from "react-native";
import DateTimePicker from "@react-native-community/datetimepicker";
import Icon from "react-native-vector-icons/Ionicons";
import * as Notifications from "expo-notifications";
import * as Audio from "expo-audio";
import * as Accelerometer from "expo-sensors";
import * as TaskManager from "expo-task-manager";

// 后台任务标识
const ALARM_TASK = "ALARM_TRIGGER_TASK";

// 注册后台任务:触发通知+播放铃声
TaskManager.defineTask(ALARM_TASK, async ({ data }) => {
  if (!data) return;
  
  // 触发即时通知
  await Notifications.scheduleNotificationAsync({
    content: {
      title: data.title,
      body: "点击停止闹钟",
      sound: "default",
      vibrate: [0, 250, 250, 250],
    },
    trigger: null,
  });
  
  // 启动循环铃声
  await playAlarmSound();
});

// 播放循环铃声
const playAlarmSound = async () => {
  await Audio.setAudioModeAsync({
    allowsRecordingIOS: false,
    playsInSilentModeIOS: true,
    staysActiveInBackground: true,
    shouldDuckAndroid: false,
  });

  const sound = new Audio.Sound();
  try {
    // 替换为自定义铃声路径,或使用系统默认
    await sound.loadAsync(require("./assets/alarm.mp3"));
    await sound.setIsLoopingAsync(true);
    await sound.playAsync();
    global.alarmSound = sound;
  } catch (err) {
    console.error("铃声播放失败:", err);
  }
};

// 停止铃声
const stopAlarmSound = async () => {
  if (global.alarmSound) {
    await global.alarmSound.stopAsync();
    await global.alarmSound.unloadAsync();
    global.alarmSound = null;
  }
};

const AddAlarm = () => {
  const [date, setDate] = useState(new Date());
  const [mode, setMode] = useState("date");
  const [showPicker, setShowPicker] = useState(false);
  const [alarmTitle, setAlarmTitle] = useState("");
  const accelerometerRef = useRef(null);

  // 初始化权限+监听通知点击
  useEffect(() => {
    const requestPermissions = async () => {
      const [notifPerm, audioPerm, sensorPerm] = await Promise.all([
        Notifications.requestPermissionsAsync(),
        Audio.requestPermissionsAsync(),
        Accelerometer.requestPermissionsAsync(),
      ]);

      if (notifPerm.status !== "granted" || audioPerm.status !== "granted" || sensorPerm.status !== "granted") {
        Alert.alert("权限不足", "需要通知、音频和传感器权限才能使用闹钟");
      }
    };

    // 通知点击停止铃声
    const notificationListener = Notifications.addNotificationResponseReceivedListener(() => {
      stopAlarmSound();
      accelerometerRef.current?.remove();
    });

    requestPermissions();
    return () => notificationListener.remove();
  }, []);

  // 监听设备移动停止闹钟
  useEffect(() => {
    const startMovementDetection = () => {
      accelerometerRef.current = Accelerometer.addListener(({ x, y, z }) => {
        // 检测明显移动(阈值可调整)
        const movementIntensity = Math.abs(x) + Math.abs(y) + Math.abs(z);
        if (movementIntensity > 15) {
          stopAlarmSound();
          accelerometerRef.current.remove();
          Alert.alert("闹钟已停止", "检测到手机移动");
        }
      });
      Accelerometer.setUpdateInterval(1000);
    };

    // 铃声触发后启动移动检测
    const alarmTriggerListener = Notifications.addNotificationReceivedListener(startMovementDetection);
    return () => alarmTriggerListener.remove();
  }, []);

  const handleDateChange = (_, selectedDate) => {
    const currentDate = selectedDate || date;
    setShowPicker(false);
    setDate(currentDate);
  };

  const showDatePicker = () => setShowPicker(true) || setMode("date");
  const showTimePicker = () => setShowPicker(true) || setMode("time");

  const setAlarm = async () => {
    if (!alarmTitle.trim()) {
      Alert.alert("提示", "请输入闹钟标题");
      return;
    }

    const alarmTimestamp = date.getTime();
    if (alarmTimestamp <= Date.now()) {
      Alert.alert("提示", "请选择未来的时间");
      return;
    }

    // 注册后台任务
    await Notifications.registerTaskAsync(ALARM_TASK);

    // 调度闹钟
    await Notifications.scheduleNotificationAsync({
      content: {
        title: alarmTitle,
        data: { title: alarmTitle },
      },
      trigger: {
        date: new Date(alarmTimestamp),
        repeats: false,
      },
    });

    Alert.alert("成功", `闹钟已设置为: ${date.toLocaleString()}`);
    setAlarmTitle("");
    setDate(new Date());
  };

  return (
    <View style={{ flex: 1, padding: 10 }}>
      <TouchableOpacity
        style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between", height: 40, marginTop: 10 }}
        onPress={showDatePicker}
      >
        <Text>{date.toLocaleDateString()}</Text>
        <Icon name="calendar" size={30} color="gray" />
      </TouchableOpacity>
      <View style={{ height: 1, backgroundColor: "#ccc", marginVertical: 10 }} />

      <TouchableOpacity
        style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between", height: 40 }}
        onPress={showTimePicker}
      >
        <Text>{date.toLocaleTimeString()}</Text>
        <Icon name="time-outline" size={30} color="gray" />
      </TouchableOpacity>
      <View style={{ height: 1, backgroundColor: "#ccc", marginVertical: 10 }} />

      <TextInput
        placeholder="闹钟标题"
        onChangeText={setAlarmTitle}
        value={alarmTitle}
        style={{ padding: 10, borderWidth: 1, borderColor: "#ccc", borderRadius: 5, marginBottom: 20 }}
      />

      {showPicker && (
        <DateTimePicker
          value={date}
          mode={mode}
          is24Hour={true}
          onChange={handleDateChange}
        />
      )}

      <View style={{ position: "absolute", bottom: 50, left: 10, right: 10 }}>
        <Button onPress={setAlarm} title="设置闹钟" />
      </View>
    </View>
  );
};

export default AddAlarm;

功能说明

  • 后台持续响铃:通过expo-task-manager注册后台任务,确保闹钟在应用后台/关闭时仍能触发;expo-audio配置后台音频模式,保证铃声持续播放。
  • 停止方式:
    1. 点击通知直接停止铃声
    2. 检测设备加速度变化(移动手机),超过阈值自动停止铃声
  • 自定义铃声:将require("./assets/alarm.mp3")替换为你的自定义音频文件即可。

注意事项

  • iOS需确保开启后台音频能力(已在app.json配置)
  • 安卓需避免应用被系统强制杀死,否则后台任务可能失效
  • 加速度阈值可根据需求调整,当前15为明显移动的判定标准

内容的提问来源于stack exchange,提问作者Beyza Odabaşı

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.03 02:25:02