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

Flutter Circular Countdown Timer 后台持续运行不停止实现问题咨询

Flutter多计时器后台持续运行解决方案

问题本质

  1. 你当前使用的CircularCountDownTimer控件绑定在ViewPromoters页面的State上,按下返回键页面出栈被销毁时,State连带计时器控制器一起被回收,计时器自然停止运行
  2. 目前你只初始化了1个CountDownController,但ListView中渲染了3个计时器,所有计时器共用同一个控制器,本身逻辑就存在冲突,无法实现多计时器独立控制

具体实现步骤

1. 搭建全局计时器管理类

创建单例模式的计时器管理类,完全独立于页面存在,统一管理所有倒计时任务的状态,核心字段包含每个任务的唯一ID、总时长、启动时间戳、暂停时的剩余时长、运行状态。

import 'dart:async';
import 'package:awesome_notifications/awesome_notifications.dart';

class TimerTask {
  final String id;
  final int totalSeconds;
  int? startTimeStamp;
  int remainingSeconds;
  bool isRunning;

  TimerTask({
    required this.id,
    required this.totalSeconds,
    this.remainingSeconds = 0,
    this.isRunning = false,
    this.startTimeStamp,
  });
}

class TimerManager {
  TimerManager._internal();
  static final TimerManager _instance = TimerManager._internal();
  factory TimerManager() => _instance;

  final Map<String, TimerTask> _timerTasks = {};
  Timer? _globalTickTimer;
  final StreamController<TimerTask> _timerUpdateController = StreamController<TimerTask>.broadcast();
  Stream<TimerTask> get timerUpdateStream => _timerUpdateController.stream;

  // 新增/启动计时器
  void startTimer(String taskId, int totalSeconds) {
    if (_timerTasks.containsKey(taskId)) {
      final task = _timerTasks[taskId]!;
      task.startTimeStamp = DateTime.now().millisecondsSinceEpoch;
      task.isRunning = true;
    } else {
      _timerTasks[taskId] = TimerTask(
        id: taskId,
        totalSeconds: totalSeconds,
        remainingSeconds: totalSeconds,
        isRunning: true,
        startTimeStamp: DateTime.now().millisecondsSinceEpoch,
      );
    }
    _startGlobalTick();
  }

  // 全局秒级定时器,统一更新所有运行中的计时器
  void _startGlobalTick() {
    if (_globalTickTimer != null && _globalTickTimer!.isActive) return;
    _globalTickTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
      final now = DateTime.now().millisecondsSinceEpoch;
      List<String> finishedTasks = [];
      _timerTasks.forEach((taskId, task) {
        if (!task.isRunning) return;
        final elapsed = (now - task.startTimeStamp!) ~/ 1000;
        task.remainingSeconds = task.totalSeconds - elapsed;
        if (task.remainingSeconds <= 0) {
          task.remainingSeconds = 0;
          task.isRunning = false;
          finishedTasks.add(taskId);
          // 触发完成回调,发通知
          _triggerTaskFinish(task);
        }
        _timerUpdateController.add(task);
      });
      // 移除已完成的任务
      for (var id in finishedTasks) {
        _timerTasks.remove(id);
      }
      // 没有运行中的任务就停止全局定时器
      if (_timerTasks.values.where((t) => t.isRunning).isEmpty) {
        _globalTickTimer?.cancel();
        _globalTickTimer = null;
      }
    });
  }

  // 暂停计时器
  void pauseTimer(String taskId) {
    if (!_timerTasks.containsKey(taskId)) return;
    final task = _timerTasks[taskId]!;
    task.isRunning = false;
  }

  // 获取指定任务剩余时间
  int getRemainingSeconds(String taskId) {
    return _timerTasks[taskId]?.remainingSeconds ?? 0;
  }

  // 触发完成通知
  void _triggerTaskFinish(TimerTask task) async {
    await AwesomeNotifications().createNotification(
      content: NotificationContent(
        id: task.id.hashCode,
        channelKey: 'key1',
        title: '计时结束',
        body: '任务${task.id}计时已完成',
      )
    );
  }
}

2. 配置后台运行权限

要保证应用退到后台、页面销毁后计时器还能运行,需要引入后台服务插件:

  • 在pubspec.yaml中添加依赖:flutter_background_service: ^5.0.5
  • 按照插件官方要求配置Android、iOS的后台运行权限,将全局计时器的逻辑移到后台服务中运行,即可保证应用退到后台也不会被系统回收

