Go-Gui:一套 无限接近 现代化UI的 GPU绘制界面组件框架

16 阅读24分钟

纯 Go、零 CGO:一个不用 WebView 的 Web 风格 GUI 框架

6.45 MB → 4.54 MB → 1.52 MB;一个矩形 6 个顶点、22 个 float;圆角、描边、阴影、毛玻璃、超采样、中文排版全部自己画。 这篇文章拆解 GoGUI 的渲染管线、布局引擎、字体图集,以及 9 个把我按在地上摩擦过的坑。

仓库地址:github.com/2252688138/… 规模:129 个 Go 源文件、约 3.8 万行(含示例与工具),MIT 协议。


0. 为什么又造一个 GUI 轮子

Go 写桌面程序,绕不开三条路:

方案代表优点代价
绑定原生控件walk、Win32 封装系统原生观感只能 Windows,样式几乎不可定制
前端 + WebViewWails、Tauri 思路前端生态成熟多一层运行时,包体大,IPC 有延迟
纯 Go 自绘Fyne、gio无浏览器内核,包体小控件与 Web 观感需要自己补

我想要的其实很具体:

  • 单文件、无运行库:拷一个 exe 就能跑,不依赖 WebView2(浏览器组件可选)、不依赖 VC++ 运行库;
  • Web 观感:圆角、渐变、投影、毛玻璃、flex 布局、深浅主题;
  • 中文要清晰:小字号横竖笔不能糊,输入法要能用;
  • 样式可编程:不要 CSS 字符串解析,要类型安全的链式 API;
  • 性能可控:静止时不重绘,滚动和动画不掉帧。

于是有了 GoGUI:ui 层完全平台无关,下面用 purego 动态加载 Win32 / WGL,用 OpenGL 3.3 做 2D 批处理渲染,x/image/font/opentype 做字形光栅化。没有一行 cgo。


1. 先看结果

真实运行截图(1180×760,深色主题;图片托管在仓库 ui/assets,若掘金未自动转存,可在本地同名路径找到原图):

仪表盘

图表组件

Markdown 渲染

浅色主题

这些界面里没有 HTML、没有 CSS、没有 WebView 参与主体渲染,全部是 OpenGL 画的。

最小可运行程序:

package main

import "gogui/ui"

func main() {
	ui.Run(ui.Options{
		Title:      "My App",
		Width:      1180,
		Height:     760,
		FontFamily: "Microsoft YaHei UI",
		Background: "#070a12",
	}, view)
}

func view() *ui.Node {
	return ui.Column(
		ui.Text("控制台").St(ui.St().Text(26, 700, "#eef2ff")),
		ui.Button("新建", func() { /* 点击回调 */ }).
			St(ui.St().Text(13, 600, "#fff").PadXY(18, 9).R(10).
				BgGrad("#6366f1", "#8b5cf6", 90).
				Shadow(18, 6, 0, "rgba(99,102,241,0.45)")),
	).St(ui.St().Padding(28).GapOf(20))
}
go run ./cmd/demo
# 无控制台窗口的发布构建
go build -ldflags "-H=windowsgui" -o app.exe ./cmd/demo

2. 架构:五层,零 cgo 是怎么做到的

cmd/gallery          示例应用(8 个可复用界面)
ui/                  声明式组件树 / 样式 DSL / 布局 / 事件 / 主题
internal/gpu         OpenGL 3.3 批处理渲染器 + GLSL
internal/text        opentype 字形光栅化 / 图集 / 度量
internal/win32       Win32 + WGL + COM 绑定(purego 动态加载)
internal/geom        几何基础类型

零 cgo 的关键是 github.com/ebitengine/purego:

// 伪代码:运行时从 DLL 取函数地址,再包装成 Go 函数
lib := purego.Dlopen("opengl32.dll", purego.RTLD_NOW)
var wglCreateContext func(hdc uintptr) uintptr
purego.RegisterLibFunc(&wglCreateContext, lib, "wglCreateContext")

代价也很真实:

  • 没有头文件和类型检查,函数签名、结构体布局、调用约定全靠手写;
  • COM 接口要自己排 vtable,槽位错一个就是访问违例(后面有个血案);
  • 一些 ABI 细节(比如 float 参数走 XMM 寄存器)Go 侧没有现成支持,得写汇编 trampoline。

收益是:整个程序只有一个 exe,不依赖 MinGW、不依赖 MSVC 运行库,交叉编译也不会因为 cgo 卡住。

2.1 渲染目标不是「画布」,是一组 Draw 指令

ui 层每帧把组件树编译成绘制指令:形状、文字、裁剪入栈/出栈。gpu.Renderer 顺序消费,遇到类型、字体、纹理或裁剪区域变化才 flush 一次,合并成尽可能少的 glDrawArrays。

