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

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,
  },
});

问题分析

  1. 音频未重置播放位置:playAsync()播放完毕后,音频停在结束位置,再次调用时不会自动从头播放,导致无声。
  2. setInterval闭包陷阱:counter状态更新是异步的,setInterval中捕获的是初始counter值,逻辑判断会出现偏差,导致播放次数不足。
  3. 音频重复加载:每次点击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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.03 13:10:24