
这个问题最反常的地方是第一次打开文件没事。从左侧工程树点击test.c编辑器能正常显示再点一次界面开始卡住有时进程直接失去响应。换一个文件反复点击却正常。文件只有 32847 字节、772 行怎么看都不像“大文件问题”。我最开始把注意力放在重复打开流程上是不是第二次点击又读了一遍磁盘是不是创建了第二个编辑器是不是扩展宿主重复同步了活动文档。这些路径确实有冗余也应该修但修完以后卡死仍然存在。真正有用的线索来自一次挂起现场。一、先看线程卡在哪里进程失去响应后我用 CDB 附加并查看主线程堆栈随后通过.detach安全脱离。主线程没有停在文件读取、Git、语言服务或锁等待而是在 Qt 的文本布局里Qt5Gui!QTextFormatCollection::indexForFormat Qt5Gui!QTextEngine::resolveFormats Qt5Gui!QTextEngine::itemize Qt5Gui!QTextLayout::draw这几层函数已经把范围缩得很小Qt 正在合并字符格式、切分字形并绘制文本。问题不是“文件又读了一次”而是第二次点击触发重绘时某个文本块的格式解析成本失控了。检查test.c后异常也很集中第 734 行长 8124 个字符包含 1353 个逗号是一整行十六进制数组数据。0xE8, 0x9E, 0x..., 0x7A, 0x31, ...整个文件不大但这一行对 Qt 来说很不友好。二、按 piece 着色为什么还是会卡编辑器的语法分析本来就是按 piece 进行的。数字、标点、关键字和字符串会被切成不同区间再交给QSyntaxHighlighter::setFormat()。问题在于 piece 只是分析阶段的数据结构。进入 Qt 文档以后每个 piece 仍可能变成一个QTextCharFormat区间词法 piece → QSyntaxHighlighter::setFormat(offset, length, format) → QTextBlock 保存格式范围 → QTextLayout 合并和解析范围 → 绘制字形一行里有上千个数字和逗号就可能形成数千个格式边界。语义 token、括号颜色、诊断下划线、查找结果再叠上去Qt 还要计算这些范围的组合。QTextBlock本身也绕不开。只要继续使用QPlainTextEdit QTextDocument一个由换行符分隔的段落就是一个 block。自动换行只会在 block 内产生多个视觉行并不会把 8124 个字符拆成独立的文档块。第一次打开通常先显示文本高亮随后写入格式第二次点击同一文件触发完整重绘时已经积累的格式范围进入QTextEngine::resolveFormats于是“第二次打开才卡”就有了解释。三、我试过但没有解决根因的方向重复打开保护不是无用功。编辑器仍然应该复用已打开的文档不重复切换当前标签不重复通知扩展宿主也不该在最近文件列表没有变化时刷新整棵树。这些修改减少了噪声却没有改变 CDB 堆栈。只要第 734 行仍把大量格式交给QTextDocument一次正常的重绘就足以复现问题。另一个看似直接的办法是把“每行最多 256 个格式”调成 2048 或 4096。它能让颜色多保留一段却只是把崩溃点往后推。格式上限适合作为保险不适合作为主要渲染方案。我也不建议在源文件中插入隐藏换行或零宽字符。那会改变文档位置随后所有功能都要维护“文件坐标与编辑器坐标”的映射包括 LSP、诊断、跳转、选区、撤销和保存。为了高亮一行数组引入这套映射不划算。四、Code OSS 的思路模型和视图分开Code OSS也就是 VS Code 的开源主体使用 Monaco Editor。它不是把所有 token 都写进一个富文本文档再让通用排版引擎解决问题。它的 token 使用紧凑的整数数组保存。每个 token 主要由起始位置和一个 metadata 组成颜色、字体样式和 token 类型编码在位字段里。官方定义可以在 languages.ts 中看到。视图模型再根据scrollTop计算可见行范围。已经渲染的行由视图层复用滚动时只补上刚进入视口的内容而不是重新生成整个文档的显示数据。相关实现位于 viewModelImpl.ts 和 viewLayer.ts。Qt 的编辑器底层不能直接替换成 Monaco但可以借用同一个原则QTextBlock继续管理文本不再承载密集行的全部语法格式。五、第一步识别密集行而不只看文件大小Code OSS 提供editor.maxTokenizationLineLength、stopRenderingLineAfter和大文件优化。当前实现采用类似的长度阈值同时补了一条 Qt 专用的密度判断namespace AiCodingEditorPerformancePolicy { enum { MaximumTokenizationLineLength 20000, StopRenderingLineAfter 10000, DenseLineMinimumCharacters 4096, DenseLineMinimumSeparators 256 }; inline bool isDenseTokenLine(const QString text) { return text.size() DenseLineMinimumCharacters text.count(QLatin1Char(,)) DenseLineMinimumSeparators; } }只用长度阈值会漏掉这次问题8124 小于 10000也小于 20000但 1353 个逗号意味着 token 密度很高。文件大小更没有参考价值因为真正参与一次QTextLayout的单位是行和格式范围。这个判断只针对逗号密集的生成数据行。普通代码仍走原来的完整高亮路径。六、第二步token 留在缓存里不写入 QTextBlock高亮器仍然调用 KSyntaxHighlighting 完成整行词法分析因为多行注释、字符串和折叠区域需要正确的结束状态。区别只发生在applyFormat()普通行继续调用 Qt 的setFormat()密集行改为保存紧凑 token。缓存结构只记录三个整数struct CompactSyntaxToken { int start 0; int length 0; int styleIndex -1; };同一种QTextCharFormat只在样式表中保存一次token 通过styleIndex引用它。相邻且样式相同的 token 会直接合并。这一步解决的是内存和文档格式数量。以第 734 行为例完整 token 仍然存在但它们只是普通数组数据不会进入QTextFormatCollection也不会迫使 Qt 为整行组合数千个文档格式。七、第三步只布局横向可见的字符纵向视口解决“显示哪些行”长行还需要横向视口。编辑器先通过QTextLine::xToCursor()把屏幕左右边界换算成字符位置并在两侧保留 160 像素缓冲区const int visibleStart line.xToCursor( -layoutOrigin.x() - 160.0); const int visibleEnd line.xToCursor( viewport()-width() - layoutOrigin.x() 160.0);随后只取这个字符窗口内的 token创建一个短的临时QTextLayout。屏幕可能只显示几十到几百个字符Qt 不必再为 8124 个字符执行一次带格式绘制。这里有个容易忽略的细节仅仅给原始QTextLayout::draw()设置窄的 painter clip 并不够。clip 限制的是输出区域布局对象仍可能遍历整行。真正缩短输入文本才能把 shaping 和格式解析限定在可见窗口。八、第四步滚动时复用彩色行缓存仅创建可见子串已经快很多但平滑滚动会在约 100 毫秒内连续产生多帧。纵向滚动没有改变横向字符窗口每一帧重新创建同一份临时布局仍然浪费。现在密集行首次进入视口时会把当前横向窗口预渲染成一张QPixmap。后续纵向滚动只更新 Y 坐标并调用drawPixmap()。缓存键包含block 位置和文本 revisiontoken 格式 revision横向字符范围与原点视口宽度、行高和设备缩放比字体、前景色与背景色。这些条件没有变化滚动期间就一直保留完整颜色也不需要“滚动时关闭高亮”。光标和动态选区不写入静态缓存而是在贴图后单独绘制。如果文本、主题、字体或横向滚动发生变化缓存自然失配并重新生成。这个行为比依赖一堆手动clearCache()更可靠。九、参数提示暴露了第二个全量扫描密集行问题解决后打开内嵌参数提示滚动速度又明显下降。原因不在 token而在 Hint 绘制路径。旧实现每次 paint 都遍历整个文档的全部 Hint重新按行分组、排序鼠标移动和点击也会扫描全部 Hint。一行有多个提示时为了给文字腾出空间还会对同一个QTextLayout分段调用多次draw()。这次没有再加一个粗暴的“滚动时隐藏参数提示”。Hint 在接收时只排序一次绘制时使用std::lower_bound()找到当前可见范围的第一项只处理视口内的提示。上一帧的可见索引被保留下来用于清理点击区域。真正调用布局和绘制之前还会检查当前 block 是否与QPaintEvent::rect()相交。鼠标悬停、点击和坐标映射也改为使用可见索引不再线性扫描整个文件。参数提示保持显示滚动路径上的工作量却从“全部 Hint”缩小到了“当前脏区域中的 Hint”。十、还要限制叠加装饰语法 token 不是唯一的格式来源。语义 token、括号着色、诊断、查找结果和当前行背景都可能进入ExtraSelection。当前实现为附加装饰设置了独立预算单 block 最多 256 项整个文档最多 6000 项。密集行不再叠加语义 token 和括号颜色因为它们与词法颜色组合后会重新制造格式范围爆炸。普通代码不受影响。这个取舍是有意的数组数据行保留基础语法颜色比为了彩色括号再把编辑器拖回resolveFormats更有价值。如果项目里的密集行不是生成数据而是确实需要语义着色的 DSL这个策略就要换成统一的视口装饰层。十一、如何验证这类修复性能问题不能只验证“文件能打开”。我保留了以下回归场景从左侧树打开test.c再次点击同一节点在第 734 行横向滚动确认后半段 token 仍会着色连续纵向滚动经过密集行颜色不降级开启内嵌参数提示检查滚动、悬停和点击修改密集行、切换主题和调整字体确认缓存能够失效检查选区、当前行背景、光标和折叠占位符。工程里还加入了静态保护测试检查密集行策略、格式预算、文档复用和活动编辑器同步路径。它们不能代替真实的 MSVC/Qt 运行测试但能防止后续重构把全量格式重新写回QTextBlock。十二、这次问题留下的判断我不再用文件大小判断代码编辑器是否“轻量”。32 KB 的文件也能构造出一个很重的文本块反过来一个几 MB、每行都很短的日志文件未必会触发同一种布局瓶颈。如果项目继续使用QPlainTextEdit最实用的边界是让 Qt 负责文本输入、光标、撤销和普通行布局把病态长行的 token 与装饰留在自己的视图缓存中。完全照搬 Monaco 意味着重写编辑器内核光标、输入法、无障碍和双向文本都要重新处理成本远高于这次故障本身。如果你的代码行最长只有几百个字符也没有语义装饰和参数提示这套虚拟化没有必要。可一旦编辑器要面对证书、固件数组、压缩数据或自动生成代码只调大格式上限迟早还会遇到同一个问题。这次最关键的不是某个阈值而是把问题问对了卡住的到底是“打开文件”还是“绘制已经打开的文件”。CDB 的四层堆栈把答案钉在了后者后面的设计才终于有了方向。