// internal/gpu/renderer.go(简化)
func (r *Renderer) Draw(instrs []Instr) {
	for _, in := range instrs {
		switch in.Kind {
		case InstrShape:
			r.pushShape(in.Shape)
		case InstrText:
			r.pushText(in.Text)
		case InstrClipPush:
			r.clipSaved = append(r.clipSaved, in.Clip)
			r.setClip(in.Clip)
		case InstrClipPop:
			// 恢复到上一层裁剪(嵌套取交集)
		}
	}
}

2.2 一个矩形 = 6 顶点 × 22 float

形状顶点格式:

pos(2) | local(2) | color(4) | color2(4) | halfSize(2) | radius4(4) | params(4)

local 是相对矩形中心的坐标,halfSize 是半宽高,radius4 支持四角不同半径,params 打包了 feather / mode / strokeWidth / glow。一个矩形 6 个顶点,528 字节。

为什么不用「一个样式一个 draw call」?因为一屏几百个圆角卡片、标签、描边,draw call 会直接压垮驱动。批处理之后,一屏通常只有个位数到几十次提交。


3. 声明式 UI:每帧重建,不做 diff

GoGUI 的 view() 是一个纯函数:每次重绘都重新构造整棵组件树,框架重新 measure / arrange / paint。没有虚拟 DOM,没有 diff,没有 Key 复用(Key 只用于保存滚动偏移和交互状态)。

var current = "dashboard"

ui.Button("组件库", func() {
	current = "components"
	ui.RequestRedraw() // 下一帧重建 → 重新布局 → 重绘
}).St(ui.St().Text(13, 500, "#8b93ad").PadXY(14, 10).R(10))

这套模型能成立,靠三个设计:

1)状态外置。 组件不持有状态,状态在调用方的包级变量或结构体里。组件只是「当前状态的投影」。

2)回调延迟一帧。 点击回调不在事件回调里立刻执行,而是缓存到下一帧、基于最新组件树触发:

// ui/app.go 注释原文:
// 点击回调在下一帧基于最新组件树触发(避免旧指针失效),无需给节点设置 Key

否则闭包捕获的 *Node 在树重建后已经失效,轻则改错节点,重则悬空指针。

3)重绘请求是原子的。 ui.RequestRedraw() 可以从任意 goroutine 调用:

// 任意 goroutine 都可以调用;只置位,不跨线程碰 UI
func RequestRedraw() {
	for _, a := range snapshotApps() {
		a.redrawRequest.Store(true)
	}
}

主循环的判定条件大致是:

if a.dirty || a.redrawRequest.Swap(false) || needAnim || a.hoverProbe {
	// 重建 + 布局 + 绘制
}

静止时没有动画、没有输入,就不产生绘制帧。空闲轮询还会从 120Hz 降到 60Hz,避免消息泵空转吃 CPU。

3.1 事件路由:命中测试 + 冒泡

鼠标事件先做命中测试,从最深的节点往上冒泡到最近的可交互祖先。滚动容器的偏移按节点 Key 存在 App 里,所以:

ui.Scroll(content).K("scroll-main")

滚动容器必须给唯一 Key,否则两个滚动区域会共享偏移。


4. 布局引擎:Flutter 式 Constraints + 两阶段

布局分两步:

  1. measure:自底向上算尺寸(文字用 opentype 度量,图片只读文件头);
  2. arrange:自顶向下定位,分配 Grow 剩余空间。

约束模型是 Flutter 风格:

type Constraints struct {
	MinW, MaxW, MinH, MaxH float32
}

Row / Column 默认 AlignStretch,与 CSS align-items: stretch 一致。

4.1 flex 为什么要测两遍

第一遍按父可用尺寸量所有子项,统计固定项占用;第二遍把剩余主轴空间按 Grow 比例分给可伸缩子项,只重测没有显式主轴尺寸的子项。

// ui/layout.go(简化)
if growSum > 0 {
	free := availW - fixedMain
	if !horiz {
		free = availH - fixedMain
	}
	if free < 0 {
		free = 0
	}
	for _, c := range n.Children {
		if c.Style.Grow <= 0 || c.Style.PosAbs {
			continue
		}
		// 已经显式定尺寸的项不需要重测
		if horiz && (c.Style.Width > 0 || c.Style.WidthPct > 0 || c.Style.Basis > 0) {
			continue
		}
		share := free * c.Style.Grow / growSum
		measureNode(lc, c, Constraints{0, share, 0, availH}, n)
	}
}

这里有一个很容易踩的坑:测量子项时,主轴方向不要给 MaxW 上限。

