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

Flutter借助Firebase实现多设备/屏幕同步计时的最优方案

计分板实时同步优化方案

问题描述

我正在开发一款计分板应用,已完成本地秒表功能,但遇到同步问题:启动秒表后,其他设备或屏幕需同步显示计时状态,启动、暂停等操作要实时同步给所有用户。尝试了部分实现方案,但运行不流畅,求最优实现方式。

当前代码

time.dart(私有控制端)

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:cloud_firestore/cloud_firestore.dart';

final CollectionReference time = FirebaseFirestore.instance.collection('game');

class Time extends StatefulWidget {
  @override
  State<Time> createState() => TimeState();
}

class TimeState extends State<Time> {
  Duration duration = Duration();
  Timer? timer;

  final myController = TextEditingController();
  
  @override
  void initState() {
    super.initState();
    reset();
  }

  void reset() {
    setState(() => duration = Duration());
  }

  void addTime() {
    const addSeconds = 1;
    setState(() {
      final seconds = duration.inSeconds + addSeconds;
      duration = Duration(seconds: seconds);
    });
  }

  void startTimer({bool resets = true}) {
    if (resets) {
      reset();
    }
    timer = Timer.periodic(Duration(seconds: 1), (_) => addTime());
  }

  void stopTimer({bool resets = true}) {
    if (resets) {
      reset();
    }
    setState(() {
      timer?.cancel();
    });
  }

  void firstHalfEnd() {
    setState(() => duration = Duration(minutes: 45, seconds: 00));
    stopTimer(resets: false);
  }

  void secondHalfEnd() {
    setState(() => duration = Duration(minutes: 90, seconds: 00));
    stopTimer(resets: false);
  }

  void addMinute (){
    setState(() {
      duration = duration + const Duration(minutes: 1);
    });
  }

  void subtractMinute (){
    setState(() {
      duration = duration - const Duration(minutes: 1);
    });
  }

  @override
  Widget build(BuildContext context) => Scaffold(
        backgroundColor: Colors.green, 
        body: Center(
            child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            buiildTime(),
            SizedBox(height: 20), 
            buildButtons()
          ],
        )),
      );

  Widget buiildTime() {
    String twoDigits(int n) => n.toString().padLeft(2, '0');
    final minutes = twoDigits(duration.inMinutes.remainder(100));
    final seconds = twoDigits(duration.inSeconds.remainder(60));
    setState(() {
      time.doc('Time').collection('minutes').doc('minutes').set({'minutes': '$minutes'});
      time.doc('Time').update({'seconds': '$seconds'});
    });
    return Text(
      '$minutes:$seconds',
      style: TextStyle(fontSize: 20),
    );
  }

  Widget buildButtons() {
    bool time_going = timer == null ? false : timer!.isActive;
    bool time_stops = duration.inSeconds == 0;
   
    return time_going || !time_stops
        ? Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Row(
                children: [
                  TextButton(
                    child:Text(time_going ? 'PAUSE' : 'RESUME'),
                    onPressed: () {
                      if (time_going) {
                        stopTimer(resets: false);
                      } else {
                        startTimer(resets: false);
                      }
                    }),
                  SizedBox(width: 12),
                  TextButton(child: Text("RESET"), onPressed: reset),
                ],
              ),
              SizedBox(height: 10),
              Row(
                crossAxisAlignment: CrossAxisAlignment.center,
                children: <Widget>[
                  TextButton(
                    onPressed: firstHalfEnd,
                    child: Text('1. Half END'),
                    style: ButtonStyle(
                      backgroundColor: MaterialStateProperty.all<Color>(Colors.blue),
                      foregroundColor: MaterialStateProperty.all<Color>(Colors.white),
                    ),
                  ),
                  SizedBox(width: 10),
                  TextButton(
                    onPressed: secondHalfEnd,
                    child: Text('2. Half END'),
                    style: ButtonStyle(
                      backgroundColor: MaterialStateProperty.all<Color>(Colors.blue),
                      foregroundColor: MaterialStateProperty.all<Color>(Colors.white),
                    ),
                  ),
                ],
              ),
              Row(
                children: [
                  ElevatedButton(onPressed: addMinute, child: Text('+1 minute')),
                  SizedBox(width: 10),
                  ElevatedButton(onPressed: subtractMinute, child: Text('-1 minute'))
                ],
              )
            ],
          )
        : TextButton(
            onPressed: startTimer,
            child: Text('Start Timer!'),
            style: ButtonStyle(
              backgroundColor: MaterialStateProperty.all<Color>(Colors.black),
              foregroundColor: MaterialStateProperty.all<Color>(Colors.white),
            ),
          );
  }
}

scoreboard.dart(公共展示端)

Container(
  height: 100,
  width: 100,
  child: StreamBuilder(
    stream: clubData.doc('Time').snapshots(),
    builder: (context, snapshot) {
      if (!snapshot.hasData) {
        return Text("Loading");
      } else {
        Map<String, dynamic> data = snapshot.data!.data() as Map<String, dynamic>;
        return Text(
          ":${data['seconds']}",
          style: TextStyle(color: Colors.white, fontSize: 20),
        );
      }
    }
))

核心问题分析

  1. 频繁的Firestore读写:当前代码在buiildTime方法中每秒执行Firestore更新,build方法会被频繁调用,导致每秒都向Firestore写入数据,不仅消耗资源,还会让展示端的StreamBuilder频繁重建,引发卡顿。
  2. 同步逻辑错误:直接同步显示的分钟/秒数,而不是同步计时的核心状态(运行状态、基准时间),导致同步依赖高频写入,无法保证多设备时间一致性。

