两大架构范式:即时模式 vs Elm 架构
做 TUI 之前先想清楚一件事:UI 状态和渲染逻辑之间是什么关系。业界基本收敛到两条路。
即时模式(Immediate Mode)——ratatui
官方文档说得很直接:UI 在每一帧都被重新创建,没有常驻的 widget 对象。
loop {
terminal.draw(|f| {
if state.condition {
f.render_widget(SomeWidget::new(), layout);
} else {
f.render_widget(AnotherWidget::new(), layout);
}
})?;
}
zellij-workbench 的 draw_tui(src/tui.rs)就是这个模式:
每次都重新构建 List、Paragraph、Line,不持有上一帧的
widget 引用。好处是简单——UI 逻辑就是状态的直接投影;代价是渲染循环和事件循环都要自己管,
库不会替你决定"什么时候该重画"。
Elm 架构(Model-Update-View)——bubbletea
Go 生态的 bubbletea(4.3 万+ star)是这个流派的代表。
三个函数:Init() Cmd 返回启动命令,Update(Msg) (Model, Cmd) 收到消息后返回
新状态 + 可选副作用命令,View() string 是只读 Model 的纯函数。数据单向流动:
事件 → Msg → Update → 新 Model → View → 终端,副作用被隔离进 Cmd。
持久 View + 脏标记——gocui / lazygit
第三种、更实用主义的形态:View(pkg/gocui/view.go)是一个持久对象,
持有自己的行缓冲区、光标、滚动位置——更接近传统 retained-mode 控件。但驱动方式是一个
tainted 脏标记 + 显式的 SetContent 调用,本质是"有状态的即时模式":
视图对象常驻,内容仍是整体替换而非增量 patch。
直接拥抱 ratatui 的即时模式,不要在上面再手撸一层 widget 树 + diff——库层的 Buffer diff 已经处理了性能问题。
网络请求、订阅、多个独立异步来源较多时,Elm 架构能少踩很多"状态在哪改的"坑,即使不用 bubbletea 也可以手动实现简化版。
多层导航、复杂如 lazygit 时,迟早需要持久视图对象 + 显式脏标记;纯函数式"每帧重建一切"会因状态量太大而笨重。
事件循环:TUI 的心脏
不管选哪种范式,事件循环骨架都长一个样:读取/等待事件 → 根据事件更新状态 → 按需重新渲染。
loop {
apply_completed_refresh(&refresh_rx, ..., &search, view, server_filter.as_deref());
if last_auto_refresh.elapsed() >= AUTO_REFRESH_INTERVAL && !auto_refresh_in_flight {
spawn_auto_refresh(refresh_tx.clone());
auto_refresh_in_flight = true;
}
terminal.draw(|frame| { /* 渲染整棵 UI 树 */ })?;
if event::poll(Duration::from_millis(200))? {
if let Event::Key(key) = event::read()? {
match mode { /* 处理按键 */ }
}
}
}
三个关键设计点:
event::poll(200ms)而不是 busy loop:阻塞式event::read()没法在等键盘时检查"该不该自动刷新了";无超时忙轮询会把一个 CPU 核心跑到 100%。200ms 对人类按键无感知延迟,却足够低频不浪费 CPU。- 渲染在事件处理之前:保证任何状态变化(哪怕后台线程刚推过来的)都会在下一次循环里被画出来,不需要额外的"是否 dirty"判断。
- 事件循环单线程,副作用去别的线程:任何可能阻塞的操作(扫描会话、跑 git 命令)都不能直接放进这个循环,否则一次扫描卡 3 秒,UI 就跟着卡 3 秒。
lazygit:两个 channel 喂一个单线程循环
pollEvent() goroutine 只把终端事件塞进 g.gEvents;任何后台 goroutine 想更新 UI,
必须调用 Gui.Update(f),本质是把闭包塞进 g.userEvents channel:
func (g *Gui) Update(f func(*Gui) error) {
task := g.NewTask()
select {
case g.userEvents <- userEvent{f: f, task: task}:
default:
panic("gocui: userEvents channel full; refusing to block or reorder")
}
}
故意用非阻塞的 select/default:从 UI 线程自己往自己发送事件,阻塞会死锁;
而"满了就退化成同步执行"看似安全,实际会破坏调用者依赖的顺序保证——宁可 panic 暴露 bug。
主循环对两个 channel 做一次 select,再非阻塞地把当前排队的事件全部处理掉,才决定要不要渲染——
显式的事件合并:短时间挤在一起的一批事件先处理完,只渲染一次。
通用结论:无论语言/框架,"单线程事件循环 + 所有跨线程通信走 channel/队列"几乎是 TUI 的铁律。 你可以让这个约束在类型系统里体现(Elm 架构的 Cmd/Msg),也可以像 gocui 那样用运行时 panic 兜底, 但"UI 状态只能被拥有它的那个线程修改"这条线不能碰。
渲染性能:即时模式不等于暴力全屏重画
最常见的误解是"每帧重画所有 widget = 每帧把所有字节都发给终端"。实际上这是两件事,中间隔着一层 diff。
ratatui 的 Buffer diff:渲染进内存里的 Buffer 后,并不是整体转成转义序列发给终端,
而是用 BufferDiff 迭代器只把这一帧里真正变化的 (x, y, cell) 发给后端。
gocui/tcell 是同一套思路,只是发生在更底层:CellBuffer.Dirty(x, y) 比较这帧和上帧,
没变化的格子根本不会被写进要发给终端的字节流。
结论 1:你几乎不需要手写"脏矩形"逻辑,这部分性能你白得。
结论 2:真正的陷阱在"逻辑重建"这一层,不在"物理发送"这一层——为一个不在屏幕上的 1 万行列表格式化 1 万个字符串,这个 CPU 开销已经发生了,diff 只省了"发给终端"那一小步。
lazygit 对"逻辑重建"这一层的具体处理
if self.renderOnlyVisibleLines {
// Rendering only the visible area can save a lot of cell memory for
// those views that support it.
startIdx, length := self.GetViewTrait().ViewPortYBounds()
content := self.renderLines(startIdx, startIdx+length)
self.GetViewTrait().SetViewPortContentAndClearEverythingElse(totalLength, content)
}
- 列表虚拟化:只格式化视口可见范围内的行,而不是整个提交历史/文件列表。
- 跳过整个布局重算的"仅内容"快路径:
flushContentOnly()在整批事件都标记contentOnly时,直接跳过Layout()重算,只重画tainted的 view——状态栏 100ms 一次的 spinner 没理由触发整体窗口布局重排。 - 昂贵计算的显式缓存:commit 图的
pipeSetCache、着色字符串的rgbCache、git config读取的CachedGitConfig,避免每次刷新都重新计算/fork 子进程。 - 限流突发请求:
pkg/tasks/tasks.go用户快速翻动 commit 列表时,若上一个
const THROTTLE_TIME = time.Millisecond * 30 const COMMAND_START_THRESHOLD = time.Millisecond * 10git show被取消得很快、系统看起来已吃力,下一个命令会先 sleep 30ms 再启动——显式避免"快速翻页 = 疯狂 fork 子进程"。
检查清单
- 长列表一定要做视口窗口化——diff 只能省发送成本,省不了构建字符串的成本。
- "同一份输入、短时间内重复计算"的东西,用一个
HashMap做 memoization。 - 高频但视觉轻量的更新(进度条、spinner)要有不触发整体布局重算的快路径。
- 用户能快速触发、背后连着重量级命令的操作,要"取消上一个 + 限流下一个"。
布局系统:约束驱动,而不是像素计算
好的 TUI 布局都收敛到同一个思路:声明式的约束/权重,而不是手算 x/y 坐标——终端 resize 时只需重新求解约束。
let shell = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // 顶部状态栏:固定 3 行
Constraint::Min(8), // 中间主体:至少 8 行,其余空间都给它
Constraint::Length(1), // 底部快捷键提示:固定 1 行
])
.split(frame.area());
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(52), Constraint::Percentage(48)])
.split(shell[1]);
典型的嵌套切分:先竖切"头/体/脚"三段,再把中间段横切成 52%/48% 两栏。resize 后这段代码完全不用改。
type Box struct {
Direction Direction // ROW 或 COLUMN
Children []*Box
Window string // 叶子节点:对应哪个窗口
Size int // 静态大小(和 Weight 二选一)
Weight int // 动态权重,按比例分配剩余空间
}
ArrangeWindows 的分配算法本质是 flexbox:先满足所有 Size 子节点,剩余空间再按
Weight 比例分给其余子节点——和 CSS flex-grow 是同一个模型。每一帧都用当前屏幕
宽高、聚焦窗口、屏幕模式重新构建整棵 Box 树再求解——布局从头算,不做增量 patch,
因为树很小,重算开销可忽略。
约束系统复杂度该和布局复杂度匹配——两栏静态布局用 Constraint 就够,"正常/半屏/全屏/横竖屏切换"值得单独抽一棵可递归的 Box 树。
两边都没为 resize 写专门分支——布局本来就是每帧算的,尺寸只是输入参数之一。
Rect 数组或 Dimensions map 只回答"这块区域多大在哪",不掺杂"画什么"。
状态与导航:从一个枚举到一个上下文栈
enum InputMode {
Normal,
Search,
}
一个枚举 + match,对"列表浏览 + 搜索输入框"这种扁平交互完全够用。但如果需要
"面板弹菜单、菜单再弹确认框、Esc 逐层退回、退回后原面板状态原样保留"——扁平枚举会迅速膨胀成
所有模式的笛卡尔积式巨型 match。
lazygit 的显式上下文栈
type ContextMgr struct {
ContextStack []types.Context
sync.RWMutex
...
}
每个面板/弹窗实现 types.Context,声明自己的种类,压栈规则按种类走:
- 压入
SIDE_CONTEXT(侧边栏)→ 清空整个栈只留它自己(互斥,不嵌套)。 - 压入
MAIN_CONTEXT(主内容区)→ 移除其他MAIN_CONTEXT,但保留侧边栏等其他层。 - 其他情况(菜单/确认框)→ 若栈顶是临时弹窗,直接替换而非嵌套(代码注释坦诚承认这是已知取舍:临时弹窗复用同一个 view,理论上该支持逐层返回,目前做不到)。
这个栈同时是键盘事件路由的唯一依据:激活一个 context 会调用 SetCurrentView,
gocui 决定"按键该由谁处理"只认 g.currentView——"现在按键归谁",答案永远是"看栈顶"。
什么时候该升级
- "模式"之间开始出现真正的嵌套而不是简单互斥切换。
- Esc 的语义变成"退回上一层"而不是"回到某个固定默认状态"。
- 不同模式需要独立的按键绑定表,而不是在一个大
match里穷举笛卡尔积。
不要过早引入:如果确实只有"浏览 + 搜索"这种扁平交互,一个枚举远比一整套 context 抽象好维护。上下文栈是给"确实有嵌套导航需求"的应用准备的,不是 TUI 的标配起手式。
异步与响应性:永远不要阻塞事件循环
耗时操作(网络、子进程、磁盘)怎么办?两边答案高度一致:开线程/goroutine 去做,通过 channel 把结果送回来,事件循环只非阻塞地"看看有没有新结果"。
let (refresh_tx, refresh_rx) = mpsc::channel();
fn spawn_auto_refresh(refresh_tx: Sender<RefreshResult>) {
thread::spawn(move || {
let result = refresh_index_report()
.and_then(|summary| { /* 重新从 SQLite 读一遍 */ })
.map_err(|err| format!("{err:#}"));
let _ = refresh_tx.send(result);
});
}
主循环每次迭代开头非阻塞地 try_recv() 取出已跑完的后台结果合并进 UI 状态,同时用
restore_selection 按 ID(而不是下标)找回用户当前选中的行——不会因为数据刷新就把光标
弹回列表顶部。扫描/git 命令全程在另一线程跑,事件循环该 200ms 轮询按键就照常轮询。这本质上是
一个手搓的、简化版"Cmd 派发 + Msg 回收"(对应第一节的 Elm 架构思路)。
lazygit 的两层异步
Gui.OnWorker(f) 开 goroutine 跑 f,自动包 panic 恢复;
RefreshHelper.Refresh 让文件/分支/commit/stash 等各自并发刷新,跑完必须通过
OnUIThread(即 Gui.Update)写回 UI,绝不允许 worker 直接改 view;
每类数据各自一把互斥锁(RefreshingFilesMutex 等)防止刷新互相踩踏。
更精细的是 pkg/tasks/tasks.go 的 ViewBufferManager:翻 commit 时每选中一个就要跑
git show,与其等命令跑完再整体塞进 view,不如 4 个协作 goroutine 边读边显示——一个扫
stdout 灌 channel,一个 200ms 无内容时显示占位符,一个消费者写入并局部重绘,一个监听取消信号
优雅终止上一个还没跑完的进程。
检查清单
- 可能阻塞超过一帧时间的操作,不能直接放进事件循环所在线程。
- 后台结果必须通过 channel/队列交回事件循环线程再改状态,不要靠
Mutex<State>跨线程直改然后指望渲染线程"自然看到"。 - 高频刷新要么去重(已经在跑就不重复启动),要么限流。
- 长输出命令考虑流式消费,能显著改善大仓库/长历史下的可感知延迟。
必要功能清单:一个"好用"的 TUI 需要什么
- 快捷键要可发现,不能靠用户记文档。
zellij-workbench的底部状态栏常驻展示当前模式下所有可用按键;lazygit 更进一步,底部选项栏 +?完整可搜索键位菜单共享同一份 binding 数据。 - 搜索/过滤要支持结构化查询。
SearchQuery::parse支持server:status:tag:git:前缀叠加纯文本关键词,一行输入表达复合条件。 - 异步操作要有可见的状态反馈,哪怕只是一行文字(
scan_status:idle / refreshing… / ok, N workspaces / failed)。 - 默认操作克制,危险操作显式升级。会话缺失时
attach报错并提示"用recreate",而不是静默建一个空会话;lazygit 对 force push、丢弃修改会弹确认框。 - 后台刷新不能打断用户当前操作状态。
restore_selection按 ID 找回选中行,而不是刷新后 selection 归零。 - resize 要免特殊处理——只要布局是"每帧从当前尺寸重新求解",几乎不需要专门代码(见第 4 节)。
终端状态必须能在异常退出时被恢复
这是本文写作过程中在这个仓库里发现的真实问题:
enable_raw_mode()?;
execute!(stdout, EnterAlternateScreen)?;
let result = draw_tui(&mut terminal, workspaces); // 如果这里 panic……
disable_raw_mode()?; // ……这两行就永远不会跑
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
一旦 draw_tui 内部 panic(哪怕只是下标越界的低级 bug),用户终端会卡在 raw mode + 备用屏幕里,回车没反应,体验非常糟糕。修复:
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
draw_tui(&mut terminal, workspaces)
}));
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
match result {
Ok(result) => result,
Err(payload) => std::panic::resume_unwind(payload),
}
lazygit 的 onWorkerAux 同理:未恢复的 panic 会先调用 Screen.Fini()(tcell 退出
备用屏幕、恢复终端模式的清理函数)再继续 panic。原则一样:任何可能 panic 的路径,只要发生在
"终端已切换到特殊模式"之后,就必须显式兜底恢复终端——这个教训对所有直接操作 raw mode 的
语言/框架通用(Node 的 blessed/ink、Python 的 curses 都有等价的 finally/atexit 模式)。
案例对照表
| 维度 | zellij-workbench(ratatui) | lazygit(gocui/tcell) |
|---|---|---|
| 架构范式 | 即时模式:每帧重建 widget 树 | 持久 View 对象 + 脏标记局部重建 |
| 事件循环 | 单线程 loop,poll(200ms) | 单线程 processEvent,两个 channel 汇合 |
| 渲染 diff | BufferDiff(cell 级) | tcell Dirty()(cell 级)+ gocui 自研 view 级快路径 |
| 长列表优化 | 未做虚拟化(数据规模不需要) | RenderOnlyVisibleLines:只格式化可见窗口 |
| 布局系统 | Layout + Constraint | 自研 boxlayout:Size/Weight 递归树 |
| 导航/模式 | 扁平枚举 InputMode | ContextMgr 上下文栈 |
| 异步 | thread::spawn + mpsc | OnWorker + OnUIThread,scope 级互斥锁 |
| 限流/缓存 | 暂无(规模不需要) | commit 图/颜色/git config 缓存,命令限流 30ms |
| 快捷键发现 | 底部固定提示行 | 底部选项栏 + ? 完整键位菜单 |
| 终端状态恢复 | catch_unwind(本次修复) | panic recover + Screen.Fini() |
不是"lazygit 处处更强",而是两边复杂度投入和问题规模匹配——lazygit 要处理任意大小的真实仓库、
复杂多面板导航,所以在虚拟化/缓存/限流/上下文栈上做了真功夫;zellij-workbench 现阶段的
数据规模用即时模式 + 扁平状态就够。先把复杂度控制在刚好够用,等真的遇到"明显卡了"或
"模式管理已经乱了"的信号,再按本文方向加虚拟化、缓存、上下文栈。
延伸阅读
- Ratatui:Rendering under the hood — 官方文档对 Buffer diff 机制的详细说明
- Ratatui ARCHITECTURE.md — 0.30 之后的多 crate 拆分
- The Elm Architecture(bubbletea) — Model-Update-View 在 Go 里的落地
- lazygit 源码 — 尤其是
pkg/gocui/gui.go、pkg/gui/context.go、pkg/tasks/tasks.go、lazycore/pkg/boxlayout - 本仓库
src/tui.rs— 本文几乎每个 ratatui 例子都直接摘自这个文件
建议起步顺序:先用即时模式 + 扁平状态枚举把主流程跑通,按第 2 节把事件循环和异步刷新的骨架 搭对,再根据实际遇到的性能/导航复杂度问题,参考第 3、4、5 节里 lazygit 的做法逐步"加料"—— 而不是一开始就把上下文栈、虚拟化列表、限流这些为大规模场景准备的机制全部搬进来。