// 测量约束:主轴方向不设上限(与 CSS flex 一致),交叉轴按父可用尺寸。
// 若主轴也给上限,子项内部的百分比尺寸会按父级满宽计算,
// 导致「自动宽」容器被撑大。
childC := func() Constraints {
	if horiz {
		return Constraints{0, 0, 0, availH} // MaxW = 0,不限
	}
	return Constraints{0, availW, 0, 0}
}

同理,百分比尺寸在 arrange 阶段还要再算一次——因为父容器可能在 AlignStretch 下被拉伸,measure 阶段的旧尺寸会让 Wp(100) 溢出。

4.2 内容型容器不受父高截断

Scroll 里的子项必须按内容自然伸展,否则内容会被截成视口高度,根本滚不动。Wrap / Grid / Masonry 也一样:

content := n.Kind == KWrap || n.Kind == KGrid || n.Kind == KMasonry
inScroll := parent != nil && parent.Kind == KScroll
if c.MaxH > 0 && h > c.MaxH && !st.Scrollable && !content && !inScroll {
	h = c.MaxH
}

4.3 不止 flex

构造器能力
Grid等宽列网格,固定列数或按列宽自适应
Wrapflex-wrap: wrap,行内支持 space-between
Masonry瀑布流,子项落入当前最矮的列
EqualRow / EqualCol主轴等分栏
Pick / BP按视口宽度切响应式分支
ui.Grid(tiles...).St(ui.St().ColWidth(180).GapOf(12))
ui.Wrap(tags...).St(ui.St().GapOf(8)).JustifyTo(ui.AlignSpaceBetween)
ui.Masonry(cards...).St(ui.St().ColsN(3).GapOf(12))
ui.Pick(narrow, wide, wide, wide)

5. 渲染:一个 SDF shader 解决圆角、描边、阴影

圆角矩形用解析式有向距离场(SDF):

float sdRoundBox(vec2 p, vec2 b, vec4 r) {
    r.xy = (p.x > 0.0) ? r.xy : r.zw;
    r.x  = (p.y > 0.0) ? r.x  : r.y;
    vec2 q = abs(p) - b + r.x;
    return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r.x;
}

片元着色器按 mode 分三路:

float d = sdRoundBox(vLocal, vHalf, vRadius);
if (glow > 0.0) {
    // 阴影:SDF 向外扩散 glow 像素,距离衰减近似高斯
    float k = clamp(-d / max(glow, 0.001), 0.0, 1.0);
    d = d + glow * (1.0 - k);
}

if (mode < 0.5) {
    alpha = 1.0 - smoothstep(-feather, feather, d);              // 填充
} else if (mode < 1.5) {
    alpha = 1.0 - smoothstep(-feather, feather,
            abs(d + strokeW * 0.5) - strokeW * 0.5);             // 描边
} else {
    alpha = (1.0 - smoothstep(-glow, glow, d)) * 0.9;            // 阴影
}

一次 draw 同时完成:填充、描边、阴影。阴影不是单独一张纹理,也不需要 blur 通道——CPU 侧把矩形外扩 spread + blur,片元里做距离衰减。

feather 默认 0.7 逻辑像素,配合 smoothstep 得到抗锯齿边缘:

r.appendQuad(rs, sh.Radius, sh.Fill, Paint{A: -1}, 0.7, 0, 0)

5.1 渐变与批处理

渐变用两个顶点色插值,vColor2.a >= 0 时启用:

vec4 col = vColor;
if (vColor2.a >= 0.0) {
    col = mix(vColor, vColor2, vT.x);
}
fragColor = vec4(col.rgb, col.a * alpha);

批次在四种情况下切分:

  • 指令类型变化(shape → text → image);
  • 字体变化(每张字形图集是独立纹理);
  • 图片纹理变化;
  • 裁剪区域变化(glScissor 是全局状态)。

裁剪嵌套取交集,同一矩形重复设置不会打断批次:

func (r *Renderer) setClip(c geom.Rect) {
	if r.clipOn && r.clipCur == c {
		return // 嵌套滚动容器常见,避免无意义 flush
	}
	r.flush()
	// glScissor 坐标原点在左下,需要翻转 y
}

5.2 顶点缓冲:孤儿化 + SubData

早期实现每帧 glBufferData(data) 重新分配整块缓冲,滚动时驱动频繁分配、还要和上一帧的绘制同步。现在只在容量不足时扩容,其余帧孤儿化后 glBufferSubData 覆盖:

func (r *Renderer) uploadVerts(vbo *uint32, capBytes *int, data []float32) {
	n := len(data) * 4
	glBindBuffer(GL_ARRAY_BUFFER, *vbo)
	if *capBytes < n {
		*capBytes = n + n/2 + 4096
	}
	// 孤儿化:解除与上一帧的依赖
	glBufferAlloc(GL_ARRAY_BUFFER, *capBytes, GL_STREAM_DRAW)
	glBufferSubData(GL_ARRAY_BUFFER, 0, data)
}

