数据截 至 (上游 commit 3667151744e3)
渲染管线:服务端画好帧,再决定发多少字节给谁
30 秒导读: herdr 的服务端是个没有屏幕的进程,却要负责把整个 TUI 画出来。它的办法是:在内存里开一块假的终端缓冲区画完一整屏,拿到一个结构化的「帧」,然后针对每个连上来的客户端分别决定——是把这一屏的所有单元格原样寄过去,还是在服务端先跟上一帧比对、只寄变化部分的 ANSI 转义字节。本章讲这条管线怎么搭,以及在 agent 疯狂刷屏、多个客户端尺寸还不一样时,它靠哪几道闸门不被压垮。
前置背景在 01-server-client-runtime(为什么有个常驻服务端和一群瘦客户端)与 02-terminal-core(PTY 和内嵌 VT 怎么产出可渲染的终端状态)。本章只讲「画」和「发」。
1. 先搞清楚问题:没有终端,怎么画?
普通 TUI 程序的渲染很直接:程序跑在你的终端里,ratatui 把界面画进一块缓冲区,再把差异写到 stdout,你就看见了。
herdr 把这件事拆开了。真正持有工作区、tab、PTY 的是一个后台服务端进程,它没有 stdout 可画——甚至可能是从 systemd/launchd 拉起来的、根本没有控制终端。看界面的是客户端进程,它有真终端,但没有任何应用状态。
于是渲染要回答三个问题:
| 问题 | herdr 的答案 |
|---|---|
| 没有终端,往哪画? | 画进内存里的一块假后端缓冲区(ratatui::backend::TestBackend) |
| 画完的东西怎么过网络? | 序列化成 FrameData(整屏单元格),或先 diff 成 ANSI 字节 |
| 一个 agent 每秒刷几百行,怎么办? | 16ms 渲染节流 + 「上一帧打补丁」的保留渲染 + 每客户端只留一帧的渲染队列 |
一句话直觉: 把它想成一个没有显示器的游戏服务器——它照样跑完整的渲染逻辑得到一张「画面」,然后按每个玩家的带宽,决定是发整张位图,还是发压缩过的帧间差分。
2. 顶层全景:一帧的生命周期
先看整条流水线。从左到右是时间顺序,每一格都可以提前退出(下面章节会讲每道闸门)。
[PTY 有新输出] [用户按了键 / API 改了状态]
| |
v v
RenderSignal.request_pty RenderSignal.request_generic
\_______________ _______________/
\/
服务端事件循环每轮取一次信号
|
+---------v---------+ 不到 16ms / 没人看得见
| ① 节流与可见性闸门 |------------------> 跳过,睡到下个截止时间
+---------+---------+
| 该画了
+---------v---------+ 只有 PTY 脏 & 状态干净
| ② 选渲染计划 |----> 保留帧局部打补丁(不重画 UI)
+---------+---------+
| 需要全画
+---------v---------+
| ③ 虚拟渲染出一帧 | compute_view -> render -> TestBackend Buffer
+---------+---------+
|
v Buffer -> FrameData(cells/cursor/hyperlinks/graphics)
+---------------------+
| ④ 按客户端分别编码 |
+----+-----------+----+
| |
SemanticFrame TerminalAnsi
(整帧 CellData) (服务端 diff 后的 ANSI 字节)
| |
v v
客户端自己 blit 客户端直接 write_all 到 stdout
怎么读这张图: 第 ①②④ 步是本章的三个重点,分别对应「什么时候画」「画多少」「发多少字节」。
各部件一句话职责:
| 部件 | 干什么 | 在哪 |
|---|---|---|
RenderSignal | 把散落各处的「该重画了」合并成一个待处理请求,并记住是谁触发的 | src/render_signal.rs:17 |
compute_view | 算几何、顺带把 pane 尺寸对齐(会改 AppState) | src/ui.rs:111 |
render | 只读 AppState 往 Frame 上画,不改任何状态 | src/ui.rs:391 |
render_virtual_with_runtime_registry | 在内存 backend 上跑一遍上面两步,产出 Buffer + 光标 | src/server/render_stream.rs:304 |
ClientRenderState | 每个客户端一份的「上一帧基线」,决定这次发什么 | src/server/render_stream.rs:13 |
BlitEncoder | 把两帧 FrameData 的差异编码成终端 ANSI 字节 | src/protocol/render_ansi.rs:57 |
render_and_stream | 全渲染 + 遍历所有渲染目标发帧 | src/server/headless.rs:4432 |
3. 纯渲染约定:算几何的和画画的必须分家
3.1 它要解决的小问题
ratatui 的绘制回调拿到的是 &mut Frame,很容易顺手在里面改应用状态(比如"发现侧栏放不下了,把滚动位置改一下")。一旦这么写,同一份状态在不同尺寸的客户端下会互相踩——因为服务端要为每个客户端各画一遍。
3.2 herdr 的做法:两个函数,签名就把规矩定死了
pub fn compute_view(app: &mut AppState, area: Rect) // src/ui.rs:111 —— 可变,算几何
pub fn render(app: &AppState, frame: &mut Frame) // src/ui.rs:391 —— 只读,只画
compute_view 的注释写得很直白:"Called before render to separate mutation from drawing"(src/ui.rs:109-110)。真正干活的是 compute_view_internal(src/ui.rs:215),它做三类事:
- 切版面 —— 侧栏宽度、tab 栏位置、终端区,靠
Layout::horizontal切(src/ui.rs:238-239)。 - 夹紧滚动量 —— 把
workspace_scroll、agent_panel_scroll、tab_scroll修正到合法范围(src/ui.rs:245-262)。 - 对齐 pane 尺寸 —— 只在
resize_panes = true时,把每个 pane 的 VT 尺寸 resize 到它该有的大小(src/ui.rs:288-291)。
render(src/ui.rs:391,实体是 render_with_runtime_registry,src/ui.rs:396)则完全不碰状态:读 app.view 里已经算好的矩形,依次画导航区、tab 栏、pane 表面、通知、弹出 pane,最后按 app.mode 画一个覆盖层。
3.3 关键分叉:resize_panes 这个开关
多客户端时,pane 的 VT 只有一份(见 02-terminal-core),它不能被每个客户端各 resize 一次。于是有第三个入口:
pub(crate) fn compute_view_without_resizing_panes(...) // src/ui.rs:144
注释点明了用途:非前台客户端需要按自己的尺寸算几何,但共享的 pane runtime 要钉在前台客户端的尺寸上(src/ui.rs:139-143)。这条规则在服务端的落地见 §7。
3.4 什么时候画:RenderSignal + 16ms
RenderSignal(src/render_signal.rs:17)是一个合并器。它不只记「脏了」,还记谁弄脏的:
| 字段 | 含义 | 谁写 |
|---|---|---|
generic | 非 PTY 的普通状态变化 | request_generic(src/render_signal.rs:37) |
pty_sources | 哪些 pane 的 PTY 有新输出 | request_pty(src/render_signal.rs:47) |
terminal_title_sources | 哪些 pane 改了终端标题 | request_terminal_title(src/render_signal.rs:81) |
分开记的价值在结构体注释里:让服务端能"丢弃对所有客户端都不可见的、纯 PTY 的更新"(src/render_signal.rs:14-15)。一个后台 tab 里的 agent 在疯狂刷屏,没人看得见,那就不必渲染——这条判据在 §6 展开。
节流常量只有一行:
const MIN_RENDER_INTERVAL: Duration = Duration::from_millis(16); // src/app/mod.rs:39
16ms ≈ 60fps 上限。但它被拆成两个闸门:
can_render_now(src/app/runtime.rs:530)—— 距上次任何渲染尝试是否满 16ms。can_present_now(src/app/runtime.rs:537)—— 距上次真的把画面呈现给人看是否满 16ms。
record_render_attempt(now, presentation)(src/app/runtime.rs:546)按第二个参数决定要不要同时推进呈现时间戳。为什么要分两个?因为"分类一次隐藏 PTY 的输出然后什么都不发"也是一 次渲染尝试,不该把真正要给人看的帧挡在门外——这条不变量有专门的测试:hidden_render_attempt_keeps_presentation_cadence_available(src/app/runtime.rs:677)。
顺带说明:同一套
compute_view/render也被进程内直跑模式用着(App::run,src/app/mod.rs:935;绘制在src/app/mod.rs:1098-1127)。服务端模式只是把terminal.draw的后端从真终端换成了内存缓冲区。
4. 虚拟渲染:在内存里造一个假终端
4.1 思路
ratatui 自带一个测试用后端 TestBackend——它把"写入终端"变成"写入一块 Buffer"。herdr 直接把这个测试设施当生产设施用:服务端每次渲染就 new 一个 TestBackend,画完把 Buffer 拿走。
4.2 为什么还要包一层 CursorTrackingBackend
TestBackend 记不住光标最终停在哪,而客户端需要知道光标该画在哪一格。所以 herdr 包了一层(src/server/render_stream.rs:200),只做一件事:拦下 set_cursor_position / hide_cursor,把最后一次位置记进 rendered_cursor。
4.3 主流程
// src/server/render_stream.rs:304 render_virtual_with_runtime_registry
if resize_panes { compute_view_with_cell_size(...) } else { compute_view_without_resizing_panes(...) }
let backend = CursorTrackingBackend::new(area.width, area.height);
let mut terminal = ratatui::Terminal::new(backend).expect(...);
terminal.draw(|frame| crate::ui::render_with_runtime_registry(app_state, terminal_runtimes, frame));
let buffer = terminal.backend().buffer().clone();
拿到 buffer 之后,光标要走一段优先级选择(src/server/render_stream.rs:322-338):弹出 pane 的光标 > 聚焦终端自己的光标 > 后端记下的位置;并且当聚焦终端处于同步输出(synchronized output)中或已滚回历史,光标要被抑制。这不是小事——远程终端上一个乱跳的光标会把输入法的候选框拽到屏幕另一头。
4.4 每客户端一份的基线:ClientRenderState
画出来的帧要不要发、怎么发,取决于这个客户端的基线状态:
pub(crate) enum ClientRenderState { // src/server/render_stream.rs:13
Semantic { last_frame: Option<FrameData> },
TerminalAnsi { blit_encoder: BlitEncoder, seq: u64, repaint_pending: bool },
}
三个操作定义了它的全部生命周期:
| 方法 | 干什么 | 什么时候用 |
|---|---|---|
prepare_frame(:65) | 跟基线比;一样就返回 None(整帧不发) | 每次要发帧时 |
reset_baseline(:36) | 把基线整个清空,当作从没发过 | 客户端切换成直连终端模式时(src/server/headless.rs:1901、:2880) |
request_repaint(:50) | 保留结构、但要求下一帧走全量重绘 | 共享 runtime 尺寸变了、客户端 resize 了(src/server/headless.rs:1143、:3259) |
还有一个细节值得单独拎出来:reset_semantic_input_baseline(:59)只清语义客户端的基线。源码注释解释了为什么不能一视同仁——ANSI 客户端如果每次按键都清基线,就等于每敲一个字符全屏重绘一次,远程会慢到不能用(src/server/headless.rs:2937-2940)。
5. 两种线上编码:发单元格,还是发 ANSI
5.1 协议里只有两个选项
pub enum RenderEncoding { // src/protocol/wire.rs:39
SemanticFrame, // 发完整的 FrameData
TerminalAnsi, // 发已经 diff 好的终端 ANSI 字节
}
客户端在握手时提出诉求(ClientMessage::Hello { requested_encoding, .. }),服务端在 ServerEvent::ClientConnected 里原样收下并建好对应的 ClientRenderState(src/server/headless.rs:3010、:3042)。客户端这边的选择极其朴素——读环境变量:
fn requested_render_encoding() -> RenderEncoding { // src/client/mod.rs:694
match std::env::var("HERDR_RENDER_ENCODING").ok().as_deref() {
Some("terminal-ansi" | "terminal_ansi" | "ansi") => RenderEncoding::TerminalAnsi,
_ => RenderEncoding::SemanticFrame,
}
}
5.2 两种编码的对比
| 维度 | SemanticFrame | TerminalAnsi |
|---|---|---|
| 服务端发什么 | 整屏 FrameData(width × height 个 CellData) | 只有变化部分的转义字节 |
| diff 在哪做 | 客户端(客户端也有一个 BlitEncoder) | 服务端 |
| 客户端要干的活 | 反序列化 + 自己 blit(src/client/mod.rs:1692-1718) | stdout.write_all(&frame.bytes),几乎零成本(src/client/mod.rs:1720-1726) |
| 帧大小上限 | MAX_FRAME_SIZE = 2 MiB(src/protocol/wire.rs:20) | 同上(含图形时放宽到 32 MiB) |
| 默认用在哪 | 本地 socket 客户端 | 远程 attach |
| 客户端能否重排/加工帧 | 能(拿到的是结构化数据) | 不能(拿到的是字节流) |
5.3 SemanticFrame 长什么样
pub struct FrameData { // src/protocol/wire.rs:527
pub cells: Vec<CellData>, // 行优先,长度必须等于 width * height
pub width: u16,
pub height: u16,
pub cursor: Option<CursorState>,
pub hyperlinks: Vec<String>, // OSC 8 目标,cell 里存下标
pub graphics: Vec<u8>, // 文字帧之后要应用的 Kitty 图形字节
}
单个 CellData(src/protocol/wire.rs:476)含 symbol: String、fg: u32、bg: u32、modifier: u16、skip: bool、hyperlink: Option<u32>。注意 symbol 是堆上的 String,一屏 200×50 就是一万个 String。这就是为什么整帧编码在慢链路上不合算——成本跟屏幕面积成正比,跟"这一帧到底变了多少"完全无关。
超链接用了一个小技巧:URI 去重后存进 FrameData::hyperlinks,单元格只存下标(from_ratatui_buffer_with_hyperlinks,src/protocol/wire.rs:556-596)。一个满屏都是同一个链接的日志页,URI 只传一份。
5.4 TerminalAnsi 的 blit 策略
BlitEncoder(src/protocol/render_ansi.rs:57)只有三个字段:上一帧、上次可见光标位置、上次光标形状。encode(:68)是不可变的——它算出 EncodedBlit 但不改自己;只有确认发出去之后才 commit(:128)。这个「先算后提交」的分离,让"序列化失败/通道满了"这类情况不会污染基线。
模块顶部注释列出的 6 步策略(src/protocol/render_ansi.rs:1-18),对应实现在 blit_frame_to_with_cursor_memory_and_clear_policy(:447):
| 步 | 做什么 | 为什么 |
|---|---|---|
| 1 | 第一帧写整个缓冲区 | 没有基线可 diff |
| 2 | 之后只写变化的单元格 | 这是省字节 的主力 |
| 3 | 整帧包在同步输出里 | 支持的终端不会看到画到一半的中间态 |
| 4 | 写任何单元格前先隐藏光标 | 否则光标会在中间的 CUP 位置上留下残影 |
| 5 | 全部写完再恢复光标可见性与位置 | 先显示后移动会让慢终端/输入法看到光标停在最后画的那格 |
| 6 | 需要的平台在结束同步输出后再补一次光标锚点 | 有些原生输入法看不到同步块内的光标移动;Windows Terminal 会把这次重复显示成可见抖动,所以 Windows 跳过 |
第 6 步的平台分叉就是两个函数:repeat_ime_anchor_after_sync() 在 Windows 返回 false、其他平台返回 true(src/protocol/render_ansi.rs:513-521)。
用到的转义序列(注释在 :20-25,实际写入在 :464-500):
| 序列 | 作用 |
|---|---|
CSI H(CUP) | 移动光标到 (行, 列) |
CSI m(SGR) | 设置颜色/加粗等 |
CSI ? 2026 h / l | 开始/结束同步输出 |
CSI Ps SP q(DECSCUSR) | 光标形状 |
CSI ? 25 l | 隐藏光标(第 4 步) |
OSC 8 ;; | 每帧开头清空超链接状态,防止未加链接的格子继承上一次的 URI |
CSI 2 J | 仅在从未画过时清屏(clear_before_full_redraw) |
OSC 52 | 剪贴板写入 |
省字节的关键在 write_changed_cells(:773):按行扫描,用 last_sgr 记住上一次的样式串避免重复发 SGR;用 next_inline_col 记住"如果下一格正好是当前列 +1,就不用再发一次 CUP"(:800-812)。宽字符和被覆盖的残留格用 to_skip / invalidated 两个计数器处理(:814-816)。
5.5 为什么远程要发 ANSI 而不是发 cell
答案在远程 attach 的启动代码里——它硬编码了编码方式:
// src/remote/attach.rs:2126,run_client_process
.env("HERDR_RENDER_ENCODING", "terminal-ansi")
道理很简单:远程链路上跑的每一个字节都要过 SSH。语义帧的体积由屏幕面积决定,而 ANSI 帧的体积由这一帧真的变了多少决定。一个 agent 只是在最后一行追加了一行日志,ANSI 编码可能只有几十字节,语义帧仍然是满屏一万个单元格。
代价也很明确:diff 的 CPU 成本从客户端搬到了服务端,而且服务端必须为每个 ANSI 客户端各维护一份 BlitEncoder(因为每个客户端看到的上一帧不同)。
5.6 图形字节要塞进同步块里面
Kitty 图形字节不能随便追加在末尾,否则会在同步输出块结束之后才落地,导致闪一下。所以:
// src/server/render_stream.rs:158 insert_graphics_before_sync_end
if let Some(sync_end) = crate::protocol::render_ansi::final_sync_output_end(encoded) {
encoded.splice(sync_end..sync_end, graphics.iter().copied());
} else {
encoded.extend_from_slice(graphics);
}
final_sync_output_end(src/protocol/render_ansi.rs:39)用 rposition 找最后一个 \x1b[?2026l 的位置,把图形字节插在它前面。客户端侧有对应的测试:graphics_bytes_are_written_inside_synchronized_blit_with_saved_cursor(src/client/mod.rs:2961)。
6. 增量与早退:能不重画,就绝不重画
这一节是整条管线里工程密度最高的部分。核心矛盾:一个 agent 的 PTY 可能每 秒产生几百次输出,而全渲染要重新画侧栏、tab 栏、所有 pane 边框、状态栏——绝大部分内容根本没变。
6.1 先分级:这次改动有多严重
enum RenderImpact { None, Graphics, Full } // src/server/headless.rs:133
merge 就是取 max(:141-143),所以一批事件里只要有一个是 Full,整批就按 Full 算。record_render_impact(:182)把"是 API 请求还是客户端事件导致了全渲染"打成 profiling 事件,方便事后归因。
PTY 单独有个三态,因为它要回答"这次脏的 pane,有人看得见吗":
enum PtyRenderState { Clean, Hidden, Visible } // src/server/headless.rs:147
判定逻辑在 pty_sources_visible_to_any_render_target(:4142),它会问:有 app 客户端吗?脏的 pane 在当前工作区的当前 tab 里吗?被 zoom 挡住了吗(app_surface_contains_pane,:4178)?有没有哪个直连终端客户端正盯着这个 terminal?
6.2 再选计划
四种输入组合,四条出路,一个纯函数 说了算:
fn retained_render_plan(input: RetainedRenderInput) -> RetainedRenderPlan { // src/server/headless.rs:168-181
if input.needs_full_render { Full }
else if input.needs_graphics_render && input.pty != Visible { Graphics }
else { match input.pty { Visible => Pty, Hidden => HiddenPty, Clean => Full } }
}
needs_full_render? --yes--> Full (走 render_and_stream,整条管线全跑)
|no
v
needs_graphics? & PTY 非可见 --yes--> Graphics (只重发图形层)
|no
v
PTY 状态?
Visible --> Pty (拿上一帧打补丁,不重画 UI)
Hidden --> HiddenPty (什么都不发,只推进节流时钟)
Clean --> Full (说不清,保守走全渲染)
Clean 落到 Full 是个保守兜底:走到这里说明有人喊了"要渲染"但没说清是什么脏了,那就重画。四条路径都有断言覆盖:retained_render_plan_covers_each_render_path(src/server/headless.rs:5317)。
HiddenPty 这条路的收益最直接——它返回 true(算作"渲染成功"),然后 record_render_attempt(now, false)(:789)把 presentation = false 传下去。也就是:分类一次隐藏输出会推进渲染节流,但不会占用呈现配额。这正是 §3.4 里那两个闸门存在的理由。
6.3 保留帧局部打补丁:Pty 路径怎么走
这是最省的一条路。思路:上一帧我还留着,PTY 只脏了几行,那就直接把那几行的单元格覆盖进去,其余原封不动。
拿到 client.render_state.last_frame() 的克隆
|
for 每个可见 pane:
runtime.collect_dirty_patch(w, h)
|-- Clean -> 跳过
|-- Fallback -> 放弃,退全渲染
`-- Patch(p) -> 检查是否碰到超链接 -> 覆盖进 frame
|
重新取一次聚焦终端的光标
|
没碰任何格子 且 光标没变 -> 直接算成功,一个字节都不发
|
否则 -> send_retained_frame_to_client
实现是 render_retained_pty_update_and_stream(src/server/headless.rs:4218)。三个辅助函数守着边界:
| 函数 | 位置 | 守什么 |
|---|---|---|
rect_fits_frame | :193 | pane 的矩形必须完全落在帧内,否则任何切片都不安全 |
apply_terminal_dirty_patch | :198 | 逐行 clone_from_slice;行宽对不上、越界一律返回 false |
dirty_patch_intersects_hyperlinks | :222 | 补丁覆盖的区域里若已有超链接格子,拒绝——因为补丁不带 OSC 8 信息,盖上去会留下指向旧 URI 的僵尸链接 |
dirty_patch_intersects_hyperlinks 的写法是刻意保守的:范围算不出来时(local_y >= area.height、end > cells.len())也返回 true,即"当作相交,放弃优化"。优化路径出错的代价远大于少优化一次。
6.4 哪些状态会让保留更新直接退化成全渲染
第一道闸门是一个纯谓词,读起来像一张清单:
fn retained_pty_update_allowed_by_app_state(&self) -> bool { // src/server/headless.rs:4332
self.app.state.mode == app::Mode::Terminal
&& self.app.state.popup_pane.is_none()
&& self.app.state.selection.is_none()
&& self.app.state.copy_mode.is_none()
&& self.app.state.context_menu.is_none()
&& self.app.state.toast.is_none()
&& self.app.state.copy_feedback.is_none()
&& !self.app.full_redraw_pending
}
共同点很清楚:凡是会在 pane 上面盖一层东西的状态,都不能用「直接把 PTY 行拍进上一帧」这招——因为补丁不知道自己头顶上压着一个弹窗或 toast,盖上去就把覆盖层抹花了。每一条都有测试作证:
| 判据 | 对应测试 | 行号 |
|---|---|---|
| 弹出 pane 可见 | retained_pty_update_declines_while_popup_is_visible | :9834 |
| toast 通知可见 | retained_pty_update_declines_while_toast_is_visible | :10048 |
| 复制反馈可见 | retained_pty_update_declines_while_copy_feedback_is_visible | :10090 |
| 非 Terminal 模式 | retained_pty_update_declines_unsafe_mode_without_consuming_dirty_rows | :10230 |