防止 React Native Timers 在后台停止:完整指南

发布: (2025年12月26日 GMT+8 11:06)
10 min read
原文: Dev.to

Source: Dev.to

请提供您希望翻译的完整文本内容,我将为您翻译成简体中文并保留原有的格式、Markdown 语法以及技术术语。谢谢!

构建带后台计时器的习惯追踪应用

挑战:
当用户锁定手机或切换到其他应用时,JavaScript 线程会暂停,计时器停止。对于冥想或锻炼计时器来说,这是一种失效的功能——用户期望计时器即使在应用不在前台时也能继续运行并在完成时提醒他们。

解决方案: 使用 Android 前台服务(Foreground Service)via react-native-background-actions,并使用现代状态管理方案 Zustand 进行同步。

为什么使用前台服务?

移动操作系统会积极挂起后台 JavaScript,以节省电池。
前台服务会向操作系统说明:

“我正在执行用户已知的重要任务——请不要杀掉我。”

它需要一个持续的通知,以便用户看到应用仍在运行。

技术栈

ComponentReason
React Native (Expo Dev Client or bare workflow)UI 与跨平台代码
Zustand简单、无 Hook 的状态存储(对后台访问至关重要)
react-native-background-actions处理 Android 前台服务

1️⃣ 安装库

npm install react-native-background-actions

2️⃣ Android Manifest 配置

<manifest> 标签 内部 添加所需的权限,并在 android/app/src/main/AndroidManifest.xml<application> 标签 内部 声明服务:

<!-- Add your permissions and service declaration here -->

提示: 这里是大多数开发者卡住的地方。忘记权限或服务声明都会导致在尝试启动后台任务时崩溃。

3️⃣ 在后台线程中访问 Zustand

重要:不能 在后台任务循环中使用 React hooks(useStateuseEffect、自定义 hooks 如 useHabitStore())。Hooks 依赖于 React 的渲染周期,而该周期在服务中并未运行。

Zustand 为我们提供了一个逃生口:命令式访问 通过 store.getState()。这让后台任务能够直接读取和修改状态。

4️⃣ 背景任务定义

任务函数必须在任何 React 组件 外部 定义。

// timerBackgroundTask.ts
import BackgroundService from 'react-native-background-actions';
import { useHabitStore } from './store/habitStore'; // Direct import of the Zustand store

// Helper: pause execution without hogging the CPU
const sleep = (time: number) =>
  new Promise((resolve) => setTimeout(resolve, time));

/**
 * Background timer task.
 * @param taskDataArguments Optional data passed when starting the service.
 */
export const timerBackgroundTask = async (
  taskDataArguments?: {
    targetSeconds?: number;
    habitName?: string;
  }
): Promise<void> => {
  const target = taskDataArguments?.targetSeconds ?? 0;
  const habitName = taskDataArguments?.habitName ?? 'Timer';

  await new Promise((resolve) => {
    // Loop while the foreground service is alive
    const loop = async () => {
      while (BackgroundService.isRunning()) {
        // 1️⃣ IMPERATIVE STATE ACCESS (no hooks!)
        const state = useHabitStore.getState();

        // 2️⃣ Stop conditions – user paused or timer already finished
        if (!state.timerIsRunning || state.timerIsCompleted) {
          break;
        }

        // 3️⃣ Timer logic (robust against app pauses)
        const now = Date.now();
        const start = state.timerStartTime;

        // Initialise start time if it’s missing
        if (!start) {
          useHabitStore.getState().setTimerStartTime(now);
          await sleep(1000);
          continue;
        }

        const elapsed = Math.floor((now - start) / 1000); // seconds

        // 4️⃣ Target reached?
        if (target > 0 && elapsed >= target) {
          // Update Zustand state
          useHabitStore.getState().setTimerIsCompleted(true);
          useHabitStore.getState().setTimerIsRunning(false);
          useHabitStore.getState().setTimerTimeElapsed(target);

          // Final notification
          await BackgroundService.updateNotification({
            taskTitle: habitName,
            taskDesc: 'Timer finished!',
            progressBar: {
              max: target,
              value: target,
              indeterminate: false,
            },
          });

          // End the loop
          break;
        } else {
          // 5️⃣ Regular tick – update elapsed time & notification
          useHabitStore.getState().setTimerTimeElapsed(elapsed);

          const remaining = Math.max(0, target - elapsed);
          await BackgroundService.updateNotification({
            taskTitle: habitName,
            taskDesc: `Remaining: ${formatTime(remaining)}`, // helper defined elsewhere
            progressBar: {
              max: target,
              value: elapsed,
              indeterminate: false,
            },
          });
        }

        // 6️⃣ Sleep 1 s to save battery/CPU
        await sleep(1000);
      }

      // Loop exited – resolve the outer promise
      resolve();
    };

    // Kick‑off the async loop
    loop();
  });
};

关键点

  • 命令式存储访问 (useHabitStore.getState()) 是在服务内部读取/写入状态的唯一方式。
  • 循环在每次迭代时检查 BackgroundService.isRunning(),因此当服务停止时任务会干净地结束。
  • sleep(1000) 防止循环占用过多 CPU。

5️⃣ 将 UI 与后台任务挂钩

// HabitTimer.tsx
import { useEffect } from 'react';
import BackgroundService from 'react-native-background-actions';
import { useHabitStore } from './store/habitStore';
import { timerBackgroundTask } from './timerBackgroundTask';

// Options for the foreground service notification
const serviceOptions = {
  taskName: 'HabitTimer',
  taskTitle: 'Habit Timer',
  taskDesc: 'Running...',
  // Android only – customize the notification appearance
  notification: {
    channelId: 'habit-timer',
    channelName: 'Habit Timer',
    color: '#ff0000',
    // icon: 'ic_launcher', // optional
  },
};