另外,顶点切片的初始容量从「按 60000 顶点硬上限预留」改成「按典型一屏预留」:

maxVertsShapeInitial = 8192
maxVertsTextInitial  = 20480

旧实现一开窗就分配 60000×22×4B ≈ 5.3MB + 60000×9×4B ≈ 2.2MB 的 Go 切片,而实际一屏通常只有几百到几千个顶点。


6. 文字:中文清晰度是自绘 GUI 的生死线

文字管线:

  1. x/image/font/opentype 读 TTF/TTC,取字形轮廓;
  2. 矢量轮廓光栅化成 8bit 覆盖率;
  3. 写进 RGBA8 字形图集(普通字形写 (v,v,v,255),Emoji 写真实颜色);
  4. 顶点带 mode,mode=1 直接输出纹理颜色;
  5. 每个字形一个四边形,UV 指向图集。

6.1 图集策略:256² 起步,按需扩容,LRU 回收

这是内存优化里收益最大的一刀。

旧版每个字体固定 2048² 图集 = 16MB。一个页面用十几种字号/字重,直接吃掉上百 MB。

现在:

阶段图集单字体内存
起步256²256 KB
装不下512²1 MB
常规上限1024²4 MB
光栅倍率 ≥ 1.75(超采样)2048²16 MB

字体实例上限 24 个,LRU 回收时同时释放对应纹理:

const maxFonts = 24

func (fc *fontCache) evictOldest() {
	// 找最久未使用的字体
	if f, ok := fc.fonts[oldKey]; ok {
		if fc.onEvict != nil {
			fc.onEvict(f) // 上层释放图集纹理
		}
		f.Close()
		delete(fc.fonts, oldKey)
	}
	// 折行缓存以 *Font 为键,字体关闭必须整体失效
	clearWrapMemo()
}

实测(GOGUI_MEMSTATS=1,gallery 运行时的示例输出):

[gogui] frames=120 heap=19.4MB inuse=20.6MB sys=27.7MB gc=2 fonts=13 atlas=3.2MB images=0

13 个字体实例,图集总共 3.2MB。换成旧的固定 2048²,光图集就是 13 × 16MB = 208MB。

6.2 只上传脏矩形

新字形出现时,不再重传整张图集,而是记录脏矩形,glTexSubImage2D 局部更新:

if x0, y0, x1, y1, dirty := f.TakeDirty(); dirty {
	r.uploadAtlasRegion(f, t, x0, y0, x1, y1)
}

整行范围直接上传(内存连续),否则逐行拷贝到复用缓冲再提交。滚动时新文字不断出现,这一刀把「连续全量上传」的卡顿消掉了。

6.3 字体文件走内存映射

msyh.ttc 这类字体文件用 MapViewOfFile 映射,数据由系统页缓存承载,不占 Go 堆。

6.4 字形 gamma:为什么自绘文字总是「发虚」

这是最隐蔽的一个坑。

浏览器(Skia / DirectWrite)在 sRGB 空间混合前会对覆盖率做 gamma 校正,等效于把中间覆盖率拉高:亮度 0.5 的像素在浏览器里看起来接近 0.7。

而自绘渲染通常是 sRGB 直混,同样的覆盖率就显得偏淡偏虚,小字号中文的横竖笔尤其明显。

解决办法是给覆盖率加一道 gamma:

float lum = dot(vColor.rgb, vec3(0.2126, 0.7152, 0.0722));
if (uTextGamma != 1.0 && lum > 0.5) {
	a = pow(a, uTextGamma);
}

只对「亮字」(暗底浅字)补偿;暗字浅底时线性混合反而更淡,不需要再拉。实测 1/1.8 ≈ 0.55 与浏览器观感最接近,可用 GOGUI_TEXT_GAMMA 覆盖。

6.5 Emoji 回退链

主字体缺字形时依次回退:

Microsoft YaHei UI → Segoe UI Symbol → GDI 彩色 Emoji(Segoe UI Emoji)

GDI 渲染到 32bpp DIB 后,第二遍白色蒙版补齐深色像素 alpha,再写进图集,顶点 mode=1 直出颜色。

6.6 一个输入框,半部输入法血泪史

自绘输入框不是「画个矩形 + 收键盘事件」那么简单。要处理:

  • 光标位置:按 rune 存,不能按 byte(中文一个字 3 字节);
  • 焦点:St().FocusOn() + 全局 focusKey,点空白处要清焦点;
  • 密码掩码:显示 •••,但光标位置仍按真实 rune 数算;
  • 编辑键:← → Home End Backspace Delete;
  • 回车提交:地址栏、搜索框需要 InputSubmit;
  • 输入法:组合串、候选窗位置、上屏去重。

输入法这三段消息必须全链路接:

