egui 0.37 升级实录:卡顿修复与5倍性能回退

导语 :如果你正在用 Rust + egui 开发桌面应用,且被 TextEdit 在输入、长文本渲染、高频重绘时的卡顿困扰,或者正打算升级到 egui 0.37 ------ 本文用一次真实的跨 4 个次版本升级(0.33.3 → 0.37.0)告诉你:哪些卡顿真的被修好了、哪些反而变慢了 5 倍、20 多处罚单式 API 变更怎么一一适配、以及升级中暴露出的 3 个真实缺陷。全文含实测 benchmark 与完整代码位置,建议收藏慢读。

目录

  • [一、背景:为什么要从 0.33 跳到 0.37](#一、背景:为什么要从 0.33 跳到 0.37)
  • [二、版本对照与 4 条关键兼容性注意事项](#二、版本对照与 4 条关键兼容性注意事项)
  • [三、20+ API 破坏性变更清单(附代码位置)](#三、20+ API 破坏性变更清单(附代码位置))
  • [四、TextEdit 卡顿:到底修好了什么?](#四、TextEdit 卡顿:到底修好了什么?)
  • [五、实测 Benchmark:4 项改善 vs 1 项 5× 回退](#五、实测 Benchmark:4 项改善 vs 1 项 5× 回退)
  • [六、升级中暴露并修复的 3 个真实缺陷](#六、升级中暴露并修复的 3 个真实缺陷)
  • [七、测试基础设施必须改的 2 处](#七、测试基础设施必须改的 2 处)
  • [八、升级后卡顿仍未解决?5 步归因排查法](#八、升级后卡顿仍未解决?5 步归因排查法)
  • 九、总结与学习路径

一、背景:为什么要从 0.33 跳到 0.37

这是一个真实项目(Rust + egui 桌面编辑器)的依赖升级记录。动因很明确:验证新版 egui 能否改善 / 解决 TextEdit 在输入、长文本渲染、高频重绘时的卡顿。

升级跨越 0.33.3 → 0.37.0(4 个次版本),同时 wgpu 从 27.0.1 跨到 30.0.1。最终结果是部分改善:

问题 结论
升级是否完成 ✅ egui / eframe / epaint / egui-wgpu / egui-winit + wgpu 全部升级
是否改善 TextEdit 卡顿 部分改善:大文档选择、GalleyCache 驱逐、重绘爆发、布局偏移等直接成因逐个修掉
升级是否"纯换版本" ❌ 不是。修复 1 处真实渲染缺陷 + 1 处布局缺陷 + 2 处 egui 行为变更
遗留 尚无"应用侧"卡顿根因被本次升级解决(不在 egui 侧)

💡 核心结论 :egui 0.34~0.37 确实把 TextEdit 卡顿的多个直接成因修掉了,但文本栈换代(ab_glyph → harfrust+skrifa+vello_cpu)带来了超长单行排版约 5× 的回退------这是升级中最容易被忽视的隐性代价。


二、版本对照与 4 条关键兼容性注意事项

2.1 版本对照

crate 升级前 升级后 说明
egui 0.33.3 0.37.0 UI 主库;API 破坏面最大
eframe 0.33.3 0.37.0 App trait 重组
epaint 0.33.3 0.37.0 被动升级;文本栈整换代
egui-wgpu 0.33.3 0.37.0 被动升级
egui-winit 0.33.3 0.37.0 被动升级
wgpu 27.0.1 30.0.1 三平台声明同步提升
winit 0.30.13 0.30.13 未变
harfrust / skrifa / vello_cpu(新链入) --- 0.12.0 / 0.44.0 / 0.2.0 取代 ab_glyph:整形 + 字形解析 + CPU 光栅化

2.2 4 条兼容性注意事项(务必写进 Cargo.toml 注释)

① eframe 的 wgpu feature 语义变了 ❌

0.33 用 "wgpu" 即可;0.37 里它连带 打开 egui-wgpu/default → wgpu/default(各平台后端全开,体积暴涨)。正确做法是改用 wgpu_no_default_features,各平台后端仍由 wgpu 直接声明按平台各开一个。

toml 复制代码
# Cargo.toml
[dependencies]
eframe = { version = "0.37.0", features = ["wgpu_no_default_features"] }
egui   = { version = "0.37.0", features = ["default_fonts", "monochrome_emoji_fonts"] }

[target.'cfg(target_os = "windows")'.dependencies]
wgpu = { version = "30.0.1", default-features = false, features = ["dx12"] }
[target.'cfg(target_os = "macos")'.dependencies]
wgpu = { version = "30.0.1", default-features = false, features = ["metal"] }
[target.'cfg(target_os = "linux")'.dependencies]
wgpu = { version = "30.0.1", default-features = false, features = ["vulkan"] }

② egui 必须新增 monochrome_emoji_fonts ⚠️

0.37 把 emoji 字体从 default_fonts 拆了出去。只开 default_fonts 会缺 ✅❌⚠💡🔗☑ 等码位,本项目符号面板 / 工具栏图标直接变豆腐块(实测 6 个用例失败)。

③ monochrome_emoji_fonts 是 egui-only 的 feature ❌

照抄到 eframe 会直接报错 failed to select a version for eframe。因 feature 可加性,只加在 egui 上即可。

④ 文本栈换代有观感与性能后果 ⚠️

0.34 换 ab_glyph → skrifa + vello_cpu(首次支持字体 hinting),0.35 再引入 harfrust 做字距 / 连字。字形整形更准但更贵,排版结果会与 0.33 有像素级差异------这是后续超长单行回退的直接来源,也是任何"字符宽度 / 折行位置"断言的潜在影响源。


三、20+ API 破坏性变更清单(附代码位置)

3.1 框架与上下文(5 处)

# 0.33 用法 0.37 用法
1 impl eframe::App { fn update(&mut self, ctx, frame) } fn ui(&mut self, ui, frame) + 可选 fn logic(ctx, frame)(update 已移除)
2 `ctx.run(input, ctx
3 ctx.run_logic(...)(会初始化字体) run_logic 不再初始化字体 (fonts_mut 会 panic)
4 ctx.style() / ctx.set_style(s) ctx.style_of(theme) / ctx.set_style_of(theme, s) + ctx.set_theme(theme)
5 Id::new(x) / ui.id() Id::unique(x) / ui.scope_id()

💡 第 3 条最坑:ctx.run_logic 不再初始化字体,直接调 fonts_mut 会 panic 「No fonts available until first call to Context::run()」------所有无头测试驱动点必须改用 run_ui。

3.2 面板与滚动(3 处)

rust 复制代码
// ❌ 0.33
SidePanel::left(id).exact_width(200.0).show(ctx, |ui| { ... });
CentralPanel::default().show(ctx, |ui| { ... });

// ✅ 0.37
egui::Panel::left(id_salt).exact_size(200.0).show(ui, |ui| { ... });
egui::CentralPanel::default().show(ui, |ui| { ... });  // 收 ui 而非 ctx

ScrollArea::State.offset 也变私有 了:读用 clamped_offset() / unclamped_offset(),写用 .scroll_offset(Vec2)。

3.3 文本与光标类型(4 处)

# 0.33 用法 0.37 用法
9 CCursor.index: usize CCursor.index: CharIndex(pub usize)(语义仍是字符下标)
10 LayoutSection.byte_range: Range<usize> Range<ByteIndex>
11 LayoutJob 允许预填 text + append 新增 debug_sanity_check(),sections 必须从 byte 0 起无空隙覆盖全文
12 FullOutput 无约束 textures_delta 的 Drop 会 debug_assert!(is_empty())

3.4 事件与输入(6 处)

rust 复制代码
// ❌ 0.33
Event::Ime(ImeEvent::Preedit(String))

// ✅ 0.37:struct 变体
Event::Ime(ImeEvent::Preedit { text, active_range_chars: Option<Range<usize>> })
  • ImeEvent::Enabled / Disabled 已废弃 ,IME 开关由 egui 自管;新增 DeleteSurrounding
  • DroppedFile 从结构体变为 trait (fn path(&self) / fn bytes(&self))
  • Event::Screenshot / egui::UserData 均移除 ,改用 ctx.request_screenshot(cb) → 命令出现在 FullOutput.viewport_output[ROOT].commands 里
  • Event::MouseWheel 新增必填 phase: TouchPhase + source: MouseWheelSource(都没实现 Default)
  • epaint::ClippedShape 新增第三字段,用 ClippedShape::new(clip_rect, shape) 构造

3.5 两处"静默出 bug"的行为变更 ⚠️

这是升级中最危险的部分------不是 API 变更,编译器不会报错,但运行时会出 bug。

# 0.37 行为差异 应对
19 ImeEvent::Commit("") 不再等价折叠选区:0.33 空串只折叠光标;0.37 改为先 delete_selected 再插入,空串会把预编辑串真删掉 (实测 "helloni" + 回车 → "hello\n") 回提交与预编辑串同一段文本(delete 与 insert 相抵 = 折叠选区而文本不变)
20 0.33 会把 IME 事件稳定排序到最前 ;0.37 删掉了这个排序,按列表顺序处理 补发的 Commit 显式 events.insert(0, ..)

🚀 另附一条 0.37 新概念:Memory::owns_ime_events(id)(实现为 has_focus(id))。只有聚焦的 TextEdit 才会消费 Event::Ime;未聚焦时 cursor_purpose 被重置为 Selection、选区折叠。


四、TextEdit 卡顿:到底修好了什么?

4.1 先分清"谁的锅"

TextEdit 的一次击键要经过四段,只有第 ③④ 段在 egui 侧:

段 内容 归属
① 领域编辑命令 + 全文 diff 本应用
② 语法高亮 span 生成(syntect) 本应用 / syntect
③ 逐行 LayoutJob → Galley(排版、折行、整形、光栅化) egui / epaint
④ 每帧 UI 逻辑 + 绘制 + 重绘调度 egui

4.2 0.34~0.37 针对卡顿的改进(逐条对照 changelog)

卡顿成因 对应改动 版本
大文档文本选择慢 Optimize text selection performance for large documents #7917 0.34.2
词边界扫描 O(n²) Add regression test for O(n²) word boundary scan #8077 0.34.2
字形缓存 / 光栅化 Replace ab_glyph with Skrifa + vello_cpu #7694 0.34.0
字距与连字质量 Use harfrust for better kerning and ligatures #8031 0.35.0
字体变体下字形缓存失效 Fix glyph caching on font variations #8189 0.35.0
每帧 Response 体积 Shrink the byte-size of Response slightly #8011 0.34.0
GalleyCache 跨 viewport 驱逐 Avoid cross-viewport GalleyCache eviction #8528 0.37.0
request_repaint_after 截止期前重绘爆发 Fix burst of extra repaints before deadlines #8595 0.37.0
TextEdit 布局偏移 / 未裁剪撑高 Fix TextEdit layout shift; unclipped TextEdit grows to fit #8628 0.37.0
字体设置延迟生效导致重排 Apply set_fonts/add_font right away #8602 / #8609 0.37.0
CJK 行首避头尾标点 Don't start a CJK row with full-width closing punctuation #8658 0.37.0

五、实测 Benchmark:4 项改善 vs 1 项 5× 回退

测试命令:cargo test --release --test presentation -- --nocapture measure_(3/3 通过)。结论一律以 release 数字为准。

5.1 完整对比数据

场景 本次实测 对比历史 判定
1MB 降级 · 冷首帧 19.23 ms (47.9 ms) ✅ 明显改善
1MB 降级 · 稳态滚动帧 p50 0.27 / p95 0.73 ms (p50 0.26 / p95 1.19 ms) ✅ 远优于 16 ms 目标
10KB 全文高亮 · 排版(增量,改 1 行) 冷 2.07 ms | 增量 p50 0.07 / p95 0.11 ms (冷 0.75 / 增量 p50 0.16 / p95 0.51 ms) ✅ 增量帧更好
10KB 全文高亮 · span 生成(syntect) p50 58.72 / p95 86.97 ms (p50 56.5 / p95 59.1 ms) ⚠️ 持平(syntect 侧,与 egui 无关)
超长单行(27 万字节 / 无换行)· 冷排版 69.06 ms (13.5 ms) ⚠️ 回退 ~5×
超长单行 · 改一字符(整行重排) p50 66.65 / p95 77.51 ms (p50 13.5 / p95 14.5 ms) ⚠️ 回退 ~5×

5.2 超长单行回退的归因

该行无换行、近 9 万 CJK 字符,成本全落在 egui/epaint 的折行 + 字形整形上。

⚠️ 0.34 换代到 skrifa+vello_cpu、0.35 引入 harfrust 之后整形更精细,代价即体现在这类极端输入上(正常文档无此规模的单行)。

好消息是:距 500 ms「卡死」线仍有 6.4× 余量(历史 34×),不需要行级截断高亮。但余量已从 34× 缩到 6.4×,若未来出现更大单行需重估。

5.3 升级没有解决的部分(仍属应用侧)

以下成因不在 egui 侧,换版本不会改善:

  1. 每帧取打开文档列表走完整视图 (list_open 全文快照 + 行数扫描)------必须走轻量摘要
  2. 高亮 LRU 驱逐策略------误用任意迭代序驱逐会让切标签触发 56 ms 级 syntect 重算
  3. syntect 全量高亮------已由防抖压到「每 150 ms 至多一次」
  4. 状态栏每帧查询 ------get_stats 必须按 (id, revision) 缓存
  5. highlight_layout 缓存命中判据含全文 cached_text == text 比较(1MB 约几十 µs,可接受)

六、升级中暴露并修复的 3 个真实缺陷

6.1 build_line_job 把整行文本写了两遍(真实渲染缺陷)

现象:0.37 起大量高亮单测 panic:

复制代码
First LayoutSection must start at byte 0
Last LayoutSection must end at the end of the text

根因 :LayoutJob 初始化时预填了 text: line.to_string(),随后又 job.append(&line[lo..hi], ..) ------ 文本被写了两遍。诊断打印:

复制代码
line="the cat sat on the mat" len=22
bounds=[0, 22] sections=[(22, 44)]

0.33 只排版 append 出来的 section ,故这个 bug 一直不可见;0.37 新增了 LayoutJob::debug_sanity_check()(在 Fonts::layout_job 内每次调用),要求 sections 从字节 0 起、无空隙覆盖全文,于是把它当场揪出。

修复:

rust 复制代码
// src/presentation/theme/mapper/highlight_cache.rs
let mut job = LayoutJob {
    wrap: TextWrapping { ... },
    // ❌ 删掉预填:text: line.to_string(),
    ..Default::default()
};
// ... append sections ...
if job.sections.is_empty() {
    job.append(line, 0.0, format);  // 空行兜底
}

💡 教训 :egui 0.37 的 debug_sanity_check 是升级时的一把利剑------它会把你以前"侥幸能跑"的代码当场掀翻。

6.2 标签条垂直基准负偏移(问题 044)

现象 :tab_strip_test 4 个用例失败------标签文字中心 y=66,而"全部关闭"图标中心 y=64.5。

根因 :标签行高 TAB_ROW_H(24) 常大于可用条带高 strip_h(实测 17~19,被菜单栏预算挤压),此时"在条带内居中"算出的是负偏移 :导航箭头与"全部关闭"整体上移,而标签行由 ScrollArea 贴顶排布,两者不再同一中线。

修复:

rust 复制代码
// src/presentation/views/toolbar_view.rs
let row_top = strip_top + ((strip_h - TAB_ROW_H) as f32 / 2.0).max(0.0);
// 标签行、导航箭头、全部关闭三处共用 row_top

条带够高时居中,不够时贴顶------三处对齐一致。

6.3 IME:组合期折叠语义与事件排序

现象 :4 个组合期用例失败------预编辑串被吞("helloni" + 回车 → "hello\n","基线sheng" → "基线\n")。

根因与修复 :见 §3.5 第 19/20 条------回提交与预编辑串同一段文本 ,并显式 events.insert(0, ..)。

rust 复制代码
// src/app/frame/editor/ime.rs
// 新增 composition_char_range / composition_text 从 TextEdit 状态读回
let comp_text = self.composition_text();
if let Some(t) = comp_text {
    events.insert(0, Event::Ime(ImeEvent::Commit(t)));  // 与预编辑串同段
}

七、测试基础设施必须改的 2 处

7.1 run_ui 统一清空 textures_delta

0.37 的 FullOutput.textures_delta 的 Drop 会在未 apply 增量时 debug_assert 崩,全部 80 处无头驱动必须收口:

rust 复制代码
// tests/support/mod.rs
pub fn run_ui(...) -> FullOutput {
    let mut output = ctx.run_ui(input, |ui| { ... });
    output.textures_delta.clear();  // 必须
    output
}

7.2 settle:先空跑一帧再取画面

⚠️ egui 0.37 的首帧是测量 pass (实测菜单栏首帧占 43 pt、之后恒为 44 pt → 标签条 strip_top 在 54/56 间漂移 2 pt)。

渲染类断言若从不同帧取证,几何会差 1~2 pt,直接导致「三条顶栏同一水平线」这类 1 pt 容差断言稳定误报。取画面之前先空跑一帧即稳定。

7.3 阈值按编译档位给

highlight_perf_test 的「不卡死」阈值改为按编译档位:

rust 复制代码
// debug/release 同一段排版差约 8.4×,实测 p95 650.5 ms vs 77.5 ms
#[cfg(debug_assertions)]
const FREEZE_THRESHOLD_MS: f64 = 1500.0;
#[cfg(not(debug_assertions))]
const FREEZE_THRESHOLD_MS: f64 = 500.0;

达标结论一律以 release 数字为准。


八、升级后卡顿仍未解决?5 步归因排查法

按「先测再改」排序,任一条都先取数字再动手:

① 先分清段 :在 highlight_layout_with_find 内分别计时「span 生成 / build_line_job / ctx.fonts_mut(layout_job)」三段,得到 ③ 段真实占比。

② 若瓶颈在 epaint 排版(③):

  • 超长单行改走 layout_no_wrap 对照实验
  • 评估行级截断高亮:对超阈值单行只高亮可视片段
  • 检查自定义 layouter 是否每帧都被调用

③ 若瓶颈在 syntect(②) :先调防抖窗口;仍不够则做增量高亮(需扩领域接口)。

④ 若瓶颈在应用侧(①):逐项体检每帧路径上是否存在 O(全文) 的 DTO 构造。

⑤ 若是高频重绘 :用 ctx.request_repaint_after 替代无条件 request_repaint;注意 0.35 起动画时长从 0.1s 放慢到 0.2s。

⑥ 若怀疑字形整形 :对超长单行做 harfrust 开关对照(0.35 起为默认)。


九、总结与学习路径

9.1 本次升级的 5 条核心经验

# 经验 关键点
1 升级不是换版本号 20+ 处 API 变更 + 2 处静默行为变更 + 3 个真实缺陷
2 monochrome_emoji_fonts 必加 否则符号面板直接变豆腐块
3 wgpu_no_default_features 必用 否则体积暴涨
4 行为变更最危险 Commit("") 语义变化会静默吞字
5 文本栈换代有代价 超长单行回退 ~5×,但距卡死线仍有 6.4× 余量

9.2 门禁结果(升级已完成并交付)

门禁 结果
cargo fmt --check ✅ 通过
cargo run --bin check-arch ✅ 未发现分层依赖违规
cargo clippy --all-targets --all-features -- -D warnings ✅ EXIT=0
cargo test --all-targets ✅ 1722 passed / 0 failed / 2 ignored

9.3 推荐学习路径

  1. 入门 :先跑通 eframe 官方 counter 示例,理解 fn ui + fn logic 双入口
  2. 进阶 :精读 egui::Ui / epaint::Galley 源码,理解 LayoutJob → Galley 流程
  3. 实战 :本类项目重点看 highlight_cache.rs、ime.rs、toolbar_view.rs 三处适配
  4. 性能 :掌握 cargo test --release + --nocapture measure_ 的 benchmark 姿势

9.4 思考题

  • 如果你手上有 LayoutJob 预填 text 的历史代码,升级 0.37 后会第一时间报错还是静默出错?
  • 超长单行 5× 回退,你会选 layout_no_wrap、行级截断还是维持现状?
  • 0.37 首帧是测量 pass,你的截图断言是否也该"先空跑一帧"?

如果本文对你有帮助,欢迎点赞👍、收藏⭐、关注🔔!有任何问题欢迎在评论区交流💬。

标签 :#Rust #egui #桌面应用开发 #性能优化 #避坑指南 #wgpu #eframe #文本渲染 #IME #依赖升级

相关推荐
aichitang20244 小时前
QCU 2.0.0:补齐 Windows、Linux 与 GNOME Wayland
linux·运维·windows·ai·agent·ai编程
唐钰小宝B5 小时前
深度解读 JDK 8、JDK 11、JDK 17 和 JDK 21 的区别
java·开发语言·windows
北风toto5 小时前
Windows 下用 OpenSSL 生成 SSL 密钥并配置 Nginx 教程
windows·nginx·ssl
2601_962364976 小时前
升级电脑人脸登录,选购 Windows Hello 模组常见误区
windows·安全·电脑
小陈不好吃6 小时前
Java 泛型与类型擦除:从 PECS 原则到 TypeToken 绕过
java·开发语言·windows·pecs 原则·typetoken绕过
Helix2507 小时前
WinPE 实战:从零制作纯净 Windows 安装与维护 U 盘
windows·安装·uefi·winpe·adk·u盘启动盘·dism
geovindu8 小时前
rust: Iterator Pattern
开发语言·后端·设计模式·rust·行为模式·迭代器模
桌管小李9 小时前
内网终端安全再升级|迪康端点安全 2609 版本四大新功能解读
大数据·windows·安全·macos·网络安全
情 九10 小时前
Dify 本地环境搭建教程:macOS / Windows
windows·macos