导语 :如果你正在用 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 toContext::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 自管;新增DeleteSurroundingDroppedFile从结构体变为 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 侧,换版本不会改善:
- 每帧取打开文档列表走完整视图 (
list_open全文快照 + 行数扫描)------必须走轻量摘要 - 高亮 LRU 驱逐策略------误用任意迭代序驱逐会让切标签触发 56 ms 级 syntect 重算
- syntect 全量高亮------已由防抖压到「每 150 ms 至多一次」
- 状态栏每帧查询 ------
get_stats必须按(id, revision)缓存 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 推荐学习路径
- 入门 :先跑通 eframe 官方 counter 示例,理解
fn ui+fn logic双入口 - 进阶 :精读
egui::Ui/epaint::Galley源码,理解 LayoutJob → Galley 流程 - 实战 :本类项目重点看
highlight_cache.rs、ime.rs、toolbar_view.rs三处适配 - 性能 :掌握
cargo test --release+--nocapture measure_的 benchmark 姿势
9.4 思考题
- 如果你手上有
LayoutJob预填 text 的历史代码,升级 0.37 后会第一时间报错还是静默出错? - 超长单行 5× 回退,你会选
layout_no_wrap、行级截断还是维持现状? - 0.37 首帧是测量 pass,你的截图断言是否也该"先空跑一帧"?
如果本文对你有帮助,欢迎点赞👍、收藏⭐、关注🔔!有任何问题欢迎在评论区交流💬。
标签 :#Rust #egui #桌面应用开发 #性能优化 #避坑指南 #wgpu #eframe #文本渲染 #IME #依赖升级