case EvCompositionStart:
	a.imeText, a.imeCaret = "", 0
case EvComposition:
	a.imeText, a.imeCaret = ev.Text, ev.Caret   // GCS_COMPSTR
case EvCompositionEnd:
	a.imeText, a.imeCaret = "", 0
  • GCS_COMPSTR 组合串通过 ui.ComposingText() / ComposingCaret() 暴露,输入框用高亮块显示预编辑文本,此时光标不闪烁;
  • GCS_RESULTSTR 上屏时逐字符派发 EvChar,并且不再重复产生 WM_CHAR,否则一个汉字会输入两次;
  • 焦点输入框的位置经 ImmSetCompositionWindow / ImmSetCandidateWindow 同步给系统,候选窗才会跟着走,而不是永远停在窗口左上角。
// ui/widgets.go(简化)
if comp := ComposingText(); comp != "" && !password {
	cp := ComposingCaret()
	cr := []rune(comp)
	cells = append(cells, Text(string(cr[:cp])).St(St().Text(13, 400, ColText).BgC(ColBgSoft).R(3)))
	cells = append(cells, Text(string(cr[cp:])).St(St().Text(13, 400, ColText).BgC(ColHover).R(3)))
	showCaret = false // 组合期间不闪光标
}

7. 超采样:把「字体发虚」和「边缘发毛」一起解决

圆角 SDF 的抗锯齿只有 1 个像素的过渡带;小字号笔画更惨。整帧 SSAA 的思路很直接:

整帧先按 N 倍分辨率画进离屏 FBO,再平均降采样回窗口像素。

ui.SetSuperSample(ui.SSAuto) // DPI < 150% 时 2×,否则 1×(推荐)
ui.SetSuperSample(ui.SS200)  // 强制 2×
ui.SetSuperSample(ui.SSOff)  // 1:1,GPU 开销最低
// 另有 SS125 / SS150

降采样是 4×4 子带采样的 box 滤波:

vec2 base = uTexel * (uSpan * 0.125);
vec4 c = vec4(0.0);
for (int y = 0; y < 4; y++) {
    for (int x = 0; x < 4; x++) {
        vec2 o = vec2(float(x) * 2.0 - 3.0, float(y) * 2.0 - 3.0) * base;
        c += texture(uTex, vUV + o);
    }
}
c *= 0.0625;

逻辑布局、命中测试、鼠标坐标完全不变,只改变渲染分辨率。但这里有四个反直觉的细节。

7.1 边缘过渡带要按设备像素恒定

feather 原本按逻辑像素给 0.7。超采样 2× 后画布是 2 倍分辨率,0.7 逻辑像素在设备空间变成 1.4 像素,降采样后边缘反而更软——超采样白做了。

所以 CPU 侧要把过渡带除以倍率:

// 填充/描边的 SDF 边缘过渡带宽度按「设备像素」恒定
if r.ssaa > 1.01 && mode < 1.5 && feather > 0 {
	feather /= r.ssaa
}

片元里还得把下限从 0.5 降到 0.25,否则 2× 下收紧后的 0.35 又被抬回 0.5,锐度被吃掉一半。

7.2 字形 gamma 不能按倍率收敛

一开始想当然:超采样后平均了 N² 个样本,gamma 补偿应该弱化成 1-(1-g)/ss。结果 2× 下文字明显发灰发虚,观感还不如 1:1。

原因:降采样是子样本线性平均,gamma 补偿作用在平均之后并不失真。所以所有倍率统一使用同一个补偿值,SSAA 只提升几何/边缘精度,不改变笔画实度。

7.3 box 平均会让 1px 细节变「软」

数学上 box 降采样是正确的,但 1px 的横竖笔、1px 描边被平均后对比度下降,人眼会主观判断「还不如 1:1 清晰」。

所以降采样后补一道轻度 unsharp:

if (uSharpen > 0.0) {
    vec2 o = uTexel * uSpan;
    vec4 blur = (texture(uTex, vUV + vec2(o.x, 0.0)) +
                 texture(uTex, vUV - vec2(o.x, 0.0)) +
                 texture(uTex, vUV + vec2(0.0, o.y)) +
                 texture(uTex, vUV - vec2(0.0, o.y))) * 0.25;
    c = clamp(c + (c - blur) * uSharpen, 0.0, 1.0);
}

默认 0.4(GOGUI_SS_SHARPEN 可调),只补回被平滑掉的一小部分高频,既保留抗锯齿,又不出现白色光晕。

7.4 像素预算与半透明背景

  • 像素预算 8192×4320:窗口很大时自动降档 2× → 1.5× → 1.25× → 1×,避免 4K/5K 下填充率爆炸;
  • 降采样是整屏覆盖写,必须关掉混合,否则半透明背景会和目标里的旧内容再混一次;
  • 字形图集在光栅倍率 ≥ 1.75 时自动放宽到 2048²,否则 2× 下很容易写满触发「清空重用」,同帧 UV 失效导致缺字;
  • 运行时热切换会重建渲染目标与字形图集,适合放在设置页做清晰度选项。

