这个问题最反常的地方,是第一次打开文件没事。
从左侧工程树点击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 位置和文本 revision;
- token 格式 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 的四层堆栈把答案钉在了后者,后面的设计才终于有了方向。