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

Flutter Firestore聊天应用:单条消息触发两次监听且时间戳异常

聊天应用Firestore监听重复触发问题排查与解决

问题背景

开发聊天应用,流程为在TextField输入消息后点击按钮保存至Firestore数据库。发送一条消息后,数据库仅存储一条消息,但监听新消息的listen方法触发两次,且两次返回的时间戳不同。


相关代码片段

1. 消息发送代码

var _mesajCont = TextEditingController();
...
onPressed: () async {
            if (_mesajCont.text.trim().length > 0) {
              Mesaj _kaydedilecMesaj = Mesaj(
                  bendenMi: true,
                  message: _mesajCont.text,
                  kimden: _chatModel.currentUser!.userID,
                  kime: _chatModel.sohbetedilenUser!.userID);

              var sonuc = await _chatModel.saveMessage(_kaydedilecMesaj); // 保存消息
              
              if (sonuc) {
                _mesajCont.clear();
              }
            }
          },

2. Firestore服务代码(保存+获取消息)

Future<bool> saveMessage(Mesaj kaydedilecMesaj) async {
var mesajID = _firestoreDB.collection("konusmalar").doc().id;
var _myDocID = kaydedilecMesaj.kimden + "--" + kaydedilecMesaj.kime;
var _alici = kaydedilecMesaj.kime + "--" + kaydedilecMesaj.kimden;
var _kaydedilecMap = kaydedilecMesaj
    .toMap(); // bundan nesne üretince false sorunu düzeldi ilginc
await _firestoreDB
    .collection("konusmalar")
    .doc(_myDocID)
    .collection("mesajlar")
    .doc(mesajID)
    .set(_kaydedilecMap);

await _firestoreDB.collection("konusmalar").doc(_myDocID).set({
  "konusma_sahibi": kaydedilecMesaj.kimden,
  "kimle_konusuyor": kaydedilecMesaj.kime,
  "son_yollanan_mesaj": kaydedilecMesaj.message,
  "konusma_goruldu": false,
  "olusturulma_tarihi": FieldValue.serverTimestamp(),
});

_kaydedilecMap.update("bendenMi", (value) => false);

await _firestoreDB
    .collection("konusmalar")
    .doc(_alici)
    .collection("mesajlar")
    .doc(mesajID)
    .set(_kaydedilecMap);

await _firestoreDB.collection("konusmalar").doc(_alici).set({
  "konusma_sahibi": kaydedilecMesaj.kime,
  "kimle_konusuyor": kaydedilecMesaj.kimden,
  "son_yollanan_mesaj": kaydedilecMesaj.message,
  "konusma_goruldu": false,
  "olusturulma_tarihi": FieldValue.serverTimestamp(),
});

return true;
}
Stream<List<Mesaj>> getMassage(
  String currentUserID, String sohbetEdilenUserID) {
var snapShot = _firestoreDB
    .collection("konusmalar")
    .doc(currentUserID + "--" + sohbetEdilenUserID)
    .collection("mesajlar")
    //.where("konusma_sahibi", isEqualTo: currentUserID)
    .orderBy("date", descending: true /*tarihi son olanı öne koyar*/)
    .limit(1)
    .snapshots();

return snapShot.map((mesajListesi) =>
    mesajListesi.docs.map((mesaj) => Mesaj.fromMap(mesaj.data())).toList());

}

3. UI监听与消息列表渲染

Widget _buildMesajListesi() {
return Consumer<ChatViewModel>(builder: (context, value, child) {
  return Expanded(
    child: ListView.builder(
      reverse: true,
      controller: _scrollCont,
      itemBuilder: ((context, index) {
        
          return talkingBalloon(value.mesajlarListesi![index]); // 新消息气泡
        
      }),
      itemCount: value.dahaVarmii
          ? value.mesajlarListesi!.length + 1
          : value.mesajlarListesi!.length,
    ),
  );
});
}

4. ViewModel中的监听逻辑