8. 多媒体:不引 cgo,也能播 mp4

播放音视频不一定非要链接 FFmpeg 动态库。GoGUI 的做法是把 ffmpeg 当子进程用:

p, err := ui.NewMediaPlayer("movie.mkv") // 或 m3u8 直链
if err != nil { /* 未安装 ffmpeg / 无视频流 / 链接不可达 */ }
defer p.Close() // 释放 ffmpeg 进程与音频设备
p.Play()

ui.VideoPlayer("vp", p).St(ui.St().H(420))

内部是两条独立管线:

管线实现
视频ffmpeg -f rawvideo -pix_fmt rgba → 管道 → 帧环(上限 48MB / 2 秒)→ 按播放位置取帧 → glTexSubImage2D 局部更新纹理
音频第二个 ffmpeg 进程输出 s16le / 48k / 立体声 → WinMM waveOut

8.1 用音频当主时钟

视频帧率和音频采样率不可能精确对齐。有音轨时,播放器以 waveOut 已播样本数作为时钟(MediaClock),画面跟随音频位置取帧;倍速用 atempo 滤镜重建整条音频链路。

这比「用 time.Now() 估算播放位置」稳得多——后者跑几分钟就会和声音差出几百毫秒。

8.2 拖动进度条不要每个事件都重启 ffmpeg

最开始的实现是 mousemove 就 -ss 重启解码进程,结果拖动时 CPU 直接打满,画面还卡住。

现在的策略是 MediaScrub:

  • 拖动期间只记录目标位置,直接返回现有帧;
  • 等解码等待预算降到 80ms 以下,才允许抓一帧预览;
  • 松手后一次性 -ss 定位,只重启一次。

拖动因此不再卡顿,代价是拖动过程中预览帧不是完全跟手——这个取舍在播放器里是值得的。

8.3 没有 ffmpeg 就回退系统解码

ui.OpenMedia 先探测 PATH 里有没有 ffmpeg / ffprobe,没有则走 Windows Media Foundation:

os.Setenv("GOGUI_DECODER", "system")
src, _ := ui.NewMediaPlayer("movie.mp4")
fmt.Println(src.Info().Backend) // system
ffmpeg 后端系统解码后端
容器mp4/mkv/mov/avi/webm/flv/ts/m3u8系统支持的容器
编码基本全部系统已安装的 MFT
倍速支持(atempo)仅 1x
拖动预览单帧抓取保留上一帧

MF 的坑前面提过:IMFSourceReader 的所有调用必须串行在同一个锁定线程上(win32.MFRun),否则创建第二个 reader 时直接访问违例。


9. 组件化:把浏览器的一部分重新实现

自绘框架最容易「 demo 很漂亮,一用就少十个控件」。所以 ui/widgets.go 把常用控件都做了一遍,而且状态全部由调用方持有——组件本身无隐藏状态,拷走就能用。

9.1 Markdown 渲染器

ui.Stack(
	ui.Scroll(
		ui.Markdown("md", src, ui.MarkdownOptions{Width: 760}),
	).K("scroll-md"),
	ui.MarkdownLightbox(), // 点击图片后全屏预览
)

支持:标题 / 段落 / 粗体 / 斜体 / 删除线 / 行内代码 / 链接 / 图片 / 有序无序列表 / 任务列表 / 引用与 GitHub 提示块 / 代码块(行号 + 着色 + 复制 + 超 22 行折叠)/ 表格 / 分隔线 / [toc] / [tabs] / [details] / [card] / [badge] / [progress] / [gallery],以及数学公式与 Mermaid。

解析器、语法着色、关键字表都是自己写的,没有引入 goldmark / chroma。代码高亮覆盖 go / js / ts / py / json / bash / sql / html / css / yaml 十种语言,够用且体积极小。

9.2 图表:数据不变时只发 1 个 draw call

chart := ui.NewChart(ui.ChartLine, labels,
	ui.Series{Name: "本周", Values: week, Color: "#6366f1"},
	ui.Series{Name: "上周", Values: last, Color: "#22d3ee"},
)
chart.Smooth = true
chart.OnPick = func(series, index int) { /* 点击数据点 */ }

ui.ChartView("trend", chart).St(ui.St().H(300))

图表不是每帧重新光栅化,而是按「节点 Key + 尺寸 + 缩放 + 数据指纹」缓存一张 RGBA 纹理:

  • 数据不变:每帧 1 个 draw call,零 CPU 光栅化;
  • 缓存总预算 24MB,超出按最近使用淘汰,纹理随窗口销毁;
  • 悬浮高亮与点击命中复用同一份布局结果(chart.cache.hitS / hitI),回调里不用重算。

