Skip to content

Performance Rendering

Yao Jingxi edited this page Sep 12, 2026 · 1 revision

性能与渲染:把高频路径变成局部、增量、可测量

Lithe 的性能型 bug 很少只是“代码慢”。更常见的根因是高频事件触发了错误层级的状态失效、后台结果没有身份、或者把屏幕外工作当成了用户当前需要的工作。

1. 大 Java 文件:全文工作不应跟着视口走

#621 — 滚动、光标和结构结果应用卡顿

根因:旧编辑器在主线程反复遍历全部折叠区域、全文着色并强制全文排版;结构结果、编辑、滚动和主题变化之间没有清晰的缓存失效边界。屏幕外折叠也会触发布局查询。

方案:

  • 折叠箭头直接复用可见行几何,在可见行循环中绘制;不再单独查询屏幕外区域。
  • 普通语法色和 Java 语义色共用 viewport cache,按稳定顺序补色,平衡区间索引保留跨行/重叠 token 语义。
  • 全文替换、外部同步、全选粘贴、主题变化、分析失败和缺少 range 的更新会丢弃旧折叠/语义快照;普通局部编辑尽量保留折叠状态。
  • 折叠隐藏区间合并并二分查找,着色目标扣除折叠内容;只对真正影响布局的编辑重新排版。
  • 后台 Java 分析保留取消和 stale-result 检查,非 Java 文件不触发 Java 折叠刷新。

验证:PR 报告新增 19 个确定性回归测试,并对关键修复做临时回退 A/B,确认旧代码会在隐藏新文本、额外颜色属性、首屏颜色和全文布局方面失败;同时用 8329 行/447 KiB 合成 Java 文件做 Release 复测。

与仓库交互规则的关系:这是一个高频 UI 改动,必须维持现有 AppKit 绘制循环和视口通知合并,不新增每事件写多个 SwiftUI @State 或整页重建。

2. 文件打开:延迟并不等于取消

#174 — 打开大文件时主线程持续被占用

根因有三层:

  • 编辑器刷新中同步发布 cursor/selection/find 状态,引发 Publishing changes from within view updates is not allowed 和重复 SwiftUI refresh。
  • 首屏已经显示后仍在主线程分块扫描全文、跑多组正则、修改全文样式。
  • Java 折叠/实现分析在主线程执行,同一源码可能重复解析。

方案:把状态更新延后到当前 view update 结束,并合并连续更新;切换文件时丢弃旧排队状态。高亮只处理当前视口并缓存已处理范围;Java 分析移到后台、80ms debounce、取消旧任务、完成后重新校验文件和内容。这个问题后来在 #621 继续演化为更细的区间失效和 fold snapshot 规则。

经验:Task.cancel() 只表达意图;结果回到主线程前仍要检查 document identity/content identity。

#305 — 运行配置加载被 Spring 全量索引阻塞

根因:loadProjectServices 先 await springFeature.load,再绑定 RunService.projectURL 和 configurationStatus;大型项目的 Spring 索引完成前,Run panel 既没有配置也不能响应“识别并生成”。更隐蔽的是 generateRunConfigurations() 在 projectURL == nil 时静默 return,用户无法区分“仍在加载”和“真的失败”。

方案:将 Spring load 改为可取消的 scheduleLoad,复用 generation/reloadTask;运行配置增加 .projectNotLoaded 显式状态;所有 Run/Debug 入口在冷启动时都先绑定项目。没有把加载责任塞进 activateExecutionModule(),避免和已有 loadProjectServices 形成递归。

经验:异步初始化应拆成可并行的任务,并对“尚未具备前置条件”给出独立状态;静默 return 会把时序 bug 伪装成 UI 无响应。

3. 输入热路径:不要每个按键都重建世界

#209 / 关闭的前序 #203 — Windows 编辑器输入不跟手

根因:每键全文读取、行数组重建、Buffer 广播;inline Git blame 每次输入拉起 git.exe;LSP 空闲时 20ms 轮询;大文件仍默认开启昂贵的 LSP、blame、Code Lens 和 semantic tokens。

方案:

  • 支持增量同步的 LSP 使用 range-based didChange。
  • blame 只在打开、保存或 Git revision 变化时刷新,且取消过期进程。
  • 空闲会话使用 lsp.waitEvents,不再固定 20ms poll。
  • 2 MiB/50,000 行以上文件默认关闭昂贵服务,显示可恢复的降级提示。

经验:性能保护要有产品语义(可恢复提示/手动启用),不能静默丢功能;同时要对 Undo/Redo、CRLF、外部更新和补全建立边界测试。

4. Tab 切换:稳定 editor identity,实时读取可变开关

#563 — Monaco 每次切 Tab 都销毁重建并清空上色缓存

根因:isActiveSurface 切换导致 enableExpensiveService 变化;该值既在 editor create/destroy effect 依赖中,又在 options effect 中。每次切换都重建 Monaco,并重新 defineMonacoTheme/setTheme,导致语法上色缓存完全失效。

方案:创建期选项读取 enableExpensiveServiceRef,把可变开关从创建 effect 依赖中移除,保持 editor instance;definition link 从固定 enable 值改为每次调用的 isEnabled() 回调。这样 editor identity 稳定,行为开关仍实时。