export const HabitTimer = () => {
  const {
    timerIsRunning,
    timerIsCompleted,
    timerTargetSeconds,
    habitName,
    setTimerIsRunning,
    setTimerIsCompleted,
    setTimerTimeElapsed,
    // any other needed actions...
  } = useHabitStore();

  // Start the foreground service when the timer becomes active
  useEffect(() => {
    const startService = async () => {
      if (timerIsRunning && !timerIsCompleted) {
        await BackgroundService.start(timerBackgroundTask, serviceOptions, {
          // Pass any data the task needs
          targetSeconds: timerTargetSeconds,
          habitName,
        });
      }
    };

    startService();

    // Cleanup – stop the service when the component unmounts or timer stops
    return () => {
      if (!timerIsRunning || timerIsCompleted) {
        BackgroundService.stop();
      }
    };
  }, [timerIsRunning, timerIsCompleted, timerTargetSeconds, habitName]);

  // UI rendering (simplified)
  return (
    <div>
      {habitName}
      {/* Render timer UI, start/pause buttons, etc. */}
    </div>
  );
};

说明

  1. useEffect 监听 Zustand 状态中的相关字段(timerIsRunningtimerIsCompleted 等)。
  2. 当计时器启动时,我们调用 BackgroundService.start(...),传入:
    • 任务函数(timerBackgroundTask
    • 通知选项(serviceOptions
    • 任务需要的任何参数(targetSecondshabitName)。
  3. 清理函数会在计时器暂停、完成或组件卸载时停止服务。

6️⃣ 辅助工具:格式化时间(可选)

// utils.ts
export const formatTime = (seconds: number): string => {
  const mins = Math.floor(seconds / 60);
  const secs = seconds % 60;
  return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
};

🎉 回顾

步骤您所做的
1️⃣ 安装添加了 react-native-background-actions
2️⃣ 清单声明了前台服务权限和服务
3️⃣ Store 访问在后台循环中将使用方式从 hooks 切换为 store.getState()
4️⃣ 任务实现了一个异步循环,在每次迭代中更新 Zustand、更新通知,并休眠 1 秒
5️⃣ UI 绑定使用 useEffect 根据 Zustand 状态启动/停止前台服务
6️⃣ 附加添加了一个小型 formatTime 辅助函数,用于美化通知

现在,即使应用被置于后台,计时器仍会继续运行,用户会看到持久的通知,且 UI 能够通过 Zustand 的命令式 API 保持同步。祝你构建习惯的过程愉快! 🚀

背景服务集成

import { timerBackgroundTask } from './backgroundTasks'; // Import the function above
import { COLORS } from './theme';

// Inside your TimerComponent...
const {
  timerIsRunning,
  timerIsCompleted,
  selectedHabit,
  targetSeconds,
} = useHabitStore();

useEffect(() => {
  const manageBackgroundService = async () => {
    // -------------------------------------------------
    // Condition to **START**: timer is running, not finished,
    // and a habit is selected
    // -------------------------------------------------
    if (timerIsRunning && !timerIsCompleted && selectedHabit) {
      // Options for the native notification
      const options = {
        taskName: 'Habit Timer Task',
        taskTitle: selectedHabit.name,
        taskDesc: 'Timer Running...',
        taskIcon: {
          name: 'ic_launcher', // Ensure this icon exists in android/app/src/main/res/mipmap-*
          type: 'mipmap',
        },
        color: COLORS.primary,
        parameters: {
          // Pass initial data to the background‑task arguments
          targetSeconds,
          habitName: selectedHabit.name,
        },
      };

      try {
        // Only start if it's not already running
        if (!BackgroundService.isRunning()) {
          await BackgroundService.start(timerBackgroundTask, options);
          console.log('Background service started successfully');
        }
        // Optional: else { BackgroundService.updateNotification(...) } 
        // if you need UI‑only updates
      } catch (error) {
        console.error('Background Service Start failed:', error);
      }
    } else {
      // -------------------------------------------------
      // Logic to **STOP**: timer paused in UI or completed
      // -------------------------------------------------
      if (BackgroundService.isRunning()) {
        console.log('Stopping background service...');
        await BackgroundService.stop(); // Initiates teardown of the while loop
      }
    }
  };

  manageBackgroundService();

  // Re‑run this logic whenever the timer state changes
}, [timerIsRunning, timerIsCompleted, selectedHabit, targetSeconds]);

实现技巧

  • 状态管理悖论
    后台任务是普通的 JavaScript 函数,不是 React 组件。
    你不能在其中使用 hooks。如果你依赖 Redux、Zustand 或其他 store,请使用它们的 命令式 APIstore.getState()store.dispatch())在循环内部读取/写入数据。

  • 通知循环
    BackgroundService.updateNotification 让你保持通知“活跃”。
    每秒更新进度条和计时器文字,让用户确信应用仍在运行,即使在口袋里。

  • 清理工作至关重要
    useEffect 的依赖数组至关重要。当 UI 中的 timerIsRunning 变为 false 时,你 必须 调用 BackgroundService.stop()
    如果不这样做,会留下“幽灵进程”,消耗电池并让用户感到烦恼。

  • 稳健的计时
    请注意,后台任务使用 Date.now() 的差值,而不是简单的 elapsed++ 计数器。
    使用系统时间差可以确保即使操作系统短暂暂停执行,也能保持准确的计时。

这种做法为我的习惯追踪器提供了稳健的“设置后忘记”体验,匹配了基于滑动的 UI 的流畅感。


如需了解更多关于网页或移动开发的信息,欢迎访问我的网站。

Back to Blog

相关文章

阅读更多 »