[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"i-ri:search-line":3,"i-ri:moon-line":8,"i-ri:sun-line":10,"i-ri:menu-3-line":12,"i-ri:close-line":14,"notes-module-items-im":16,"notes-page-im-01-message-state\u002F04-unread-refresh":66,"i-ri:arrow-left-s-line":333,"i-ri:menu-2-line":335,"i-ri:arrow-right-s-line":337,"i-ep:copy-document":339},{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":7},0,24,false,"\u003Cpath fill=\"currentColor\" d=\"m18.031 16.617l4.283 4.282l-1.415 1.415l-4.282-4.283A8.96 8.96 0 0 1 11 20c-4.968 0-9-4.032-9-9s4.032-9 9-9s9 4.032 9 9a8.96 8.96 0 0 1-1.969 5.617m-2.006-.742A6.98 6.98 0 0 0 18 11c0-3.867-3.133-7-7-7s-7 3.133-7 7s3.133 7 7 7a6.98 6.98 0 0 0 4.875-1.975z\"\u002F>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":9},"\u003Cpath fill=\"currentColor\" d=\"M10 7a7 7 0 0 0 12 4.9v.1c0 5.523-4.477 10-10 10S2 17.523 2 12S6.477 2 12 2h.1A6.98 6.98 0 0 0 10 7m-6 5a8 8 0 0 0 15.062 3.762A9 9 0 0 1 8.238 4.938A8 8 0 0 0 4 12\"\u002F>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":11},"\u003Cpath fill=\"currentColor\" d=\"M12 18a6 6 0 1 1 0-12a6 6 0 0 1 0 12m0-2a4 4 0 1 0 0-8a4 4 0 0 0 0 8M11 1h2v3h-2zm0 19h2v3h-2zM3.515 4.929l1.414-1.414L7.05 5.636L5.636 7.05zM16.95 18.364l1.414-1.414l2.121 2.121l-1.414 1.414zm2.121-14.85l1.414 1.415l-2.121 2.121l-1.414-1.414zM5.636 16.95l1.414 1.414l-2.121 2.121l-1.414-1.414zM23 11v2h-3v-2zM4 11v2H1v-2z\"\u002F>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":13},"\u003Cpath fill=\"currentColor\" d=\"M3 4h18v2H3zm6 7h12v2H9zm-6 7h18v2H3z\"\u002F>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":15},"\u003Cpath fill=\"currentColor\" d=\"m12 10.587l4.95-4.95l1.414 1.414l-4.95 4.95l4.95 4.95l-1.415 1.414l-4.95-4.95l-4.949 4.95l-1.414-1.415l4.95-4.95l-4.95-4.95L7.05 5.638z\"\u002F>",[17,22,26,30,34,38,42,46,50,54,58,62],{"path":18,"title":19,"meta":20},"\u002Fnotes\u002Fim\u002F01-message-state\u002F01-offline-sync","连接恢复了，离线消息为什么还没补齐？",{"date":-1,"tags":-1,"summary":-1,"lastUpdated":21},"2026-11-05T09:47:15.000Z",{"path":23,"title":24,"meta":25},"\u002Fnotes\u002Fim\u002F01-message-state\u002F02-read-cursor","手机读过了，电脑为何又显示未读？",{"date":-1,"tags":-1,"summary":-1,"lastUpdated":21},{"path":27,"title":28,"meta":29},"\u002Fnotes\u002Fim\u002F01-message-state\u002F03-pagination-search","聊天搜索跳回旧消息后，怎样继续翻页？",{"date":-1,"tags":-1,"summary":-1,"lastUpdated":21},{"path":31,"title":32,"meta":33},"\u002Fnotes\u002Fim\u002F01-message-state\u002F04-unread-refresh","消息没停，未读角标就不更新？",{"date":-1,"tags":-1,"summary":-1,"lastUpdated":21},{"path":35,"title":36,"meta":37},"\u002Fnotes\u002Fim\u002F02-connection-protocol\u002F01-stream-parsing","一次接收回调，为什么不是一条消息？",{"date":-1,"tags":-1,"summary":-1,"lastUpdated":21},{"path":39,"title":40,"meta":41},"\u002Fnotes\u002Fim\u002F02-connection-protocol\u002F02-request-response","长连接上的请求，怎样等到自己的响应？",{"date":-1,"tags":-1,"summary":-1,"lastUpdated":21},{"path":43,"title":44,"meta":45},"\u002Fnotes\u002Fim\u002F02-connection-protocol\u002F03-connection-lifecycle","登录一直在连接中，到底在等什么？",{"date":-1,"tags":-1,"summary":-1,"lastUpdated":21},{"path":47,"title":48,"meta":49},"\u002Fnotes\u002Fim\u002F03-message-content\u002F01-file-download","附件下载完了，为什么还是打不开？",{"date":-1,"tags":-1,"summary":-1,"lastUpdated":21},{"path":51,"title":52,"meta":53},"\u002Fnotes\u002Fim\u002F03-message-content\u002F02-image-send","图片上传到 100%，为什么还没发出去？",{"date":-1,"tags":-1,"summary":-1,"lastUpdated":21},{"path":55,"title":56,"meta":57},"\u002Fnotes\u002Fim\u002F03-message-content\u002F03-mentions","聊天 @提及怎样保存身份和位置？",{"date":-1,"tags":-1,"summary":-1,"lastUpdated":21},{"path":59,"title":60,"meta":61},"\u002Fnotes\u002Fim\u002F04-client-release\u002F01-flutter-build","Release 包为什么仍连着测试环境？",{"date":-1,"tags":-1,"summary":-1,"lastUpdated":21},{"path":63,"title":64,"meta":65},"\u002Fnotes\u002Fim","即时通信",{"date":-1,"tags":-1,"summary":-1,"lastUpdated":21},{"id":67,"type":68,"sourcePath":69,"routePath":31,"path":31,"title":32,"description":70,"updatedAt":21,"meta":71,"body":80,"toc":327},"bm90ZTpub3Rlcy9pbS8wMS1tZXNzYWdlLXN0YXRlLzA0LXVucmVhZC1yZWZyZXNoLm1k","note","notes\u002Fim\u002F01-message-state\u002F04-unread-refresh.md","聊天 App 会在收到消息后更新本地数据，再查询所有会话的未读总数，显示到角标上。查询是异步的，消息多的时候，上一轮还没回来，下一条消息就到了。",{"id":67,"type":68,"sourcePath":69,"slug":72,"title":32,"updatedAt":21,"description":70,"tags":73,"date":77,"comment":78,"summary":79,"lastUpdated":21},"im\u002F01-message-state\u002F04-unread-refresh",[74,75,76],"Flutter","IM","并发","2026-09-10",true,"持续收消息时，慢查询总被判为过期。区分需要再查和必须丢弃结果，让未读数在消息到达期间也能更新。",{"type":81,"children":82},"root",[83,91,96,101,108,113,118,123,129,134,139,144,150,155,160,290,295,300,306,311,316,321],{"type":84,"tag":85,"props":86,"children":88},"element","h1",{"id":87},"消息没停未读角标就不更新",[89],{"type":90,"value":32},"text",{"type":84,"tag":92,"props":93,"children":94},"p",{},[95],{"type":90,"value":70},{"type":84,"tag":92,"props":97,"children":98},{},[99],{"type":90,"value":100},"这里遇到的现象是：消息一直在收，角标却不怎么变；等消息停下来，数字又恢复正常。没有报错，最终数量也对，单看最后一帧很难发现问题。",{"type":84,"tag":102,"props":103,"children":105},"h2",{"id":104},"查询有结果为什么界面收不到",[106],{"type":90,"value":107},"查询有结果，为什么界面收不到？",{"type":84,"tag":92,"props":109,"children":110},{},[111],{"type":90,"value":112},"原来的实现用版本号防止旧结果覆盖新数据。每次收到消息就增加版本，查询开始时记住版本，回来时发现不一致便丢弃，再查一次。",{"type":84,"tag":92,"props":114,"children":115},{},[116],{"type":90,"value":117},"假设一次查询要 750 ms，新消息每 400 ms 到一条。第一轮查到一半版本就变了，750 ms 返回时失效；第二轮查询也会被后续消息打断。只要消息不停，查询就一直有结果，却始终没有结果能交给界面。",{"type":84,"tag":92,"props":119,"children":120},{},[121],{"type":90,"value":122},"这种防旧数据的办法保护了准确性，却让界面失去了持续更新的机会。",{"type":84,"tag":102,"props":124,"children":126},{"id":125},"防抖取消旧查询能解决吗",[127],{"type":90,"value":128},"防抖、取消旧查询，能解决吗？",{"type":84,"tag":92,"props":130,"children":131},{},[132],{"type":90,"value":133},"如果业务只关心输入停止后的最终结果，防抖很合适。但聊天消息可能连续到达，每来一条就重新计时，仍然可能长时间不刷新。每次取消旧查询再启动新查询，也有类似问题。",{"type":84,"tag":92,"props":135,"children":136},{},[137],{"type":90,"value":138},"另一个选择是让查询串行执行，期间的变化合并成下一次刷新。这里还需要区分两类变化：新消息来了，当前数量可能少几条，短暂显示后再补查可以接受；用户刚把会话读完，旧查询却把角标重新点亮，就不能接受。切换账号时，旧账号的数量更不能显示。",{"type":84,"tag":92,"props":140,"children":141},{},[142],{"type":90,"value":143},"因此，刷新请求不必全部导致当前结果作废。",{"type":84,"tag":102,"props":145,"children":147},{"id":146},"这个项目怎样区分这两类变化",[148],{"type":90,"value":149},"这个项目怎样区分这两类变化？",{"type":84,"tag":92,"props":151,"children":152},{},[153],{"type":90,"value":154},"项目保留两个版本。一个表示“查询期间又有变化，需要再查”，另一个表示“当前结果已经不能使用”。新增消息通常只推动前者，已读、清理缓存或账号切换，则按实际影响决定是否作废结果。",{"type":84,"tag":92,"props":156,"children":157},{},[158],{"type":90,"value":159},"查询循环的核心逻辑如下，省略了账号、初始化和错误处理：",{"type":84,"tag":161,"props":162,"children":167},"pre",{"className":163,"code":164,"language":165,"meta":166,"style":166},"language-dart shiki shiki-themes github-light github-dark","while (!disposed) {\n  final startedAt = revision;\n  final validAt = invalidationRevision;\n  final count = await queryCount();\n\n  if (disposed) return;\n  if (validAt == invalidationRevision && count != lastCount) {\n    lastCount = count;\n    publish(count);\n  }\n\n  if (startedAt == revision) return;\n}\n","dart","",[168],{"type":84,"tag":169,"props":170,"children":171},"code",{"__ignoreMap":166},[172,183,192,201,210,219,228,237,246,255,264,272,281],{"type":84,"tag":173,"props":174,"children":177},"span",{"class":175,"line":176},"line",1,[178],{"type":84,"tag":173,"props":179,"children":180},{},[181],{"type":90,"value":182},"while (!disposed) {\n",{"type":84,"tag":173,"props":184,"children":186},{"class":175,"line":185},2,[187],{"type":84,"tag":173,"props":188,"children":189},{},[190],{"type":90,"value":191},"  final startedAt = revision;\n",{"type":84,"tag":173,"props":193,"children":195},{"class":175,"line":194},3,[196],{"type":84,"tag":173,"props":197,"children":198},{},[199],{"type":90,"value":200},"  final validAt = invalidationRevision;\n",{"type":84,"tag":173,"props":202,"children":204},{"class":175,"line":203},4,[205],{"type":84,"tag":173,"props":206,"children":207},{},[208],{"type":90,"value":209},"  final count = await queryCount();\n",{"type":84,"tag":173,"props":211,"children":213},{"class":175,"line":212},5,[214],{"type":84,"tag":173,"props":215,"children":216},{"emptyLinePlaceholder":78},[217],{"type":90,"value":218},"\n",{"type":84,"tag":173,"props":220,"children":222},{"class":175,"line":221},6,[223],{"type":84,"tag":173,"props":224,"children":225},{},[226],{"type":90,"value":227},"  if (disposed) return;\n",{"type":84,"tag":173,"props":229,"children":231},{"class":175,"line":230},7,[232],{"type":84,"tag":173,"props":233,"children":234},{},[235],{"type":90,"value":236},"  if (validAt == invalidationRevision && count != lastCount) {\n",{"type":84,"tag":173,"props":238,"children":240},{"class":175,"line":239},8,[241],{"type":84,"tag":173,"props":242,"children":243},{},[244],{"type":90,"value":245},"    lastCount = count;\n",{"type":84,"tag":173,"props":247,"children":249},{"class":175,"line":248},9,[250],{"type":84,"tag":173,"props":251,"children":252},{},[253],{"type":90,"value":254},"    publish(count);\n",{"type":84,"tag":173,"props":256,"children":258},{"class":175,"line":257},10,[259],{"type":84,"tag":173,"props":260,"children":261},{},[262],{"type":90,"value":263},"  }\n",{"type":84,"tag":173,"props":265,"children":267},{"class":175,"line":266},11,[268],{"type":84,"tag":173,"props":269,"children":270},{"emptyLinePlaceholder":78},[271],{"type":90,"value":218},{"type":84,"tag":173,"props":273,"children":275},{"class":175,"line":274},12,[276],{"type":84,"tag":173,"props":277,"children":278},{},[279],{"type":90,"value":280},"  if (startedAt == revision) return;\n",{"type":84,"tag":173,"props":282,"children":284},{"class":175,"line":283},13,[285],{"type":84,"tag":173,"props":286,"children":287},{},[288],{"type":90,"value":289},"}\n",{"type":84,"tag":92,"props":291,"children":292},{},[293],{"type":90,"value":294},"有查询在跑时，新的刷新请求只更新版本，不再为每条消息单独开查询。当前结果仍有效，就先发布；期间还有变化，再补一轮。如果调用方仍然把每次新增消息都标成失效，两个版本号也救不了原来的问题。",{"type":84,"tag":92,"props":296,"children":297},{},[298],{"type":90,"value":299},"自动已读也要一起调整。当前打开的会话收到消息后，会自动更新阅读状态；如果数据库的未读状态实际上没有变化，就没必要反复作废正在进行的计数查询。",{"type":84,"tag":102,"props":301,"children":303},{"id":302},"怎么确认修到的是持续刷新",[304],{"type":90,"value":305},"怎么确认修到的是持续刷新？",{"type":84,"tag":92,"props":307,"children":308},{},[309],{"type":90,"value":310},"回归用例让两个会话持续收消息，同时延迟计数查询。查询替身在开始时就取走快照，稍后才返回，才能模拟真实的旧结果。断言放在消息还没停的时候，检查角标是否已多次更新，最后再核对总数是否收敛。",{"type":84,"tag":92,"props":312,"children":313},{},[314],{"type":90,"value":315},"另外还有一个恢复问题：界面曾显示 8，查询出错后隐藏角标，恢复时查到的仍是 8。若只与上次发布值比较，就不会重新显示。实现会在有效查询失败后清掉发布基线，让恢复后的相同数字也能发出去。",{"type":84,"tag":92,"props":317,"children":318},{},[319],{"type":90,"value":320},"查询返回前退出登录、切账号或销毁页面，都需要拒绝旧结果。账号身份还要单独保存，不能指望一个会被原地修改的配置对象继续提供旧值。这些替身用例检查的是应用时序，真实数据库筛选、设备生命周期和双端同步仍需对应环境验证。",{"type":84,"tag":322,"props":323,"children":324},"style",{},[325],{"type":90,"value":326},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":166,"searchDepth":185,"depth":185,"links":328},[329,330,331,332],{"id":104,"depth":185,"text":107},{"id":125,"depth":185,"text":128},{"id":146,"depth":185,"text":149},{"id":302,"depth":185,"text":305},{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":334},"\u003Cpath fill=\"currentColor\" d=\"m10.828 12l4.95 4.95l-1.414 1.415L8 12l6.364-6.364l1.414 1.414z\"\u002F>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":336},"\u003Cpath fill=\"currentColor\" d=\"M3 4h18v2H3zm0 7h12v2H3zm0 7h18v2H3z\"\u002F>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":338},"\u003Cpath fill=\"currentColor\" d=\"m13.172 12l-4.95-4.95l1.414-1.413L16 12l-6.364 6.364l-1.414-1.415z\"\u002F>",{"left":4,"top":4,"width":340,"height":340,"rotate":4,"vFlip":6,"hFlip":6,"body":341},1024,"\u003Cpath fill=\"currentColor\" d=\"M768 832a128 128 0 0 1-128 128H192A128 128 0 0 1 64 832V384a128 128 0 0 1 128-128v64a64 64 0 0 0-64 64v448a64 64 0 0 0 64 64h448a64 64 0 0 0 64-64z\"\u002F>\u003Cpath fill=\"currentColor\" d=\"M384 128a64 64 0 0 0-64 64v448a64 64 0 0 0 64 64h448a64 64 0 0 0 64-64V192a64 64 0 0 0-64-64zm0-64h448a128 128 0 0 1 128 128v448a128 128 0 0 1-128 128H384a128 128 0 0 1-128-128V192A128 128 0 0 1 384 64\"\u002F>"]