线程模型¶
lilt 只有一个 UI goroutine,它独占节点树:处理按键、推进动画、布局、绘制都在它上面。用户的 OnClick、OnChange 回调则相反,每次都在一个新的 goroutine 上跑,慢回调不会让动画卡住一帧。两边之间只有一座桥:ui.Do。
import (
"github.com/chhongzh/lilt"
"github.com/chhongzh/lilt/ui"
"github.com/chhongzh/lilt/widget"
)
生命周期¶
lilt 包只有三个函数和一个变量,都可以在任何 goroutine 上调用:
| 标识符 | 作用 |
|---|---|
lilt.Start(s, root) |
绑定屏幕 s,把 root 作为第一个页面推入(root 为 nil 时不推),启动 UI goroutine |
lilt.Pause() |
停止更新和绘制,状态原样保留 |
lilt.Stop() error |
结束 UI goroutine,丢掉所有页面和弹窗,返回让帧循环提前结束的错误 |
lilt.FPS |
帧率上限,默认 60,每帧都重新读取 |
stateDiagram-v2
[*] --> 未启动
未启动 --> 运行: Start(s, root)
运行 --> 暂停: Pause()
暂停 --> 运行: Start(任意参数)
运行 --> 未启动: Stop()
暂停 --> 未启动: Stop()
板子上的 main 一般是这样:先建好树,Start,然后主 goroutine 去读引脚,或者用 select {} 挂住。
func run(screen gfx.Screen, home ui.Node) {
lilt.Start(screen, home) // 从这里起,树归 UI goroutine
select {}
}
几个从代码里确认的细节:
Start只在未启动时生效。已经在运行时再调用什么也不做;暂停时调用会恢复运行,传入的参数被忽略。- 暂停期间按键照样进输入队列,恢复后再处理。队列能放 32 个事件,满了之后新来的直接丢掉,读按键的 goroutine 永远不会被阻塞。
- 帧之间传给动画的
dt最多按 0.1 秒算,卡顿或暂停之后动画不会一步跳到终点。 screen.Flush返回错误时帧循环自己结束,错误留给下一次Stop返回。Stop之后可以用新的屏幕和根节点重新Start。
一帧里各步骤的顺序见核心概念。
谁在哪个 goroutine 上跑¶
| 代码 | 运行在 |
|---|---|
组件实现的接口:Update、Draw、HandleKey、Measure、FocusChanged、CursorShape 等 |
UI goroutine |
ui.TextTarget、ui.TextInputMethod 的实现 |
UI goroutine |
OnClick、OnChange 回调 |
每次激活一个新的 goroutine |
ui.Do 的参数 |
调用者的 goroutine,但持有帧锁,帧不会同时进行 |
ui.Emit、ui.EmitRune、ui.EmitPointer、lilt.Start/Pause/Stop |
任意 goroutine |
lilt.Start 之前树还不属于任何人,在哪里建都可以。之后,除了 UI goroutine 自己,谁要改树、改组件属性、开关页面,都得包在 ui.Do 里。
ui.Do¶
ui.Do(fn) 等到两帧之间,拿到帧锁,在调用者自己的 goroutine 上执行 fn,返回时 fn 已经跑完。一帧从输入到 Flush 全程持有这把锁,所以 fn 里看到的树不会跑到一半,改动在下一帧一起生效。

