做 Windows 桌面自动化,读聊天窗口里的会话内容是个常见需求:程序定时轮询窗口文本,判断买家有没有发来新消息,再决定要不要处理。这套流程稳定跑了很久,直到某天我们注意到,程序在长会话里开始"装死"。复盘下来,根因是取文本 API 的单次字符上限——一个明明白白写在文档里、却很容易被忽略的设计行为。本文完整记录这次排查过程。
现象:短会话正常,长会话开始空转
刚上线那段时间一切正常。会话消息少,窗口文本短,程序每一轮都能读到完整内容,"有没有新消息"的判断也准确。问题集中出现在老会话上:会话越长,程序越容易判定"没有新消息"而空转;买家明明连发了好几条,界面上一条条看得清清楚楚,程序却毫无反应。
真实案例:某买家从 17:21 到 17:31 连续发来多条消息,十分钟里程序全程静默,日志里只有一行行"无新增内容"。更蹊跷的是,同一个买家如果另开一个新会话,程序响应立刻恢复正常。这个"会话越老越聋"的特征提示我们:问题跟会话长度强相关,而不是跟某个买家或某个时段有关。
排查:监听正常,文本长度恒等于 3000
起初怀疑消息监听器没触发——毕竟表象是"程序没反应"。于是在关键路径上加了一排打点:监听回调入口、轮询循环入口、文本比对处。日志显示监听回调每次都准时触发,事件链路没有断,"监听器失灵"这个怀疑先被排除。
接着怀疑轮询间隔太长,买家消息恰好落在两轮轮询之间被漏掉。把间隔从五秒缩到一秒,现象毫无变化,"漏轮询"的怀疑也被排除。此时日志里出现一个更耐人寻味的细节:每一轮读到的文本,开头几百字都是会话最早期的寒暄,内容一模一样——程序每轮都在重新读入同样的旧内容,却从未读到过最新的那几条。
那就是"读"这一步出了岔子。接着把每轮读到的文本长度也打了出来,真相浮出水面:无论会话里实际有多少内容,读回来的文本长度恒等于 3000,一个字符不多、一个字符不少。再换一个途径量全文的真实长度,发现那个出问题的会话已经积累到 6000 多字。
也就是说,程序每次只拿到前 3000 字,而新消息全部落在 3000 字之外。比对逻辑拿着同一份被截断的旧文本反复比对,自然得出"无新增内容"的结论——监听是好的,消息也确实进来了,只是读文本这一环把后半截丢了。
根因:单次取文本上限是设计行为
翻文档确认:Windows UI 自动化这一族取文本接口,单次调用存在字符上限,不同框架从 3000 到 5000 不等。超过上限时,返回值只包含上限以内的部分,超出部分需要调用方自己分段获取。这不是 bug,而是设计——接口在约束单次调用的开销,把"长文本怎么读"的责任留给了上层调用者。
坑就坑在写代码时的隐含假设:"一次调用等于读到全文"。短会话阶段这个假设成立,于是它被悄悄固化进了轮询逻辑;会话一旦长过上限,假设崩塌,程序就在错误的输入上稳定地输出错误的结论。这类问题隐蔽之处在于:不报错、不抛异常,返回的是一份格式完好、看起来完全正常的文本,只有长度会出卖它。
还有一层容易被忽略的连带效应:截断是从头开始的,意味着丢掉的恰恰是末尾的最新内容。如果判断逻辑依赖"文本末尾是否有变化",那么长会话下它必然恒为"无变化";如果依赖"全文哈希比对",结果同样恒等。接口返回得越"体面",这类静默错误就越难在测试期暴露——测试用的会话往往只有几十条消息,根本够不到上限。
修复:先量总长,再按末尾窗口重读
修复思路很直接:放弃"一次读完"的假设。先量全文总长,再按末尾 N 字窗口重读,把"读全文"改成"读增量"。判断有无新消息时只看末尾窗口就够了,因为新消息一定出现在会话末尾。
# 量长 + 末尾窗口重读(示意代码) MAX_SINGLE = 3000 # 单次取文本上限,按实际框架探测 TAIL_WINDOW = 8000 # 末尾窗口长度,覆盖一轮轮询内的新增量 def read_session_tail(hwnd, tail_chars=TAIL_WINDOW): total = get_text_length(hwnd) # 先量全文总长 if total <= MAX_SINGLE: return get_text(hwnd, 0, total) # 未超限,正常读全文 start = max(0, total - tail_chars) return get_text(hwnd, start, tail_chars) # 超限,只读末尾窗口# 用末尾锚点判断"有无新消息"(示意代码) def poll_once(state): tail = read_session_tail(hwnd) new_part = slice_after(tail, state.anchor) # 锚点之后的内容即增量 if new_part: handle_messages(new_part) # 只处理刚进来的消息 state.anchor = tail[-ANCHOR_LEN:] # 滚动更新末尾锚点两个工程细节值得交代:其一,上限值不要写死,不同框架 3000/5000 不等,启动时用一段长样本实测探测更稳妥;其二,所用接口若不支持按区间读取,可以用"全选后取选中内容"或"滚动到底再读"等变通手段拿到末尾窗口,思路不变。另外,锚点在长会话里可能重复出现,一旦比对失配,就降级为上一轮与这一轮的末尾窗口整体比对——宁可多处理一次,也不能漏掉消息。
延伸:警惕一切"读一次就下结论"的取文本
这次踩坑的本质不是某个接口的陷阱,而是一种编码惯性:拿到返回值就当全文用。凡是"读一次就下结论"的 UI 自动化——读日志控件、读列表内容、读文档正文——都值得自查一遍:读回来的长度是否顶到了某个上限?日志里是否出现过 `len(text) == 3000` 这类可疑的整数值?
建议加一道开销很低的自检:读到的长度恰好等于已知上限时,记录告警并按截断路径处理。判断"有无新消息"这类问题,用末尾锚点做增量比对,比全量比对更快,也更抗截断——全量比对既浪费算力,又在长文本下不可靠。把窗口文本当成"只增不减的日志流"来读,截断问题就能从源头绕开。
参考文章
"读增量、用末尾锚点"这套做法,放在客服消息统一处理的场景里同样适用,延伸阅读两篇:
- 电商客服自动回复话术模板:售前/议价/物流/售后 30 例
- 微信消息太多回不过来?客服消息过载的分层处理