List<Mesaj>? _tumMesajlar;
List<Mesaj>? get mesajlarListesi => _tumMesajlar;
.....
void yeniMesajListenerGet() {
_userRepository
    .getMessage(currentUser!.userID, sohbetedilenUser!.userID)
    .listen((anlikData) {
  debugPrint("LISTENER GET DATA>>>>${anlikData[0].toString()}"); 

  if (anlikData[0] != null) {
    if (_listeyeilkMesaj!.date!.millisecondsSinceEpoch !=
        anlikData[0].date!.millisecondsSinceEpoch) {
      _tumMesajlar!.insert(0, anlikData[0]);
    }
  }
});
}

问题现象

发送一条消息后,控制台输出两次监听日志,时间戳不同:

LISTENER GET DATA>>>>WHO XzAHsfvxmYUqHirg5eH7ifT0Qtf1, TO WHO WoxT4Nxomeh1MQOE6p8eOadgRYa2, ME-> true, MESSAGE same message but diff timeStamp, TİMESTAMP Timestamp(seconds=1679296897, nanoseconds=792506000)
LISTENER GET DATA>>>>WHO XzAHsfvxmYUqHirg5eH7ifT0Qtf1, TO WHO WoxT4Nxomeh1MQOE6p8eOadgRYa2, ME-> true, MESSAGE same message but diff timeStamp, TİMESTAMP Timestamp(seconds=1679296914,nanoseconds=30000000)

数据库中仅存在一条消息,但两次监听返回的时间戳不一致。


原因分析

Firestore的FieldValue.serverTimestamp()存在两次写入机制:

  1. 客户端写入时,先使用本地生成的临时时间戳占位,立即触发监听回调;
  2. 服务器确认写入后,将临时时间戳替换为真实的服务器时间,文档更新再次触发监听回调。

你的监听逻辑是获取mesajlar集合的最新一条消息,当时间戳从临时值更新为服务器真实值时,会再次触发监听,导致同一消息被处理两次。


解决方案

方案一:使用客户端时间戳替代服务器时间戳

在创建Mesaj对象时直接使用客户端当前时间作为date字段,避免Firestore的二次更新:

Mesaj _kaydedilecMesaj = Mesaj(
    bendenMi: true,
    message: _mesajCont.text,
    kimden: _chatModel.currentUser!.userID,
    kime: _chatModel.sohbetedilenUser!.userID,
    date: DateTime.now() // 直接使用客户端当前时间
);

此方案简单直接,消息文档只会写入一次,不会触发二次监听。

方案二:通过消息ID过滤重复监听

修改ViewModel中的监听逻辑,以mesajID作为唯一判断依据(需确保Mesaj类包含mesajID字段并正确序列化):

void yeniMesajListenerGet() {
  _userRepository
      .getMessage(currentUser!.userID, sohbetedilenUser!.userID)
      .listen((anlikData) {
    debugPrint("LISTENER GET DATA>>>>${anlikData[0].toString()}"); 

    if (anlikData[0] != null) {
      // 检查列表中是否已存在相同mesajID的消息
      bool isDuplicate = _tumMesajlar!.any((mesaj) => mesaj.mesajID == anlikData[0].mesajID);
      if (!isDuplicate) {
        _tumMesajlar!.insert(0, anlikData[0]);
      }
    }
  });
}

此方案保留服务器时间戳的同时,避免重复插入同一条消息。

方案三:优化监听逻辑,获取完整消息集合快照

去掉getMassage方法中的limit(1),监听整个消息集合的变更,通过文档快照的类型判断是新增还是更新:

Stream<List<Mesaj>> getMassage(
  String currentUserID, String sohbetEdilenUserID) {
  var snapShot = _firestoreDB
      .collection("konusmalar")
      .doc(currentUserID + "--" + sohbetEdilenUserID)
      .collection("mesajlar")
      .orderBy("date", descending: true)
      .snapshots(); // 移除limit(1)

  return snapShot.map((mesajListesi) =>
      mesajListesi.docs.map((mesaj) => Mesaj.fromMap(mesaj.data())).toList());
}

之后在ViewModel中直接更新整个消息列表,避免单条消息更新导致的重复触发。


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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.27 23:07:10