支持折线 / 面积 / 柱状 / 饼 / 环形 / 雷达 / 散点,堆叠、平滑(Catmull-Rom)、自定义轴范围、OnPick 点击回调。

9.3 其余开箱即用

分类组件
表单Input PasswordInput Checkbox Radio Switch Slider Progress Ring
展示Card Title Divider Tag Dot Avatar Badge KeyValue Empty Skeleton Spinner
导航Tabs TabsBar Breadcrumb Accordion
数据Table(固定/弹性列宽、斑马纹、整行点击)ListItem
浮层Modal MenuPanel TooltipOf Toast Banner
媒体VideoPlayer MusicPlayer(含频谱 / 封面 / 播放列表)
浏览器WebView + 自绘地址栏 / 状态行

ui/COMPONENTS.md 里有每个组件的文件归属和最小示例,按文件模块化引入即可。


10. 性能清单:省下来的都是帧时间

优化做法效果
按需重绘dirty / redrawRequest / animBusy / hoverProbe 四选一静止时不产生绘制帧
最小化不渲染窗口最小化直接跳过 frame后台不烧 GPU
视口外跳过滚动容器只遍历可见子树长列表滚动更稳
悬停去重鼠标移动只做命中测试,悬停目标没变不重绘鼠标划过不抖
VBO 复用容量足够时孤儿化 + SubData减少驱动分配与同步
图集局部上传脏矩形 glTexSubImage2D新字形不再全量重传
图片异步解码后台 goroutine 解码(并发上限 4),UI 线程只上传纹理大量图片不卡顿
字体 mmapMapViewOfFile 映射字体文件不占 Go 堆
媒体帧环上限48MB / 2 秒播放长视频内存可控

GOGUI_MEMSTATS=1 可以每 120 帧打印一次内存构成:

[gogui] frames=120 heap=19.4MB inuse=20.6MB sys=27.7MB gc=2 fonts=13 atlas=3.2MB images=0

11. 踩坑合集:那些文档里不会写的

11.1 DirectComposition 的 vtable 槽位是反的

为了让 WebView2 网页能被自绘菜单盖住,需要走 DComp 合成。dcomp 运行时的虚表把「动画版」排在「float 版」前面,和 SDK 头文件相反:

SetOffsetX(float) = 4
SetOffsetY(float) = 6
SetClip           = 14
矩形裁剪的左右上下/四角半径 float 版全在偶数槽(4/6/8/10/12…26)

用 3/5/7/9/11/13 会把 float 当动画指针解引用,崩在 addr=0x50。这个坑查了很久,因为崩溃现场完全看不出和 DComp 有关。

11.2 Go 链接器只接受一个 .rsrc 节

图标和版本信息必须由同一个 syso 生成:

too many .rsrc sections

tools/winres 直接输出 COFF .syso,把 RT_ICON + RT_GROUP_ICON + RT_VERSION 写进同一个 .rsrc 节,不需要 windres、不需要 cgo。

11.3 NSIS 脚本含中文必须 UTF-8 带 BOM

否则 makensis 直接报:

Bad text encoding

11.4 InstallDirRegKey 会污染目录页

它在 .onInit 之前就把旧目录塞进 $INSTDIR,导致「为所有用户安装」误用当前用户的旧路径。正确做法是只在范围切换时调 SetScope。

11.5 安装 Section 内不要再给 $INSTDIR 赋值

会覆盖用户在目录页手选的路径。自定义路径必须完整保留,范围切换只在两个默认目录之间切换。

11.6 WebView2 的 HWND 永远在 OpenGL 内容之上

HWND 托管模式下,网页像素不进入 GL 批次,所以:

  • 无法被菜单 / Modal 遮挡;
  • 不能做 3D 变换;
  • 圆角只能 SetWindowRgn 硬裁剪。

现在的策略是:浮层展开时临时隐藏网页;GOGUI_COMPOSE=1 时尝试 DComp 合成(实验性),失败自动回退。

11.7 WM_DROPFILES 没有 dragover

系统只在松手时投递消息,没有 dragenter/dragover,所以做不了悬停高亮。想要悬停反馈必须改用 OLE IDropTarget。

11.8 Media Foundation 必须串行在同一线程

IMFSourceReader 对跨线程调度极其敏感。所有调用(创建 / ReadSample / Seek / Release)都要通过 win32.MFRun 串行在一个锁定线程上执行,否则创建第二个 reader 时直接访问违例。

11.9 无标题栏窗口的 1px 白边

NoTitleBar 窗口要额外把 DWMWA_BORDER_COLOR 置为 DWMWA_COLOR_NONE,否则 DWM 会画一圈 1px 白色系统边框,在深色界面上非常刺眼。


