Racket 编程入门

第 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)