GPUI 是什么,以及 MyGo 如何实现同类界面
最后更新:
GPUI 是一个用 Rust 编写图形界面应用的框架,由 Zed 团队开发。Zed 是编辑器,GPUI 是支撑它的界面基础:项目树、标签页、弹窗和编辑区域,都需要这套基础来处理布局、输入和屏幕绘制。GPUI 官网
使用 GPUI 时,开发者写 Rust 代码说明“界面上有什么、排在哪里、用户操作后怎样改变状态”,框架把这些描述变成可交互的窗口。它提供自己的界面元素和绘制系统,并使用 GPU 加速生成屏幕上的像素。
Go 也可以采用自己绘制界面的路线。MyGo(egoist/mygo) 同时支持系统 WebView 和 Go 编写的原生 UI。下面先解释 GPUI,再沿 MyGo 的源码看同类问题在 Go 中怎样解决。
GPUI 替应用做了哪些事
假设要写一个最简单的桌面计数器:窗口中央显示 0,下面有一个“加一”按钮,点击后数字变成 1。
业务规则只有一句“点击后加一”,但要让它成为一个可用界面,还需要创建操作系统窗口,确定数字和按钮的位置,识别鼠标是否点在按钮内,接收点击,更新文字,再把新画面显示出来。窗口缩放后,布局还要跟着变化。
GPUI 将这些界面工作组织成一套可复用的 API:
| 工作 | 应用代码表达什么 | GPUI 负责什么 |
|---|---|---|
| 窗口 | 初始尺寸、根视图等参数 | 对接平台窗口和事件循环 |
| 内容与布局 | 显示数字、放置按钮、居中排列 | 构建元素树,计算位置与尺寸 |
| 输入与交互 | 点击后将计数加一 | 接收平台输入,按元素区域和事件规则分发 |
| 状态更新 | 保存当前计数,通知它已变化 | 管理实体,安排相关视图更新 |
| 绘制 | 文字、背景、边框等样式 | 组织绘制数据,交给图形后端显示 |
因此,GPUI 处在应用业务逻辑与操作系统、图形接口之间。开发一个普通按钮时,可以组合已有元素、设置样式并注册交互;需要编辑器这样的特殊控件时,也可以下探到自定义布局和绘制。GPUI 的能力分层
这里还要区分三个词:GUI 是图形用户界面这一类交互方式;GPU 是绘制图形的硬件;GPUI 是一个具体的软件框架。用 GPUI 做桌面工具,不需要把业务写成 GPU 程序,通常也不需要自己调用 Metal 等图形接口。
为什么 Zed 要自己做界面框架
编辑器的界面会频繁变化:输入字符会改变文字和光标,滚动会改变可见行,搜索会改变高亮区域。Zed 团队希望直接控制这些界面的更新和绘制路径,因此开发了 GPUI。它围绕界面需要的矩形、文字和图片等内容建立绘制能力,同时提供 Rust 中组织状态和处理交互的方式。Zed 团队对 GPUI 的介绍
应用开发者可以复用这套框架,做编辑器以外的界面。这里复用的是窗口、元素、输入和绘制能力;文件管理、网络请求、数据处理等业务仍由应用实现。
GPU 在这里做什么
GPU(Graphics Processing Unit,图形处理器)擅长并行处理图形任务。GPUI 用 GPU 绘制矩形、边框、阴影和图片等图元;文字先由平台文字系统排版,字形在 CPU 上光栅化进图集,再由 GPU 合成到窗口。
例如,编辑器需要画出一排标签页。应用先确定每个标签的文字、位置、大小、颜色和选中状态,再把绘制信息交给渲染器。GPU 根据这些信息生成屏幕上的像素。点击标签后打开哪个文件、怎样修改应用状态,仍由 Rust 代码处理。
Zed 团队在 Zed Decoded: GPUI 中解释过这条路线:围绕界面常用的图形原语编写着色器,在 CPU 上描述它们的属性,再交给 GPU 绘制。着色器是运行在 GPU 上、计算图形位置或像素颜色的程序。普通 GPUI 应用开发者通常使用框架提供的元素,无需自己编写着色器。
因此,“GPU 加速”描述的是绘制方式。读取文件、解析数据或在界面线程中执行耗时计算,依然可能让应用卡住。也不能单凭使用 GPU 就断言 GPUI 一定比某个 Electron 应用更快;性能比较需要相同工作负载和实际测量。
用 Rust 描述界面是什么样子
用刚才的计数器看 GPUI 的写法。下面是视图片段,省略应用启动和窗口创建;实际创建根视图时,需要把 Counter { value: 0 } 注册为实体:
| |
这段代码可以分成三部分读。Counter.value 保存当前数字;render 描述一个居中的纵向容器,里面先放数字,再放可点击区域;on_click 指定点击后的操作。
div() 创建容器,.child(...) 加入子内容,.flex_col() 表示纵向排列。这种链式 API 借鉴了 CSS 和 Tailwind 的表达习惯,实际调用的是 Rust 方法,生成的是 GPUI 元素树。它不需要浏览器解析 HTML,也不等于支持完整的 CSS。官方 Hello World 示例
cx.listener(...) 把事件回调与当前实体连接起来,让回调可以修改计数器。cx.notify() 告诉框架,这个实体的状态发生了变化。代码中的可点击 div 只演示鼠标交互;完整按钮还需要键盘操作、焦点和无障碍语义。listener 实现、官方点击事件示例
“原生桌面界面”也需要区分含义:GPUI 可以接入操作系统窗口和平台服务,窗口内部的元素由 GPUI 自己绘制。它不意味着每个按钮都是系统提供的按钮控件。
状态怎样变成屏幕上的内容
阅读 GPUI 代码时,经常会遇到这几个名字:
| 概念 | 负责什么 |
|---|---|
App | 管理应用级状态与服务,创建窗口和实体 |
Entity<T> | 指向由应用管理的 T 状态的句柄 |
Render | 根据当前状态生成界面元素树 |
Context<T> | 为当前实体提供访问应用、通知变化等能力 |
在计数器中,Counter 是被管理的状态,Entity<Counter> 是访问这份状态的句柄;它实现了 Render,所以也是一个可以显示的视图。App 持有实体,Context<Counter> 则让当前计数器访问框架服务。单独持有实体句柄,不等于可以绕开上下文直接修改其字段。
GPUI 这样组织状态,是为了处理 Rust 中的所有权问题。项目列表、编辑区和弹窗可能互相通信,把所有对象简单地嵌套起来,很难表达这些关系。由 App 管理实体、通过句柄和上下文访问它们,可以让界面各部分协作,同时保留 Rust 的借用约束。Ownership and data flow in GPUI
回到数字从 0 变成 1 的过程。这里涉及两个先后发生的动作:事件处理修改状态,随后渲染读取新状态。它们通过 GPUI 的通知和调度连接起来:
- 平台传来鼠标输入,GPUI 将点击分发到“加一”区域的回调。
- 回调把
Counter.value从0改成1,并调用cx.notify()。 - 框架安排相关视图更新;渲染时,
render读取新值,生成显示1的文本元素。 - 布局系统确定文本和可点击区域的位置,元素准备绘制内容。
- 图形后端将内容画到窗口上,用户看到
1。
notify() 不会在这一行代码里立即把像素改掉。GPUI 的通知涉及更新队列和窗口失效处理,由框架协调后续更新。这也让“先完成状态修改,再处理其影响”有了明确顺序。状态通知的处理方式
这里简化了缓存和调度细节。GPUI 的底层 Element 接口将布局请求、绘制前准备和绘制分为 request_layout、prepaint、paint,布局使用 Taffy。需要自定义布局或绘制行为时,可以实现自己的元素。Element 源码
为什么称为“即时模式与保留模式混合”
GPUI 官方将它描述为 hybrid immediate and retained mode。要理解这个说法,可以先区分两种常见的界面组织方式。
保留模式会让框架长期保存界面对象,应用修改这些对象的属性。例如持有一个文本控件,把它的内容从 0 改成 1。
即时模式通常由应用在绘制时重新执行界面描述代码,根据当前状态说明这次要显示什么。界面长什么样,直接体现在这次执行的代码中。
GPUI 的实体和视图状态可以跨帧保存;渲染时,Render 又根据这些状态构建元素树。底层还支持通过元素标识关联跨帧状态。这就是它混合两种方式的含义:长期存在的是应用状态,界面描述可以随着渲染重新生成。元素生命周期说明
对上面的计数器来说,跨帧保存的是 Counter.value;显示数字的文本元素由 render 根据当前值创建。如果把计数写成 render 内的 let mut value = 0,下次执行就会重新变成 0。这正是应用状态与本次界面描述需要分开的原因。
与 Electron、Tauri 有什么区别
三者都能用于桌面应用,但界面技术路线不同。
| 比较项 | Electron | Tauri | GPUI |
|---|---|---|---|
| 界面描述 | HTML、CSS、JavaScript 等 Web 技术 | Web 前端,可配合 Rust 核心逻辑 | Rust 元素、视图和样式 API |
| 界面渲染 | 随应用分发的 Chromium | 平台提供的 WebView | GPUI 自身的绘制系统与 GPU 后端 |
| 复用已有 Web 界面 | 可沿用 Web 前端代码 | 可沿用 Web 前端代码 | 通常需要重写为 GPUI 界面 |
| 可复用的界面能力 | Web 组件和浏览器能力 | Web 组件和 WebView 能力 | GPUI 元素及适配它的组件库 |
Electron 的结构见官方进程模型说明,Tauri 的 WebView 与 Rust 分工见官方架构文档。
Tauri 使用 Rust 核心逻辑,并不意味着它与 GPUI 使用相同的界面方案。对已有 React 或 Vue 项目的团队,Tauri 仍可以延续 Web 前端;迁移到 GPUI,则需要重新实现界面和相应交互。
GPUI 提供的是构建界面的基础能力。复杂表单、数据表格等成品控件,可以进一步考察 Longbridge 的 GPUI Kit,并核对它与所选 GPUI 版本的兼容关系。
MyGo 怎样实现原生界面
MyGo 是一个用 Go 编写桌面应用的框架,提供两条窗口内容路径:一条把 HTML、CSS 和 JavaScript 放进系统 WebView,另一条用 Go 的 ui 包直接构建原生界面。一个应用或窗口可以混合使用两类内容,本文只分析原生 UI。
| 路径 | 界面来源 | 如何显示 |
|---|---|---|
| 网页 | HTML、CSS、JavaScript,通过生成的 TypeScript 客户端调用 Go | WKWebView、WebKitGTK 或 WebView2 |
| 原生 UI | Go ui 视图 | MyGo 构建、布局和绘制,交给 GPU 或软件光栅化 |
原生路径不依赖 HTML、JavaScript 或用于显示界面的 WebView;窗口、输入和平台操作仍由系统后端提供。这一节分析的源码固定在提交 ff19a6f9。它的原生 UI 链路由 Go 的 ui、internal/scene 和平台渲染器组成,和前面 GPUI 的 Rust 实现分别演进。相似的声明式写法,不代表两者拥有相同的状态管理或渲染实现。
窗口先选择 WebView 还是绘制表面
MyGo 的两条路径从窗口参数开始分开:传入 URL 显示网页,传入 Content: ui.View(...) 显示原生 UI。
入口 NewWindow 最终进入 newWindow,其中用 opts.Content != nil 设置平台参数 Surface。macOS 后端的 createContent 根据这个字段选择 createSurface 或 createWebView。前者提供 MyGo 自己绘制内容的表面,后者创建 WKWebView。窗口入口、macOS 分支
| |
网页路径中,Bind 注册 Go 服务,生成器依据服务类型生成 TypeScript 客户端;页面通过注入的桥接脚本发送调用,Go 解码、查找方法、执行后返回结果。handleCall 会检查页面是否受信任,TypeScript 类型本身不能代替运行时校验。IPC 实现、类型生成器
原生 UI 则直接调用 Go 视图函数。attachContent 取得平台表面,组装 surface.Conn,将输入、重绘请求和窗口服务连接给 ui.Content.AttachContent。后者创建 windowHost 和 UI 引擎,并请求首帧。连接窗口与内容、UI 引擎挂接
这两种窗口共用生命周期和桌面 API。原生界面也可以显式嵌入 WebView,因此描述应用的资源需求时,要看它实际使用了哪些内容。
点击发生在构建过程中,为什么文字不会慢一帧
下面从官方计数器中保留最相关的部分。s.n 是应用长期保存的 Go 字段,视图函数每次根据它构建界面:
| |
这里先创建数字文本,再查询按钮点击。如果点击时 s.n 从 0 变成 1,前面创建的文本仍然是 0,框架怎样处理?
关键在 engine.runFrame:它在布局和绘制之前,最多执行三次构建。Clicked() 发现点击后,会设置 rt.consumed = true;本次构建结束时,endPass 清掉已经处理的点击等输入,再次运行视图函数。第二次构建读到 s.n == 1,文本随之更新,按钮也不会重复消费同一个点击。构建循环、点击查询
这个实现也约束了视图函数的写法:一次显示帧可能执行多次构建,不能在函数顶层无条件发送请求或保存文件。副作用应由明确的输入动作触发,耗时工作放到后台。官方还提供构建结束后执行的 OnClick 等回调,不必把所有动作都写成内联查询。
GPUI 示例通过修改实体并通知变化来驱动界面;MyGo 这里则让控件在构建过程中回答输入查询,再由引擎决定是否重新构建。两者都根据状态描述界面,但更新协议不同。
元素可以重建,焦点和编辑状态怎样保留
MyGo 将本次构建的节点与跨帧控件状态分开保存。
私有 context.newElement 从节点池取得 node,为它计算 ID,再通过 ID 查找引擎中的 state。没有显式 key 时,ID 取决于父节点、子元素位置和类型;传入 c.Key(...) 时,使用父节点 ID 和 key 计算身份。焦点、编辑等状态由这个身份关联,而非依赖本次分配的节点地址。节点构造与身份
这意味着动态列表应使用稳定的业务 key。插入或重排项目后,位置会改变,而文件路径或记录 ID 仍可标识同一个条目。相同父节点下,key 还需要保持唯一。
对外返回的 ui.Element 则是带校验的临时句柄,由 owner、节点池槽位和 generation 组成。每次构建递增代数,使上一轮句柄失效,避免它误操作已被复用的节点。这里的生命周期甚至短于“一帧”:前面提到的同帧再次构建,也会让旧句柄过期。长期保存控件身份应使用 Handle,而非把 Element 放进应用状态。句柄与代数校验
Go 的垃圾回收可以管理对象内存,但不会自动判断一个句柄是否仍然代表当前控件。MyGo 的代数校验解决的正是这种逻辑生命周期问题。
从节点树到 GPU,中间有一份绘制列表
构建结束后,runFrame 调用 layoutTree 计算位置与尺寸,再执行 paint 和 host.present。MyGo 的 Flexbox、Grid 布局由自己的 Go 代码实现;GPUI 则使用前面提到的 Taffy。MyGo 布局入口
paint 并不直接对着 Metal 画按钮。它遍历元素,将填充、阴影、字形、图片和裁剪等操作写进 scene.Scene。这份按绘制顺序组织的数据就是 display list(绘制列表)。字形图集也挂在场景上,供渲染器使用。绘制入口、场景数据结构
| |
渲染器只需理解场景里的图形操作,无需知道它们来自按钮、列表还是编辑器。当前平台入口分别连接 Metal、Direct3D 11 和 OpenGL;macOS 的 Metal 渲染器把场景操作组织为实例化四边形,使用着色器绘制。Metal 渲染器
windowHost.present 还包含失败路径:GPU 渲染失败时释放并尝试重建渲染器;无法使用时,通过 internal/raster 在内存中绘制,再调用表面的 PresentPixels 或 PresentDamage 显示结果。失败后的重试带退避,避免每帧持续重建。渲染与回退
所以,MyGo 原生 UI 可以使用 GPU,也有软件绘制路径。“自己绘制”与“每次都在物理 GPU 上绘制”是两回事。
即时模式不要求空闲时不停重建界面
MyGo 的 engine.next 根据动画状态决定是否请求下一帧;窗口被遮挡时,可以暂停相关动画帧。没有输入、状态变化或动画需求时,不必仅为维持即时模式而连续执行视图函数。
它还区分两种更新:布局或应用状态改变时,重新构建、布局并绘制;只有自定义图形随时间变化时,Painter.AnimationFrame() 可以请求只重绘。后一条路径进入 repaintFrame,复用现有节点树,跳过视图构建和布局。帧调度与重绘
从这个实现可以看到,即时模式描述的是怎样表达和处理界面,并不要求应用永远以固定频率重新创建所有元素。
主线程调度与后台更新
MyGo 不使用 cgo,但仍调用原生库。macOS、Linux 通过 purego 动态加载库并调用函数,Windows 路径使用 syscall 等平台接口。以 macOS 为例,internal/darwin/objc.go 加载 Objective-C runtime 和 AppKit 等框架,并取得 objc_msgSend 入口。Metal 渲染器也通过这类机制调用系统 API。macOS 原生互操作
“无 cgo”降低了构建时对 C 工具链的依赖,不表示运行时没有平台库。例如 Linux 原生 UI 仍依赖 GTK,网页路径还需要 WebKitGTK。平台依赖说明
界面构建和平台操作在主线程执行。loop.go 用队列将其他 goroutine 的任务投递到主线程;后台加载完成后,Window.Update(fn) 会把状态修改和重绘安排过去。单独调用 Invalidate() 只请求更新,不能让任意 Go 变量的并发读写自动变得安全。主线程队列、状态更新入口
与 GPUI 对照后,差异在哪里
| 问题 | GPUI | MyGo 原生 UI |
|---|---|---|
| 应用状态 | 通过 App、Entity<T> 和上下文组织 | 普通 Go 结构体,框架不托管实体;视图直接读取和修改 |
| 界面描述 | Render 返回元素树 | 视图函数构建节点并查询输入 |
| 跨帧控件状态 | 实体与元素身份等机制 | 节点 ID 对应持久 state,临时 Element 带代数校验 |
| 布局 | Taffy | Go 编写的 Flexbox、Grid 实现 |
| 平台连接 | Rust 平台后端 | 平台 Surface 加 purego / Windows 原生接口 |
两者共同需要处理输入、身份、布局、文字和绘制,但语言改变后,约束也会改变。GPUI 必须让共享状态协作符合 Rust 所有权规则;MyGo 则通过状态 ID、临时句柄检查和主线程调度维护界面生命周期,Go 的垃圾回收不能替它解决这些问题。
为核对上述行为,本次在固定提交下、以 CGO_ENABLED=0 运行了 11 个现有定向测试,覆盖同帧动作只执行一次、句柄过期、控件状态保留、只重绘和遮挡调度,以及模拟 GPU 故障后的恢复,全部通过。这些测试使用无窗口测试引擎或替身渲染器,支持相应控制逻辑的判断;真实驱动表现和跨框架性能仍需要另做实验。构建与句柄测试、重绘测试、GPU 故障测试
什么情况下值得考虑这些方案
如果应用以桌面交互为主,需要较多自定义绘制,而且团队愿意用 Rust 编写界面,GPUI 值得做一个原型。编辑器、终端或数据查看工具这类界面,可以从中获得对布局、输入处理和绘制路径的直接控制。
如果主要目标是把已有 Web 产品做成桌面应用,复用现有页面和组件往往比重写界面更有价值。选型时应把交付速度、组件可用性和团队熟悉程度一起考虑,不能只看渲染技术。
如果核心逻辑已经使用 Go,MyGo 提供了另一种组合:保留 Web 前端,或用 ui 编写自己绘制的界面,并共享 Go 桌面 API。选择原生 UI 时,同样要验证组件、平台集成和后台状态更新方式;“Go 编写”和“GPU 绘制”并不能直接保证交付成本更低。
截至 2026 年 10 月 15 日,GPUI 主分支的 README 仍注明项目处于 1.0 之前,版本之间可能出现破坏性变更。文档和代码示例也需要对应版本:官网示例使用 Application::new(),本次查阅的主分支示例则通过 gpui_platform::application() 选择平台后端。固定版本的 README
当前 README 列出了 macOS、Linux / FreeBSD 和 Windows 的平台配置。例如,macOS 使用 Metal 绘制,Linux / FreeBSD 需要选择 Wayland 或 X11 窗口后端。具体应用仍应在目标平台验证中文输入法、字体、高 DPI、键盘导航和无障碍行为,窗口能显示只是最初一步。
准备上手时,可以先选一个真实的小界面:列表、搜索框和详情面板。让它跑通输入、状态更新和后台任务,再评估组件是否够用、平台行为是否符合预期。这个原型比单独跑通 Hello World 更能说明 GPUI 是否适合你的项目。