12. 体积与发布:6.45MB → 1.52MB

build.ps1 把发布参数固定下来:

./build.ps1                 # gallery -> dist\gallery.exe
./build.ps1 -Target all     # gallery + demo + tools
./build.ps1 -Upx            # 再叠加 UPX -9
./build.ps1 -Dev            # 开发构建:保留符号 + 控制台日志
./build.ps1 -Installer      # 构建 + NSIS 安装器
./build.ps1 -Bump patch -Installer

实测(gallery,go1.26 windows/amd64):

构建方式体积说明
go build(默认)6.45 MB带符号表、调试信息、本机路径
build.ps14.54 MB−30%,去掉符号/调试信息与构建路径
build.ps1 -Upx1.52 MB−76%,启动自解压(偶发杀软误报)

关键参数:-trimpath -buildvcs=false、-s -w -H windowsgui -buildid=、CGO_ENABLED=0。

12.1 版本号只写一处

VERSION.txt 是唯一来源,一次构建注入三处:

位置注入方式读取方式
运行时-ldflags -X gogui/internal/version.Number=...version.String()
exe 文件属性tools/winres 生成 syso右键 → 详细信息
安装器makensis /DVERSION=...「应用和功能」里的版本
version.String()   // v0.1.0
version.Title()    // GoGUI v0.1.0(窗口标题)
version.Full()     // v0.1.0 · release · 1a2b3c4 · 2026-10-29T03:49:04Z

12.2 安装器支持两种范围

选项安装目录注册表权限
仅为我安装(默认)%LOCALAPPDATA%\Programs\GoGUIHKCU无需管理员
为所有用户安装%ProgramFiles%\GoGUIHKLM未提权自动 UAC 重入

静默安装:

GoGUI-0.1.0-setup.exe /S /currentuser /D=D:\Apps\GoGUI
GoGUI-0.1.0-setup.exe /S /allusers

/D 必须是最后一个参数,路径不加引号。


13. 跨平台:换后端不改业务代码

internal/gpu.Backend 已经抽象,新增平台只需要实现窗口层 + 渲染层:

平台窗口层渲染层状态
WindowsWin32OpenGL 3.3已完成
macOSCocoa + CAMetalLayerMetal / OpenGL 4.1规划
LinuxX11 / WaylandOpenGL / Vulkan规划
AndroidANativeWindow + EGLGLES 3规划
WebcanvasWebGL 2 + WASM适配层已在仓库

WASM 侧复用同一套 ui 树:internal/gpu/glport_wasm.go 提供 WebGL2 的 shader/program/buffer/texture 封装,ui/*_wasm.go 提供平台桩。浏览器示例目录尚未随主干发布,先把适配层留在仓库里。


14. 已知限制(先说清楚)

  • 富文本(行内混排 / 图文环绕)与竖排未实现;
  • 虚拟列表没有内置,长列表需要自己分页;
  • 视频解码依赖外部 ffmpeg / ffprobe 子进程(大分辨率软解 CPU 占用高),没有 ffmpeg 时回退 Windows Media Foundation;
  • WebView2 的 DComp 合成仍是实验性开关;
  • 当前生产可用平台只有 Windows。

15. 上手

git clone https://github.com/2252688138/Go-gui
cd Go-gui

go run ./cmd/gallery          # 8 个可复用界面案例
go run ./cmd/gallery --case table
go run ./cmd/demo

调试工具:

go run ./tools/layouttest     # 打印完整布局树(不需要开窗口)
go run ./tools/fonttest       # 打印字形度量
go run ./tools/atlasdump      # ASCII 打印字形图集

或者在运行参数里设置 DumpLayout: "layout.txt",首帧后导出布局树并退出。

组件清单与「按文件模块化引入」的说明在 ui/COMPONENTS.md:每个组件只依赖 internal/* 和基础节点,拷进新项目即可用。


最后:什么时候该用,什么时候别用

适合:

  • 想要单文件、无运行库的 Windows 工具;
  • 需要 Web 风格的深色界面,但不想背一个浏览器内核;
  • 对启动速度、包体、内存有要求;
  • 愿意接受「控件要自己攒」的代价。

不适合:

  • 需要复杂富文本编辑、表格公式、无障碍支持;
  • 团队更熟悉前端,且能接受 WebView 的体积;
  • 需要立刻上 macOS / Linux 生产环境。

自绘 GUI 的本质是把浏览器那一层重新实现一遍,但只实现你需要的部分。它不轻松,但每一行都在掌控之中——这大概就是造轮子的全部乐趣。

如果这个项目对你有帮助,欢迎 Star:github.com/2252688138/…

有问题也可以在仓库提 Issue,或者加 QQ 群交流(见仓库作者信息)。