优化实现方案

1. 调整Firestore存储结构

只存储计时的核心状态字段,避免每秒写入:

// Firestore文档结构示例
{
  "isRunning": bool,       // 是否正在计时
  "elapsedSeconds": int,   // 暂停时已流逝的总秒数
  "startTimestamp": FieldValue.serverTimestamp(), // 启动时的服务器时间
}

2. 控制端逻辑修改

将计时逻辑与Firestore同步分离,仅在状态变更时写入:

class TimeState extends State<Time> {
  Duration duration = Duration();
  Timer? timer;
  final DocumentReference timeDoc = FirebaseFirestore.instance.collection('game').doc('Time');

  @override
  void initState() {
    super.initState();
    // 初始化时从Firestore拉取最新状态
    timeDoc.get().then((doc) {
      if (doc.exists) {
        var data = doc.data() as Map<String, dynamic>;
        setState(() {
          duration = Duration(seconds: data['elapsedSeconds'] ?? 0);
          if (data['isRunning'] == true) {
            // 如果远程状态是运行中,本地继续计时
            resumeTimerFromRemote(data['startTimestamp']);
          }
        });
      }
    });
  }

  // 从远程状态恢复计时
  void resumeTimerFromRemote(Timestamp startTimestamp) {
    final elapsedSinceStart = DateTime.now().difference(startTimestamp.toDate());
    duration = Duration(seconds: (duration.inSeconds + elapsedSinceStart.inSeconds));
    timer = Timer.periodic(Duration(seconds: 1), (_) => addTime());
  }

  void addTime() {
    setState(() {
      duration += Duration(seconds: 1);
    });
  }

  void startTimer({bool resets = true}) {
    if (resets) {
      duration = Duration();
    }
    timer = Timer.periodic(Duration(seconds: 1), (_) => addTime());
    // 同步到Firestore
    timeDoc.set({
      'isRunning': true,
      'elapsedSeconds': duration.inSeconds,
      'startTimestamp': FieldValue.serverTimestamp(),
    }, SetOptions(merge: true));
  }

  void stopTimer({bool resets = true}) {
    timer?.cancel();
    if (resets) {
      duration = Duration();
    }
    // 同步到Firestore
    timeDoc.set({
      'isRunning': false,
      'elapsedSeconds': duration.inSeconds,
    }, SetOptions(merge: true));
  }

  void firstHalfEnd() {
    timer?.cancel();
    setState(() => duration = Duration(minutes: 45));
    timeDoc.set({
      'isRunning': false,
      'elapsedSeconds': 45 * 60,
    }, SetOptions(merge: true));
  }

  void secondHalfEnd() {
    timer?.cancel();
    setState(() => duration = Duration(minutes: 90));
    timeDoc.set({
      'isRunning': false,
      'elapsedSeconds': 90 * 60,
    }, SetOptions(merge: true));
  }

  void addMinute() {
    setState(() => duration += Duration(minutes: 1));
    timeDoc.set({
      'elapsedSeconds': duration.inSeconds,
    }, SetOptions(merge: true));
  }

  void subtractMinute() {
    setState(() => duration -= Duration(minutes: 1));
    timeDoc.set({
      'elapsedSeconds': duration.inSeconds,
    }, SetOptions(merge: true));
  }

  // 移除build中的Firestore写入,仅显示本地计算的时间
  Widget buiildTime() {
    String twoDigits(int n) => n.toString().padLeft(2, '0');
    final minutes = twoDigits(duration.inMinutes.remainder(100));
    final seconds = twoDigits(duration.inSeconds.remainder(60));
    return Text(
      '$minutes:$seconds',
      style: TextStyle(fontSize: 20),
    );
  }

  // ... 其余buildButtons代码保持不变
}

3. 展示端逻辑修改

通过远程状态实时计算当前时间,无需依赖高频更新:

Container(
  height: 100,
  width: 100,
  child: StreamBuilder<DocumentSnapshot>(
    stream: FirebaseFirestore.instance.collection('game').doc('Time').snapshots(),
    builder: (context, snapshot) {
      if (!snapshot.hasData) {
        return Text("Loading");
      }
      final data = snapshot.data!.data() as Map<String, dynamic>?;
      if (data == null) return Text("00:00");

      int totalSeconds = data['elapsedSeconds'] ?? 0;
      if (data['isRunning'] == true) {
        final startTimestamp = data['startTimestamp'] as Timestamp?;
        if (startTimestamp != null) {
          final elapsedSinceStart = DateTime.now().difference(startTimestamp.toDate());
          totalSeconds += elapsedSinceStart.inSeconds;
        }
      }

      final minutes = totalSeconds ~/ 60;
      final seconds = totalSeconds % 60;
      String twoDigits(int n) => n.toString().padLeft(2, '0');

      return Text(
        "${twoDigits(minutes)}:${twoDigits(seconds)}",
        style: TextStyle(color: Colors.white, fontSize: 20),
      );
    },
  ),
)

优化效果

  • 大幅减少Firestore读写次数:仅在启动、暂停、手动调整时间时写入,不再每秒更新。
  • 多设备时间一致性更高:基于服务器时间计算,避免本地计时误差。
  • 展示端流畅度提升:StreamBuilder仅在状态变更时重建,不再每秒刷新。

内容的提问来源于stack exchange,提问作者Robert Tomic

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.20 07:44:17