第 5 部分 · 对象与界面
第一个 GUI——窗口、按钮与点击计数器
写 GUI 程序,本质就是三件事——开一个窗口、往里放几个控件、给控件挂上回调。可程序跑过
show之后为什么不退出?按钮被点一下,又是怎么“自动”跑起一段代码的?
你写过的程序,大多是跑完就退出的命令行脚本。GUI 不一样:它跑起来之后不退出,停在屏幕上等你操作——等你点按钮、等你敲键盘,每一次操作都触发一段你事先写好的代码。这就是事件驱动。
GUI 的界面是一堆“东西”:窗口、文字、按钮,各有各的样子和行为。racket/gui 顺着这个事实,用面向对象的方式组织它们。每一个控件都是一个对象——这正是上一篇《面向对象》讲的 class 和 new 落地的场景。
这篇带你写出第一个能点、能数的窗口,顺手把 GUI 的三件套(窗口、控件、回调)一次摸透。
最小窗口:开一个 frame%
先开一个空窗口。在定义区写这几行:
#lang racket/gui
(define f (new frame% [label "我的第一个窗口"] [width 300] [height 200]))
(send f show #t)
按 Run,屏幕上弹出一个窗口,标题栏写着“我的第一个窗口”。你的第一个 GUI 程序跑起来了。
逐行看:
#lang racket/gui—— 注意不是#lang racket。racket/gui在基础语言之上加载了图形库,少了这一行,后面的frame%都不认识。frame%—— 顶层窗口类。Racket 的类用%结尾,这是个命名约定,看到%就知道它是类。frame%自带标题栏、能放大缩小、能关闭。(new frame% ...)——new创建一个类的实例(对象)。方括号里是初始化参数:[label "..."]设标题栏文字,[width 300]和[height 200]设初始大小。(send f show #t)——send是对象的方法调用:让对象f执行它的show方法,参数#t表示“显示出来”。
一个细节:对象创建出来不会自动显示,必须显式 show。把最后一行注释掉再 Run,窗口是创建了,但你看不到它——它在内存里躺着。
往窗口里放控件:控件即对象
空窗口没意思。往里放一行文字和一个按钮:
#lang racket/gui
(define f (new frame% [label "第一个 GUI"] [width 300] [height 150]))
;; 竖直面板:把里面的控件从上到下排成一列
(define panel (new vertical-panel% [parent f]))
;; 文本:message% 显示一行静态文字
(new message% [parent panel] [label "你好,Racket GUI"])
;; 按钮:button%,先不挂回调
(new button% [parent panel] [label "点我"])
(send f show #t)
按 Run,窗口里从上到下出现一行文字、一个按钮。
这里多了三个新类,但形状一样——都是 (new 类% [初始化参数 ...]):
vertical-panel%—— 容器,把子控件竖直排成一列。[parent f]表示它挂在窗口f里。message%—— 一行静态文字,[label "..."]是它显示的内容。button%—— 按钮,[label "..."]是按钮上的字。
每个控件都有一个 [parent ...],指明它挂在哪个容器里。窗口装面板,面板装按钮和文字,一层层套起来。这套父子关系,就是界面的结构。
看出来了吧:写 GUI,就是在不停地 new 出控件对象、用 [parent ...] 把它们组织成一棵树。没有什么别的魔法。
回调:按钮被点时,谁来响应
上面那个按钮点了没反应——因为你没告诉它被点时该干什么。给 button% 加一个 [callback ...]:
(new button% [parent panel] [label "点我"]
[callback (λ (b evt)
(displayln "被点了"))])
按 Run,每点一次按钮,交互区就打印一行“被点了”。
回调是一个函数,你把它交给按钮;按钮被点时,由 GUI 帮你调用它。注意它的签名——两个参数:
(λ (b evt) ...)
b—— 被点的那个按钮对象本身(一个button%实例)。evt—— 事件对象(一个control-event%),告诉你这是什么类型的事件,按钮点击的事件类型是'button。
多数时候你两个都用不上,但签名必须对:GUI 会按这个形状往里塞两个参数,少写一个就报错。这里你只在回调里 displayln,所以两个参数都忽略不计。
关键在于:这个函数你在代码里从不显式调用。它被“点按钮”这个事件触发。这就是事件驱动编程的写法——你定义好“发生了 X 就做 Y”,把 Y 挂上去,剩下的交给事件循环。
实战:一个点击计数器
把三件套凑齐,做一个能数数的程序:点一次按钮,文字显示当前点了几次。
#lang racket/gui
(define f (new frame% [label "点击计数器"] [width 300] [height 150]))
(define panel (new vertical-panel% [parent f]))
;; 计数:一个会被回调反复改写的变量
(define count 0)
;; 文本:auto-resize 让它在文字变长时自动放宽
(define msg (new message% [parent panel] [label "点击次数:0"] [auto-resize #t]))
;; 按钮:每点一次,count 加一,再更新文字
(new button% [parent panel] [label "点我"]
[callback (λ (b evt)
(set! count (+ count 1))
(send msg set-label (format "点击次数:~a" count)))])
(send f show #t)
按 Run,点按钮,文字从“点击次数:1”一直数上去。
两处新东西:
count定义在所有控件外面,回调里用set!改它。回调是个闭包,能看见并修改外层的count——这就是程序“记住”点击次数的方式。(send msg set-label ...)—— 调message%的set-label方法,把显示的文字换成新内容。format里的~a是占位符,会被count的值替进去。[auto-resize #t]让message%在文字变长时自动放宽,否则较长的文字可能显示不全。
这就是一个完整的 GUI 程序了:有状态(count)、有界面(窗口、文字、按钮)、有交互(回调改状态、再更新界面)。三件套齐活。
事件驱动:代码不再从上往下跑
回头想想这个程序和你以前写的命令行脚本,最大的区别在哪。
命令行脚本从上往下跑:定义 count、定义函数、调用函数、打印、退出。最后那行跑完,程序就结束了。GUI 程序不是这样:(send f show #t) 之后,你的代码看起来跑完了,但程序不退出——窗口还停在屏幕上,等你点。
原因是 show 背后启动了一个事件循环:程序进入一个“等事件 → 处理事件 → 再等”的循环。你点按钮,是一个事件;事件循环捕获它,找到按钮上挂的回调,调用它。回调跑完,循环继续等下一个事件。关掉窗口,循环退出,程序才真正结束。
所以回调的位置才那么特别——你只定义“被点时做什么”,调用它的不是你,是事件循环。这就是事件驱动的含义:程序的流程不再由代码的书写顺序决定,而是由“发生了什么事件”决定。
理解了这一点,你再看任何一个 GUI 程序,结构都长一个样:一堆控件对象组成界面,一组回调定义行为,一个事件循环把它们串起来。剩下的都是细节。
你已经有了一个能点、能数、能响应的窗口——窗口、控件、回调三件套都到齐了,再加上一个事件循环,整个 GUI 的骨架就立起来了。但你会发现,这个窗口长得不太像你日常用的那些界面。为什么 Racket 的 GUI 默认写不出“现代界面”?下一篇拆这个扣。
📷 待补运行截图:点击计数器窗口运行效果:一个按钮 + 一个显示次数的 message,点几次后次数累加(
racket-gui-click-counter.png)