3. 修改页面渲染逻辑

去掉原来共用的CountDownController,改为监听全局TimerManager的流更新,每个计时器对应独立的任务ID,实时渲染剩余时间和环形进度:

@override
void initState() {
  super.initState();
  fetchdata();
  // 监听计时器更新,刷新UI
  TimerManager().timerUpdateStream.listen((task) {
    if(mounted) setState(() {});
  });
}

// ListView中每个计时器的渲染逻辑,替换原来的CircularCountDownTimer
@override
Widget build(BuildContext context) {
  double width = MediaQuery.of(context).size.width * 0.6;
  return Scaffold(
    appBar: AppBar(
      title: const Text(
        "Promoters Detail",
        style: TextStyle(color: Colors.white),
      ),
      actions: <Widget>[
        IconButton(
          icon: const Icon(
            Icons.notifications_active,
            color: Colors.white,
          ),
          onPressed: () => Notify(),
        )
      ],
      elevation: 5,
      backgroundColor: Colors.green,
    ),
    body: ListView.builder(
      itemCount: listResponse?.length ?? 0,
      itemBuilder: (context, index) {
        String taskId = listResponse[index]['id'].toString();
        int totalSeconds = 120;
        int remaining = TimerManager().getRemainingSeconds(taskId);
        double progress = totalSeconds == 0 ? 0 : remaining / totalSeconds;
        return GestureDetector(
          onTap: () {
            p_id = listResponse[index]['id'].toString();
            p_name = listResponse[index]['name'].toString();
            showDialogFunc(context, imgList[index], p_name, descList[index],
                timeList[index], p_id, a_id);
          },
          child: Card(
            color: Colors.white,
            elevation: 5,
            child: Column(
              children: [
                // 自定义环形倒计时,无需依赖第三方控件
                SizedBox(
                  width: MediaQuery.of(context).size.width /6,
                  height: MediaQuery.of(context).size.width /6,
                  child: Stack(
                    alignment: Alignment.center,
                    children: [
                      CircularProgressIndicator(
                        value: progress,
                        backgroundColor: Colors.white,
                        color: Colors.green,
                        strokeWidth: 10,
                      ),
                      Text(
                        '$remaining',
                        style: const TextStyle(fontSize: 20, color: Colors.black),
                      )
                    ],
                  ),
                ),
                TextButton(
                  onPressed: () => TimerManager().startTimer(taskId, totalSeconds),
                  child: const Text('启动计时'),
                ),
                // 原有列表项布局逻辑保持不变
                Row(
                  children: <Widget>[
                    CircleAvatar(
                      radius: 60,
                      backgroundImage: AssetImage(imgList[index]),
                    ),
                    Padding(
                      padding: const EdgeInsets.all(10.0),
                      child: Column(
                        crossAxisAlignment: CrossAxisAlignment.start,
                        children: <Widget>[
                          Text(
                            listResponse[index]['name'].toString(),
                            style: TextStyle(
                              color: Colors.grey.shade500,
                              fontSize: 20.0,
                              fontWeight: FontWeight.w400,
                            ),
                          ),
                          const SizedBox(height: 10),
                          // 原有描述类布局保持不变
                          Container(
                            width: width,
                            child: Row(
                              children: [
                                Icon(
                                  Icons.add_task_sharp,
                                  color: Colors.grey[500],
                                  size: 20.0,
                                ),
                                const SizedBox(width: 7),
                                Text(
                                  descList[index],
                                  maxLines: 3,
                                  style: TextStyle(
                                      fontSize: 15,
                                      color: Colors.grey[500],
                                      fontWeight: FontWeight.w500),
                                ),
                              ],
                            ),
                          ),
                          // 剩余原有布局逻辑保持不变
                        ],
                      ),
                    )
                  ],
                ),
              ],
            ),
          ),
        );
      },
    ),
  );
}

注意事项

  1. 如果你需要应用被彻底杀掉后重启还能恢复之前的计时,可以将TimerTask的状态持久化到本地存储(比如shared_preferences),应用启动时读取恢复即可
  2. 多个计时器的通知ID要唯一,避免覆盖,上面示例中用task.id.hashCode作为通知ID即可
  3. Android平台要申请锁屏唤醒、后台运行权限,避免系统自动杀死后台进程

内容的提问来源于stack exchange,提问作者ARPIT JAISWAL IET Student

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.10.08 12:00:02