status := widget.NewLabel("Version 1.2", widget.Font(ui.CurrentTheme.SmallFont),
ui.Padding(ui.CurrentTheme.RowPadding))
check := widget.NewListItem("Check updates", widget.OnClick(func() {
time.Sleep(time.Second) // the network; no frame waits for it
ui.Do(func() { status.SetText("Version 1.3 ready") })
}))
回调里的 time.Sleep 占的是回调自己的 goroutine,这一秒里列表照常滚动、光标照常动画;超过 150ms 后行尾出现加载点(见下一节)。只有最后改文字的那一句需要 ui.Do。
ui.Do 不能嵌套,也不能在 UI goroutine 上调用
帧锁是普通的 sync.Mutex,不可重入。fn 里再调用 ui.Do 会死锁;在 HandleKey、Update、Draw 这些本来就在 UI goroutine 上、本来就持有锁的地方调用,同样会死锁。这些地方直接改树即可。
几条实用的规则:
fn要短。它执行期间下一帧在等,不要在里面Sleep或等网络,把慢的部分放在ui.Do外面。- 相关的改动放进同一个
ui.Do。分成两次调用,中间可能插进一帧,屏幕上会出现只改了一半的状态。 - 回调之间共享的变量也可以靠
ui.Do保护:所有fn在同一把锁下串行执行。 - 暂停或还没
Start时没有帧持锁,ui.Do立即执行;暂停期间的改动在恢复后的第一帧显示出来。
回调与 widget.Control¶
所有可交互的组件都嵌入了 widget.Control,OnClick、OnChange、加载状态、失败回滚都由它统一处理。设置回调有两种写法,效果一样:
saves := 0
save := widget.NewButton("Save", widget.OnClick(func() { saves++ })) // 构造时用选项
save.OnClick(func() error { return errors.New("disk full") }) // 之后用方法,替换掉上面那个
OnClick 和 OnChange 是 Control 上的泛型方法,同名的 widget.OnClick、widget.OnChange 是对应的选项。选项按能力匹配,作用于任何嵌入了 Control 的节点;RadioGroup 不是节点,只能用方法 g.OnChange(...)。
回调的类型¶
OnClick 接受 widget.ClickFunc 约束里的两种函数:
| 类型 | 用途 |
|---|---|
func() |
不会失败的动作 |
func() error |
返回非 nil 表示失败,组件抖一下 |
OnChange 接受 widget.ChangeFunc,参数是旧值和新值,值的类型由组件决定,同样可以带 error 返回值:
| 组件 | 回调类型 | 不触发 OnChange 的 Set 方法 |
|---|---|---|
CheckBox |
func(from, to bool) |
SetChecked |
RadioGroup |
func(from, to int) |
Select |
Select |
func(from, to int) |
SetSelected |
Choice |
func(from, to int) |
Choose |
Toggles |
func(from, to uint32),第 i 位是第 i 个 Chip |
SetOn、SetMask |
Input |
func(from, to string) |
SetText |
回调类型和组件不匹配时,OnChange 在设置的那一刻 panic,例如给 CheckBox 传 func(from, to int),或者给没有值的 Button 设 OnChange。
执行顺序¶
一次激活里,OnChange(值确实变了才有)先跑,返回 nil 之后再跑 OnClick,两者在同一个 goroutine 上依次执行。OnChange 返回错误时 OnClick 不再执行。值没变的激活只跑 OnClick,例如在 Select 里重新选了原来那一项。
值类组件是乐观的:激活的那一帧就显示新值,回调在后台确认。
Choice、Toggles 和 RadioGroup 虽然也嵌入了 Control,但它们只报告 OnChange,设在它们身上的 OnClick 不会被调用。要响应“点了某一项”,把 OnClick 设在单个 Radio 或 Chip 上,Radio 的 OnClick 跑在组的 OnChange 之后。
Busy、Loading 与失败¶

cloud := widget.NewCheckBox("Cloud sync", false,
widget.OnChange(func(from, to bool) error {
time.Sleep(800 * time.Millisecond)
return errors.New("offline") // the box flips back
}))
vibrate := widget.NewCheckBox("Vibrate", true,
widget.OnChange(func(from, to bool) {
time.Sleep(50 * time.Millisecond) // under 150ms: no dots
}))
Cloud sync 的回调一秒后失败:先出加载点,然后行左右抖一下,方框退回未勾选。Vibrate 的回调 50ms 就返回,画面上只有方框的翻转。
| 状态 | 判定 | 表现 |
|---|---|---|
| Busy | 回调已开始、还没返回,Busy() 为真 |
组件忽略新的激活,外观不变 |
| Loading | Busy 超过 150ms,Loading() 为真 |
按钮的文字、行尾的控件、Chip 的横条换成跳动的加载点 |
| 成功 | 返回 nil |
回到正常;按钮如果显示过加载点,先亮一个勾 |
| 失败 | 返回 error |
组件抖一下;值类组件恢复旧值,按钮显示叉 |
150ms 的门槛(widget/control.go 里的 loadingDelay)是为了让很快的回调不闪一下加载点。回调返回后,状态的收尾通过 ui.Do 回到 UI goroutine 上做,所以回滚和 Set 一样不会再触发 OnChange。抖动是给组件的 OffsetX() 一个 -260 像素/秒的初速度,弹簧见动画。
代码里 Set 不触发 OnChange
OnChange 只报告用户的改动。代码里调用 SetChecked、SetText 这些方法改值,回调不会被调用,这样回滚、从存储里恢复设置都不会引起回调循环。需要同时处理两种来源时,把逻辑写成一个函数,Set 之后自己调用它。
各组件的状态细节见各自的页面,例如 Button 的勾和叉会停留 1.2 秒。