前言
「交互式图形编程」这个说法容易让人以为是一门独立技术,其实它指的是把图形绘制和用户输入绑在一起:鼠标一点就画、拖动就跟着走、键盘一按就变色。在 Python 里做这件事有两条最省事的路子:一是标准库tkinter里的Canvas(画布)控件,二是标准库turtle(海龟绘图)。两者都是标准库,不需要pip install;但请注意,tkinter 底层依赖 Tcl/Tk 运行库,多数官方 Windows / macOS 安装包会随解释器一起提供,个别精简安装可能缺少。
标题里的「(一)」是系列编号,不影响本文的自足性——本文独立成篇,只讲 tkinter 画布这条线。turtle 更适合教学入门,本文在最后一节做个对比。
初学者最常见的误解是「画出来的图形是个对象,像变量一样能直接改」。实际上画布上的每个图形只是一个由整数 ID 标识的显示项(item),你拿到的不是你创建的那个int之外的任何东西,要改它的坐标或颜色,得带着这个 ID 回去找画布改。理解了「ID + 画布」这个模型,交互式绘图就顺了。
一、Canvas 的基本用法:创建、绘制、改属性
tkinter.Canvas(master=None, cnf={}, **kw)是标准库里的画布控件。它的创建方法签名为create_line(*args, **kw)、create_oval(*args, **kw)、create_rectangle(*args, **kw)、create_text(*args, **kw)等,返回值是该项的 ID。
# 适用于 Python 3.8+(tkinter 属标准库)
import tkinter as tk
root = tk.Tk()
root.title("画布基础")
canvas = tk.Canvas(root, width=400, height=300, bg="white")
canvas.pack()
# 线的坐标成对出现:x1, y1, x2, y2 ...
line_id = canvas.create_line(20, 20, 200, 120, fill="blue", width=3)
# 椭圆由一个外接矩形决定:左上角 (x1,y1)、右下角 (x2,y2)
oval_id = canvas.create_oval(40, 40, 120, 120, fill="orange")
# 矩形同理
rect_id = canvas.create_rectangle(220, 40, 340, 140, outline="green", width=2)
# 文本:anchor 决定 (x,y) 是文本框的哪个角
text_id = canvas.create_text(200, 240, text="hello canvas", anchor="center")
root.mainloop()坐标全部以画布左上角为原点,x 向右、y 向下,单位是像素。
要修改已经画出来的项,用itemconfigure(tagOrId, cnf=None, **kw);改坐标可以用coords(tagOrId, ...);移动可以用move(tagOrId, xAmount, yAmount),其中xAmount/yAmount是相对位移。
# 适用于 Python 3.8+(tkinter 属标准库)
canvas.itemconfigure(oval_id, fill="red") # 换填充色
canvas.move(oval_id, 10, 0) # 向右平移 10 像素
canvas.coords(line_id, 0, 0, 300, 300) # 直接改写整组坐标
canvas.delete(rect_id) # 删除指定 ID 的项注意delete(*tagOrIds)收的是可变参数,可以一次删多个;find_withtag/gettags则用来做「按名字批量管理」。给多个项设同一个 tag,就能一次性改属性、一次性删除。
二、鼠标与键盘:把输入接进来
交互的关键在于bind(sequence=None, func=None, add=None)。它把「事件序列字符串」和处理函数连起来,处理函数一定会收到一个 event 对象,里面有坐标、按键等信息。
| 事件序列 | 触发条件 | 常用字段 |
|---|
"<Button-1>" | 鼠标左键按下 | event.x、event.y |
"<B1-Motion>" | 按住左键拖动 | event.x、event.y |
"<ButtonRelease-1>" | 左键松开 | event.x、event.y |
"<Motion>" | 鼠标移动(不必按键) | event.x、event.y |
"<KeyPress>" | 键盘按下 | event.keysym、event.char |
event.x/event.y是相对绑定控件的坐标,如果你把事件绑在 canvas 上,那就是画布内的坐标,可以直接拿去当绘图坐标。
三、完整实例:一个能画线、能拖动的画板
这个例子做到三件事:拖动鼠标画自由曲线、点按钮清空、按空格键切换画笔颜色。
# 适用于 Python 3.8+(tkinter 属标准库)
import tkinter as tk
from tkinter import ttk
def main():
root = tk.Tk()
root.title("交互式画板")
# 画笔状态放可变容器里,闭包修改时不需要 nonlocal 的语法纠缠
state = {"last": None, "color": "black", "width": 2}
canvas = tk.Canvas(root, width=480, height=320, bg="white")
canvas.pack(fill="both", expand=True)
def on_press(event):
# 记下起点,本次拖动结束后再和终点连成一段线
state["last"] = (event.x, event.y)
def on_drag(event):
last = state["last"]
if last is None:
state["last"] = (event.x, event.y)
return
canvas.create_line(
last[0], last[1], event.x, event.y,
fill=state["color"], width=state["width"],
capstyle="round", smooth=True,
)
state["last"] = (event.x, event.y)
def on_release(event):
state["last"] = None
def clear():
canvas.delete("all") # 删掉画布上所有项
def switch_color(event=None):
order = ["black", "red", "blue", "green"]
i = order.index(state["color"])
state["color"] = order[(i + 1) % len(order)]
canvas.bind("<Button-1>", on_press)
canvas.bind("<B1-Motion>", on_drag)
canvas.bind("<ButtonRelease-1>", on_release)
bar = ttk.Frame(root)
bar.pack(fill="x")
ttk.Button(bar, text="清空", command=clear).pack(side="left", padx=6, pady=6)
ttk.Label(bar, text="(按空格切换颜色)").pack(side="left")
root.bind("<space>", switch_color)
root.mainloop()
if __name__ == "__main__":
main()逐行推演要点:<B1-Motion>只在「鼠标左键按下的同时移动」时触发,这正好对应画线的拖动语义;如果误用<Motion>,不按键轻轻扫过画布也会画线。on_drag里每收到一个事件就画一小段线,并把state["last"]更新成当前点,所以整条曲线其实是由很多短线段拼出来的——smooth=True让相邻线段之间做样条平滑,看起来更顺。canvas.delete("all")里的"all"是 tkinter 内置的特殊 tag,代表画布上全部项。
四、让图形动起来:after() 定时器
动画在 GUI 里没有「循环」可用,标准做法是用after(ms, func=None, *args, **kw)让事件循环每隔一段时间回调一次自己。
# 适用于 Python 3.8+(tkinter 属标准库)
def main():
root = tk.Tk()
canvas = tk.Canvas(root, width=400, height=260, bg="white")
canvas.pack()
ball = canvas.create_oval(20, 20, 60, 60, fill="tomato")
speed = {"dx": 4, "dy": 3}
def step():
canvas.move(ball, speed["dx"], speed["dy"])
x1, y1, x2, y2 = canvas.coords(ball)
# 碰到边界就反向
if x1 <= 0 or x2 >= canvas.winfo_width():
speed["dx"] = -speed["dx"]
if y1 <= 0 or y2 >= canvas.winfo_height():
speed["dy"] = -speed["dy"]
root.after(16, step) # 大约每 16 毫秒一次
step()
root.mainloop()
if __name__ == "__main__":
main()after(16, step)是「约 60 帧每秒」的常见写法,但它只管「至少等 16 毫秒」,不保证准时:系统忙时回调会被推迟。所以做物理模拟要按真实流逝的时间算,而不是按「每帧固定移动几个像素」。winfo_width()/winfo_height()返回控件当前实际尺寸,窗口被拉大后边界判断会自动跟着变。
五、和 turtle 的取舍
| 对比项 | Canvas | turtle |
|---|
| 定位 | 通用绘图控件,可嵌进任意窗口 | 面向教学的海龟画笔 |
| 坐标模型 | 绝对坐标(左上原点) | 相对运动(前进 / 转向) |
| 交互 | bind接鼠标键盘,自由度高 | 也能绑事件,但以绘图教学为主 |
| 动画 | 自己用after()驱动 | 有内置的动画开关 |
| 适合 | 画板、图表、小游戏 | 讲循环、递归、几何图形 |
结论:要做「用户能操作的图形程序」,用Canvas;要做「画个分形树给学生看」,用turtle更快。
常见坑点
- ❌ 拖动时用
"<Motion>"绑画线。
✅ 用"<B1-Motion>",它只在按住左键拖动时触发,才对应用户「画线」的意图。
- ❌ 把 canvas 的项当成可以随意改属性的对象,直接写
oval.fill = "red"。
✅ 项由整数 ID 标识,必须通过itemconfigure/coords/move拿着 ID 去操作。
- ❌ 动画用
while True加time.sleep()。
✅ 那会卡死事件循环;用after()让事件循环来调度下一次。
- ❌ 以为
after(16, f)就是精确的 60 帧。
✅ 它只是「至少延迟 16 毫秒」,会漂移;做物理模拟要按真实时间差计算位移。
- ❌ 忘记保持对
StringVar/IntVar的引用,控件联动莫名失效。
✅ 变量对象必须保持引用,被回收后底层 Tcl 变量随之消失。
- ❌ 在子线程里调用
create_line等画布方法。
✅ 画布属于主线程,跨线程绘图行为未定义;把坐标放进队列,主线程用after()取出来再画。
- ❌ 一个容器里既有
pack的子控件又有grid的子控件。
✅ 同一容器只用一种几何管理器,混用可能导致布局反复重算而卡住。
总结
| 要点 | 结论 |
|---|
| 画布控件 | tkinter.Canvas,创建方法返回整数 ID |
| 绘制 | create_line/create_oval/create_rectangle/create_text |
| 修改 | itemconfigure改属性、coords换坐标、move平移、delete删除 |
| 交互 | bind(事件序列, 处理函数),处理函数收到 event 对象 |
| 动画 | 用after()驱动,不用while+sleep |
| 备选 | turtle适合教学,Canvas适合做应用 |
交互式图形编程的门槛不在画图,而在「事件驱动的绘制节奏」:输入来了才画、动起来靠after()反复调度、所有绘制都在主线程。把这三条记住,你就能用标准库做出像模像样的画板和小动画了。