经验:把“会变的配置”放进“创建资源 effect”的依赖数组,会把配置变化升级成资源生命周期变化。应区分 create-time options 和 runtime options。

5. SwiftUI 分隔条:高频状态必须下沉到局部容器

#390 — 拖动分隔条让整个工具窗口卡顿

根因:draggedSize 存在 GitLogView、RunView、ChangesSidebarView、LanguageTestsView 自己的 @State 中;60fps 每次写入都会让 ScrollView、LazyVStack 和大量 commit row 重新求值。旧 scheduler 还用 16ms sleep 人为延迟;even-odd 圆角路径随窗格尺寸每帧重新三角化;AnyView 类型擦除和递归行 builder 破坏惰性。

方案:

  • 复用 LitheSplitPaneView,由容器持有尺寸,业务内容作为稳定值传入,失效范围缩小到 split container。
  • LitheDragUpdateScheduler 用同一 runloop turn 的 DispatchQueue.main.async 合并更新,测试时支持 .manual 同步驱动。
  • 圆角改成可缓存的固定角落缺口路径。
  • ModuleToolContent/行模型使用 Equatable,reference rows 扁平化为 LazyVStack,闭包集中在 actions struct 并在 equality 中忽略。
  • cache 通过非 observable reference box + @State 持有,写入不触发整页失效。

仓库规则要点:拖动使用稳定坐标空间,约束 min/max 和 available space,结束时再持久化;不要在父业务 view 中堆叠自定义 DragGesture。PR 自己也明确实际 FPS 未完整实测,机制性失效范围收缩不能冒充实机帧率结论。

#487 — Windows 标题栏拖动延迟

根因:data-tauri-drag-region 的自动拖动机制在标题栏所有区域接管事件,和按钮/输入框交互及 WebView2 事件处理产生额外延迟。

方案:移除自动 region,改为显式 startDragging() 路径,并保留交互控件排除逻辑。它说明平台默认手势并不总是适合复杂标题栏,手动控制时要把排除区域作为一等测试对象。

6. 大文本输出:增量布局而不是全文替换

#72 — 约 500k 字符的 Run 日志拖慢界面

根因:一个大型 SwiftUI output Text 随新日志到来整体重建和重新布局。

方案:改用增量 TextKit layout,只 append 新内容,不重建已有 attributed document;保留 ANSI 样式、source link、选择、复制和 smart scroll,并测试 append、tail replacement、truncation 和 ANSI update plan。

经验:日志/编辑器/列表都适合“稳定前缀 + 增量尾部”模型;截断必须是显式协议,不能让 view 自己猜。

7. Git Log:把拓扑和 projection 移出 body

#63 与 #511

#63 的根因:Git Log 滚动时触发 workbench 重绘,Git graph lane 在 merge 场景的连续性也没有稳定模型。

#511 的方案:Rust Core 生成确定性的 graph route snapshot(节点、父子关系、lane、terminal route),SwiftUI 只消费 projection;projection identity 使用 workspace/check/query version,后台结果回写前校验 identity。Git Log、worktree、commit file tree 使用原生 NSScrollView/NSView,只创建 viewport 可见行,行模型独立 Equatable;display-link 以固定合成历史测 p95/max budget。

经验:复杂拓扑算法和高频滚动不应共享一个渲染路径;“数据先投影成稳定行模型”同时解决性能、过期结果和测试可重复性。

8. 切换侧栏时只让当前任务活着

#167 — PR/Git/Changes 切换越多越卡

根因:每次 selectedSidebar 变化都创建不可取消或未合并的刷新 Task;旧 Git/GitHub 请求在新窗口状态下返回,继续处理 snapshot、diff、log、code insight 和 line changes。

方案:侧栏刷新 task 可取消且只保留当前侧栏任务;GitHub 加 request generation;Git 刷新各异步阶段检查取消;defer 清理刷新状态。这个模式与 #621 的 stale-result guard 相同,只是 scope 从文档扩大到 feature。

9. 删除昂贵且不稳定的功能也是可靠性方案

#280 — macOS Java inlay hints 不稳定

根因:自绘参数名 overlay、基于 kerning 的文字间距、项目源码扫描和 Swift projection 叠加在编辑器渲染路径,稳定性和布局成本都高。

方案:移除不稳定的自定义 inlay-hint 层,保留 fold-aware Code Vision、collapsed summary、blame 和 shared Core contract。它不是“少做一点”的逃避,而是把尚未可靠的能力从主路径拿掉,保护核心编辑体验。

性能审查清单

  • 高频事件写入的是局部容器状态,还是页面级 @State/store?
  • 滚动时是否只处理 viewport?屏幕外内容是否还在布局、着色或解析?
  • 后台结果是否带 document/workspace/query/version identity?取消后是否二次检查?
  • 是否把 create-time resource options 与 runtime toggles 混在同一个 effect dependency 中?
  • 列表行是否有稳定 identity/Equatable?闭包、AnyView、递归 builder 是否破坏比较和惰性?
  • 增量更新是否保留 old/new 语义、CRLF/UTF-16、selection 和失败回滚?
  • 性能结论是否有固定输入、明确采样窗口和 p95/max,而不是只说“感觉快了”?

Clone this wiki locally