React Native定时哨音播放缺失问题求助
React Native定时器音频播放异常修复方案
问题描述
开发React Native定时器功能,预期行为:
- 定时器启动前连续播放3次哨音
- 定时器结束后播放1次哨音
实际仅播放2次,频繁跳过中间或最后一次哨音。已提前加载音频、将音频时长缩短至0.3秒,问题仍存在。
原代码
import { View, Text, StyleSheet, TouchableOpacity, Dimensions, Vibration, } from "react-native"; import React from "react"; import { StatusBar } from "expo-status-bar"; import { Audio } from "expo-av"; const screen = Dimensions.get("window"); let timeout: NodeJS.Timeout | undefined = undefined; interface TimerComponentProps { timeInSeconds?: number; } export const TimerComponent: React.FC<TimerComponentProps> = ({ timeInSeconds = 5, }) => { const [remaningSeconds, setRemainingSeconds] = React.useState(timeInSeconds); const [isActive, setIsActive] = React.useState(false); const [sound, setSound] = React.useState<Audio.Sound | undefined>(undefined); const [shouldCount, setShouldCount] = React.useState(false); const [counter, setCounter] = React.useState(3); const { minutes, seconds } = React.useMemo(() => { const minutes = Math.floor(remaningSeconds / 60); const seconds = remaningSeconds % 60; return { minutes, seconds }; }, [remaningSeconds]); async function mountSound() { try { const { sound } = await Audio.Sound.createAsync( require("../../assets/audio/Whistle.wav") ); setSound(sound); } catch (error) { console.error(error); } } async function playWhistle() { if (sound) { try { await sound.playAsync(); } catch (error) { console.error(error); } } } const endTimer = async () => { try { await playWhistle(); setIsActive(false); } catch (error) { console.error(error); } }; const startCounter = async () => { await mountSound(); setShouldCount(true); }; const resetTimer = () => { if (timeout) { clearTimeout(timeout); } else { timeout = setTimeout(() => { setRemainingSeconds(timeInSeconds); clearTimeout(timeout); }, 1000); } }; React.useEffect(() => { let counterInterval: NodeJS.Timer | undefined = undefined; if (shouldCount) { counterInterval = setInterval(() => { try { if (counter === 1) { setCounter((counter) => counter - 1); } if (counter > 1) { playWhistle(); Vibration.vibrate(); setCounter((counter) => counter - 1); } else { // Plays the whistle sound and vibrates the device playWhistle(); Vibration.vibrate(); // Restarts the counter setCounter(3); setShouldCount(false); // Starts the timer setIsActive(true); // Stops the counter clearInterval(counterInterval); } } catch (error) { console.error(error); } }, 1000); } else if (!shouldCount && counter !== 0) { clearInterval(counterInterval); } return () => clearInterval(counterInterval); }, [shouldCount, counter]); React.useEffect(() => { let timerInterval: NodeJS.Timer | undefined = undefined; if (isActive) { timerInterval = setInterval(() => { if (remaningSeconds === 1) { setRemainingSeconds((remaningSeconds) => remaningSeconds - 1); } if (remaningSeconds > 1) { setRemainingSeconds((remaningSeconds) => remaningSeconds - 1); } else { Vibration.vibrate(); endTimer(); resetTimer(); } }, 1000); } else if (!isActive && remaningSeconds === 0) { resetTimer(); clearInterval(timerInterval); } return () => clearInterval(timerInterval); }, [isActive, remaningSeconds]); React.useEffect(() => { return sound ? () => { sound.unloadAsync(); setSound(undefined); } : undefined; }, [sound]); const parseTime = (time: number) => { return time < 10 ? `0${time}` : time; }; return ( <View style={styles.container}> <StatusBar style="light" /> <Text style={styles.timerText}>{`${parseTime(minutes)}:${parseTime( seconds )}`}</Text> <TouchableOpacity onPress={startCounter} style={styles.button}> <Text style={styles.buttonText}>{isActive ? "Pause" : "Start"}</Text> </TouchableOpacity> </View> ); }; const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: "#07121B", alignItems: "center", justifyContent: "center", }, button: { borderWidth: 10, borderColor: "#B9AAFF", width: screen.width / 2, height: screen.width / 2, borderRadius: screen.width / 2, alignItems: "center", justifyContent: "center", }, buttonText: { color: "#B9AAFF", fontSize: 20, }, timerText: { color: "#fff", fontSize: 90, }, });
问题分析
- 音频未重置播放位置:
playAsync()播放完毕后,音频停在结束位置,再次调用时不会自动从头播放,导致无声。 - setInterval闭包陷阱:
counter状态更新是异步的,setInterval中捕获的是初始counter值,逻辑判断会出现偏差,导致播放次数不足。 - 音频重复加载:每次点击Start都重新加载音频,旧实例未及时释放,可能干扰新实例播放。
修复方案
修改后的代码
import { View, Text, StyleSheet, TouchableOpacity, Dimensions, Vibration, } from "react-native"; import React from "react"; import { StatusBar } from "expo-status-bar"; import { Audio } from "expo-av"; const screen = Dimensions.get("window"); interface TimerComponentProps { timeInSeconds?: number; } export const TimerComponent: React.FC<TimerComponentProps> = ({ timeInSeconds = 5, }) => { const [remainingSeconds, setRemainingSeconds] = React.useState(timeInSeconds); const [isActive, setIsActive] = React.useState(false); const [sound, setSound] = React.useState<Audio.Sound | undefined>(undefined); const { minutes, seconds } = React.useMemo(() => { const mins = Math.floor(remainingSeconds / 60); const secs = remainingSeconds % 60; return { minutes: mins, seconds: secs }; }, [remainingSeconds]); // 组件挂载时加载音频,避免重复加载 React.useEffect(() => { async function loadSound() { try { const { sound: newSound } = await Audio.Sound.createAsync( require("../../assets/audio/Whistle.wav") ); setSound(newSound); } catch (error) { console.error("音频加载失败:", error); } } loadSound(); return () => { if (sound) { sound.unloadAsync(); } }; }, []); // 重置音频位置并播放 async function playWhistle() { if (!sound) return; try { await sound.setPositionAsync(0); await sound.playAsync(); } catch (error) { console.error("播放失败:", error); } } // 链式调用实现3次哨音播放 const playCountdownWhistles = async (count: number) => { if (count <= 0) { // 播放完成,启动定时器 setIsActive(true); return; } await playWhistle(); Vibration.vibrate(); // 等待音频播放完成(0.3秒)+ 间隔,这里设为1秒匹配原需求 setTimeout(() => { playCountdownWhistles(count - 1); }, 1000); }; const startCounter = () => { if (isActive) { // 暂停逻辑 setIsActive(false); return; } // 重置定时器时间 setRemainingSeconds(timeInSeconds); // 开始播放倒计时哨音 playCountdownWhistles(3); }; const endTimer = async () => { await playWhistle(); Vibration.vibrate(); setIsActive(false); }; // 定时器逻辑 React.useEffect(() => { let timerInterval: NodeJS.Timeout | undefined; if (isActive) { timerInterval = setInterval(() => { setRemainingSeconds(prev => { if (prev <= 1) { clearInterval(timerInterval); endTimer(); return timeInSeconds; } return prev - 1; }); }, 1000); } return () => clearInterval(timerInterval); }, [isActive]); const parseTime = (time: number) => { return time < 10 ? `0${time}` : time; }; return ( <View style={styles.container}> <StatusBar style="light" /> <Text style={styles.timerText}>{`${parseTime(minutes)}:${parseTime( seconds )}`}</Text> <TouchableOpacity onPress={startCounter} style={styles.button}> <Text style={styles.buttonText}>{isActive ? "Pause" : "Start"}</Text> </TouchableOpacity> </View> ); }; const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: "#07121B", alignItems: "center", justifyContent: "center", }, button: { borderWidth: 10, borderColor: "#B9AAFF", width: screen.width / 2, height: screen.width / 2, borderRadius: screen.width / 2, alignItems: "center", justifyContent: "center", }, buttonText: { color: "#B9AAFF", fontSize: 20, }, timerText: { color: "#fff", fontSize: 90, }, });
关键修改说明
- 音频预加载:组件挂载时只加载一次音频,避免重复加载导致的资源冲突。
- 音频重置播放:每次播放前调用
setPositionAsync(0),确保音频从头开始播放。 - 链式setTimeout替代setInterval:避免闭包捕获旧状态的问题,确保3次哨音准确播放。
- 简化定时器逻辑:合并状态更新与结束判断,减少异步逻辑冲突。
- 添加暂停功能:修复原按钮仅能启动的问题,支持暂停操作。
内容的提问来源于stack exchange,提问作者Luis
相关产品推荐
相关产品推荐

