参考资料
练习题 icon lost
交流讨论
笔记
img lost


1. 概述

1.1 Tkinter是什么?

Tkinter是Python自带的GUI库,Python的IDEL就是Tkinter的应用实例。Tkinter可以看作是Tk和inter的合体。词根inter之意不言自明,而Tk则是工具控制语言Tcl(Tool Command Language)的一个图形工具箱的扩展,它提供各种标准的GUI接口。

和其他GUI库相比,Tkinter有一个与生俱来的优势:无需安装就可以直接使用。当然,也有很多人——曾经我也是其中之一,认为这恰是Tkinter的唯一优点。不过,后来我改变了看法。相较于wx或Qt多如牛毛的控件和组件,Tk只用十几个控件就可以满足几乎所有的应用需求,用最低的学习成本、最简单的方式解决问题,这不正是实用至上主义的典范吗?

从实用主义的角度看,Qt的博大精深就是尾大不掉,Wx的精致严谨就是循规蹈矩。如果你正在寻找一款用于桌面程序设计的GUI库,并且只打算花一个小时学会使用它,那么就请选择Tkinter吧。这款以学习曲线平缓和易于嵌入为特定目标而设计的GUI库,也许正是你苦苦追寻的真爱。

1.2 Tkinter的组织架构

Tkinter模块提供了一个名为Tk的窗体类、十几个基本控件,多个类型对象,若干常量,以及一个可选主题的控件包ttk和各种对话框组件。可以把ttk理解为增强的控件包,它提供了更多、更美观的控件。Tkinter模块的组织架构如下图所示。

在这里插入图片描述

对于简单的应用需求,只需要像下面这样导入模块就可以了。

from tkinter import *

由于Tkinter模块在其__init__.py脚本中将可选主题的控件包ttk和各种对话框组件从__all__里面排除了,上面的模块导入方式只导入了Tk类、基本控件、类型对象和常量。如果应用程序需要打开文件、保存文件等对话操作,或者需要更多更个性化的控件,就需要像下面这样导入模块了。

from tkinter import *
from tkinter import ttk, filedialog, messagebox

2. 快速体验

2.1 GUI设计的一般流程

用Tkinter写一个桌面应用程序,只需要三步:

  1. 创建一个窗体
  2. 把需要的控件放到窗体上,并告诉它们当有预期的事件发生时就执行预设的动作
  3. 启动循环监听事件

无论这个程序有多么简单或多么复杂,第1步和第3步是固定不变的,设计者只需要专注于第2步的实现。下面这段代码实现了一个最简单的Hello World桌面程序。

from tkinter import *

root = Tk() # 1. 创建一个窗体
Label(root, text='Hello World').pack() # 2. 添加Label控件
root.mainloop() # 3. 启动循环监听事件

不同于wx用frame表示窗体,我习惯用root作为窗体的名字。当然,你也可以用window或其他你喜欢的名字,但不要用frame,因为Tkinter为frame赋予了其他的含义。

在这里插入图片描述

代码运行界面如上图所示。弹出来的程序窗口既小且丑,就像一个新生的婴儿,但这的确是一个完整的桌面应用程序。

2.2 控件布局

所谓控件布局,就是设置控件在窗体内的位置以及填充、间隔等属性。在Hello world程序中,我使用了pack方法来设置控件Label的布局,并把它们写成了链式调用的形式。如果将控件的创建和布局分写成两行的话,代码的可读性会更好一点。

pack方法是Tinkter最常用的布局手段,功能强大,参数众多,这里只介绍pack的几个主要参数。下表中用到了Tkinter定义的常量,比如,TOP就是tkinter.TOP,等价于字符串’top’,YES就是tkinter.YES,等价于字符串’yes’。

参数说明
side布局方向,可选项:TOP、BOTTOM、 LEFT、RIGHT,缺省默认TOP
anchor对齐方式,可选项:E、W、N 、S、NE、NW、SE、SW、CENTER,缺省默认CNETER
expand是否占用剩余可用空间作为控件的可用空间,可选项:NO、YES,缺省默认NO
fill控件在指定方向上扩展至填满自己的可用空间,可选项:X、Y、BOTH、NONE,缺省默认NONE
padx水平方向上控件与可用空间的留空距离,以像素表示,缺省默认0
pady垂直方向上控件与可用空间的留空距离,以像素表示,缺省默认0

下面的代码创建了标签和按钮两个控件,使用pack方法使其上下排列,同时还演示了窗口标题、窗口图标和窗口大小的设置方式。代码中用到了.ico格式的图标文件,想要运行这段代码的话,请先替换成本地文件。

from tkinter import *

root = Tk()
root.title('最简单的桌面应用程序') # 设置窗口标题
root.geometry('480x200') # 设置窗口大小
root.iconbitmap('res/Tk.ico') # 设置窗口图标

label = Label(root, text='Hello World', font=("Arial Bold", 50))
label.pack(side='top', expand='yes', fill='both') # 使用全部可用空间,水平和垂直两个方向填充
btn = Button(root, text='关闭窗口', bg='#C0C0C0') # 按钮背景深灰色
btn.pack(side='top', fill='x', padx=5, pady=5) # 水平方向填充,水平垂直两个方向留白5个像素

root.mainloop()

代码运行界面如下图所示,看上去比第一个Hello World程序要顺眼得多。在这个界面上,虽然按钮的名字叫做“关闭窗口”,但是目前还不能对点击操作做出任何反应。

在这里插入图片描述

控件布局除了pack方法外,还有place方法和grid方法,后面会有详细的说明。

2.3 事件驱动

一个桌面程序不单是控件的罗列,更重要的是对外部的刺激——包括用户的操作做出反应。如果把窗体和控件比作是桌面程序的躯体,那么响应外部刺激就是它的灵魂。Tkinter的灵魂是事件驱动机制:当某事件发生时,程序就会自动执行预先设定的动作。

事件驱动机制有三个要素:事件、事件函数和事件绑定。比如,当一个按钮被点击时,就会触发按钮点击事件,该事件如果绑定了事件函数,事件函数就会被调用。下面的代码演示了如何将按钮点击事件和对应的事件函数绑定在一起。

from tkinter import *

def click_button():
    """点击按钮的事件函数"""
    
    root.destroy() # 调用root的析构函数

root = Tk()
root.title('最简单的桌面应用程序')
root.geometry('640x320')
root.iconbitmap('res/Tk.ico')

label = Label(root, text='Hello World', font=("Arial Bold", 50))
label.pack(side='top', expand='yes', fill='both')
btn = Button(root, text='关闭窗口', bg='#C0C0C0', command=click_button) # 用command参数绑定事件函数
btn.pack(side='top', fill='x', padx=5, pady=5)

root.mainloop()

现在点击按钮就可关闭窗口了。你看,事件驱动机制是多么的简单和美妙!当然,绑定事件和事件函数的方法不止有本例用到的command,后面还会谈到bind和bind_class两种方式。

2.4 面向对象使用Tkinter

对于上一段代码,熟悉OOP的读者会注意到事件函数click_button中使用了root这个全局变量。从语法和编程规范的角度看,这样做没有任何问题。不过,当桌面程序面对稍微复杂的业务逻辑时,势必要大量使用全局变量,这给程序的安全带来了隐患,同时也不便于程序的维护。下面的代码以面向对象的方式设计了一个按钮点击计数器。

from tkinter import *

class MyApp(Tk):
    """继承Tk,创建自己的桌面应用程序类"""
    
    def __init__(self):
        """构造函数"""
        
        super().__init__()
        
        self.title('按钮点击计数器')
        self.geometry('320x160')
        self.iconbitmap('res/Tk.ico')
        
        self.counter = IntVar() # 创建一个整型变量对象
        self.counter.set(0) # 置其初值为0
        
        label = Label(self, textvariable=self.counter, font=("Arial Bold", 50)) # 将Label和整型变量对象关联
        label.pack(side='left', expand='yes', fill='both', padx=5, pady=5)
        
        btn = Button(self, text='点我试试看', bg='#90F0F0')
        btn.pack(side='right', anchor='center', fill='y', padx=5, pady=5)
        
        btn.bind(sequence='<Button-1>', func=self.on_button) # 绑定事件和事件函数
    
    def on_button(self, evt):
        """点击按钮事件的响应函数, evt是事件对象"""
        
        self.counter.set(self.counter.get()+1)

if __name__ == '__main__':
    app = MyApp()
    app.mainloop()

这段代码用到了整型对象IntVar,这是Tkinter独有的概念。当类型对象被改变时,与其关联的控件文本内容会自动更新。借助于类型对象和控件之间的关联,用户可以方便地在其他线程中更新UI。

在这里插入图片描述

代码运行界面如上图所示。每点击一次按钮,计数器自动加1并显示在Lable控件上。请注意,这个例子并没有使用command绑定按钮事件,而是使用了bind方法将鼠标左键点击事件和事件函数on_button绑定在一起。这个用法要求事件函数on_button接受一个事件对象evt作为参数,该参数提供了和事件相关的详细信息。不难理解,command适用于绑定控件自身的事件,bind适用于绑定鼠标和键盘事件。


3. 事件和事件对象

3.1 鼠标事件

Tkinter支持的鼠标事件如下所列。

  • <Button-1> - 左键单击
  • <Button-2> - 中键单击
  • <Button-3> - 右键单击
  • <Button-1> - 左键单击
  • <B1-Motion> - 左键拖动
  • <B2-Motion> - 中键拖动
  • <B3-Motion> - 右键拖动
  • <ButtonRelease-1> - 左键释放
  • <ButtonRelease-2> - 中键释放
  • <ButtonRelease-3> - 右键释放
  • <Double-Button-1> - 左键双击
  • <Double-Button-2> - 中键双击
  • <Double-Button-3> - 右键双击
  • <Motion> - 移动
  • <MouseWheel> - 滚轮
  • <Enter> - 进入控件
  • <Leave> - 离开控件

下面的代码演示了如何绑定鼠标事件,以及如何使用鼠标事件对象。

from tkinter import *

class MyApp(Tk):
    """继承Tk,创建自己的桌面应用程序类"""
    
    def __init__(self):
        """构造函数"""
        
        super().__init__()
        
        self.title('鼠标事件演示程序')
        self.geometry('480x200')
        self.iconbitmap('res/Tk.ico')
        
        self.info = StringVar()
        self.info.set('')

        label = Label(self, textvariable=self.info, font=("Arial Bold", 18))
        label.pack(side='top', expand='yes', fill='both')
        
        btn = Button(self, text='确定', bg='#C0C0C0')
        btn.pack(side='top', fill='x', padx=5, pady=5)

        label.bind('<Enter>', self.on_mouse)
        label.bind('<Leave>', self.on_mouse)
        label.bind('<Motion>', self.on_mouse)
        label.bind('<MouseWheel>', self.on_mouse)
        btn.bind('<Button-1>', self.on_mouse)
        btn.bind('<Button-2>', self.on_mouse)
        btn.bind('<Button-3>', self.on_mouse)
        btn.bind('<B1-Motion>', self.on_mouse)
        btn.bind('<Double-Button-1>', self.on_mouse)
        btn.bind('<Double-Button-3>', self.on_mouse)
    
    def on_mouse(self, evt):
        """响应所有鼠标事件的函数"""
        
        if isinstance(evt.num, int):
            self.info.set('事件类型:%s\n键码:%d\n鼠标位置:(%d, %d)\n时间:%d'%(evt.type, evt.num, evt.x, evt.y, evt.time))
        else:
            self.info.set('事件类型:%s\n鼠标位置:(%d, %d)\n时间:%d'%(evt.type, evt.x, evt.y, evt.time))

if __name__ == '__main__':
    app = MyApp()
    app.mainloop()

这段代码在标签控件和按钮控件上绑定了多种鼠标事件,并把这些事件绑定到了同一个事件函数上,事件函数被调用时会传入事件对象作为参数。借助于事件对象可以获得事件类型、鼠标位置、触发时间等详细信息。

在这里插入图片描述

当鼠标进入或离开标签控件、在标签控件上移动鼠标或滚动滚轮、在按钮控件上点击鼠标按键,相应的事件类型和信息就会显示在标签上。代码运行界面如上图所示。

3.2 键盘事件

Tkinter支持的鼠标事件如下所列。

  • <Return> - 回车
  • <Cancel> - Break键
  • <BackSpace> - BackSpace键
  • <Tab> - Tab键
  • <Shift_L> - Shift键
  • <Alt_R> - Alt键
  • <Control_L> - Control键
  • <Pause> - Pause键
  • <Caps_Lock> - Caps_Lock键
  • <Escape> - Escapel键
  • <Prior> - PageUp键
  • <Next> - PageDown键
  • <End> - End键
  • <Home> - Home键
  • <Left> - 左箭头
  • <Up> - 上箭头
  • <Right> - 右箭头
  • <Down> - 下箭头
  • <Print> - Print Screen键
  • <Insert> - Insert键
  • <Delete> - Delete键
  • <F1> - F1键
  • <Num_Lock> - Num_Lock键
  • <Scroll_Lock> - Scroll_Lock键
  • <Key> - 任意键

下面的代码演示了如何绑定键盘事件,以及如何使用键盘事件对象。

from tkinter import *

class MyApp(Tk):
    """继承Tk,创建自己的桌面应用程序类"""
    
    def __init__(self):
        """构造函数"""
        
        super().__init__()
        
        self.title('键盘事件演示程序')
        self.geometry('480x200')
        self.iconbitmap('res/Tk.ico')
        
        self.info = StringVar()
        self.info.set('')

        self.info = StringVar()
        self.info.set('')

        self.lab = Label(self, textvariable=self.info, font=("Arial Bold", 18))
        self.lab.pack(side='top', expand='yes', fill='both')
        self.lab.focus_set()
        self.lab.bind('<Key>', self.on_key)
        
        self.btn = Button(self, text='切换焦点', bg='#C0C0C0', command=self.set_label_focus)
        self.btn.pack(side='top', fill='x', padx=5, pady=5)
    
    def on_key(self, evt):
        """响应所有键盘事件的函数"""
        
        self.info.set('evt.char = %s\nevt.keycode = %s\nevt.keysym = %s'%(evt.char, evt.keycode, evt.keysym))
    
    def set_label_focus(self):
        """在Label和Button之间切换焦点"""
            
        self.info.set('')
        
        if isinstance(self.lab.focus_get(), Label):
            self.btn.focus_set()
        else:
            self.lab.focus_set()

if __name__ == '__main__':
    app = MyApp()
    app.mainloop()

这段代码在标签控件上绑定了任意键被按下事件,在按钮控件上绑定了切换焦点的事件函数。代码运行界面如下所示。

在这里插入图片描述

这里需要特别说明一下,绑定键盘事件的控件必须在获得焦点后绑定才能生效。本例点击按钮可在按钮和标签之间切换焦点,请仔细体会标签在或获得和失去焦点后对于键盘事件的不同反应。

3.3 组件事件

组件是一个较为含糊的说法,大致可以认为是窗体和控件的统称。Tkinter支持的组件事件较多,这里只介绍最为常用的几个。

  • <Configure> - 改变大小或位置
  • <FocusIn> - 获得焦点时触发
  • <FocusOut> - 失去焦点时触发
  • <Destroy> - 销毁时触发

下面的例子演示了窗体绑定销毁事件的用法。通常,这样做是为了在用户关闭窗口前做些保护性的清理性的工作。

from tkinter import *

def befor_quit(evt):
    """关闭之前清理现场"""
    
    print('关闭之前,可以做点什么')

root = Tk()
Label(root, text='Hello World').pack()

root.bind('<Destroy>', befor_quit)

root.mainloop()

3.4 事件对象

无论是鼠标事件、键盘事件还是组件事件,都要求与其绑定的事件函数接受一个事件对象作为参数。一个事件对象一般包含下列信息。

  • widget - 触发事件的控件
  • type - 事件类型
  • x, y - 鼠标在窗体上的坐标(以左上角为原点)
  • x_root, y_root - 鼠标在屏幕上的坐标(以左上角为原点)
  • num - 鼠标事件对应的按键码
  • char - 键盘事件对应的字符代码
  • keysym - 键盘事件对应的字符串
  • keycode - 键盘事件对应的按键码
  • width, height - 受事件影响后的控件宽高

在鼠标事件和键盘事件的例子中已经演示了事件对象的用法,这里不再赘述。


4. 常用控件

4.1 窗格Frame

在wx等GUI库中,Frame的含义是窗体,不过Tkinter的Frame控件更像一个控件的容器,这里我把它称为窗格,以免产生歧义。配合pack方法,Frame堪称是Tkinter的布局利器。

from tkinter import *

class MyApp(Tk):
    """继承Tk,创建自己的桌面应用程序类"""
    
    def __init__(self):
        """构造函数"""
        
        super().__init__()
        
        self.title('窗格:Frame')
        self.iconbitmap('res/Tk.ico')
        self.init_ui()
    
    def init_ui(self):
        """初始化界面"""
        
        frame1 = Frame(self, bg='#90c0c0') 
        frame1.pack(padx=5, pady=5)

        # Label是frame1的第1个子控件,从左向右布局
        Label(frame1, bg='#f0f0f0', width=25).pack(side=LEFT, fill=BOTH, padx=5, pady=5)

        # frame2是frame1的第2个子控件,从左向右布局
        frame2 = Frame(frame1, bg='#f0f0f0')
        frame2.pack(side=LEFT, padx=5, pady=5)

        # 3个Button是frame2的子控件,自上而下布局
        Button(frame2, text='按钮1', width=10).pack(padx=5, pady=5)
        Button(frame2, text='按钮2', width=10).pack(padx=5, pady=5)
        Button(frame2, text='按钮3', width=10).pack(padx=5, pady=5)

if __name__ == '__main__':
    app = MyApp()
    app.mainloop()

这段代码最外层的frame1是为了控制窗体内上下左右的留白大小。lable和frame2同属于frame1的子元素,分列左右。frame2里面自上而下放置了3个按钮。代码运行界面如下图所示。

在这里插入图片描述

4.2 输入框Entry

通过输入框的textvariable参数关联一个字符串类型对象,当输入框内容改变时会自动同步到关联的字符串类型对象——这是输入框控件Entry的一个使用技巧。输入框的另一个常用参数是justify,用来指定输入内容的对齐方式。另外,输入框控件输入密码时,show参数可以指定一个字符以替换实际输入的内容。

from tkinter import *

class MyApp(Tk):
    """继承Tk,创建自己的桌面应用程序类"""
    
    def __init__(self):
        """构造函数"""
        
        super().__init__()
        
        self.title('输入框:Entry')
        self.iconbitmap('res/Tk.ico')
        self.init_ui()
    
    def init_ui(self):
        """初始化界面"""
        
        account, passwd = StringVar(), StringVar()
        account.set('')
        passwd.set('')

        group = LabelFrame(self, text="登录", padx=5, pady=5)
        group.pack(padx=20, pady=10)

        f1 = Frame(group)
        f1.pack(padx=5, pady=5)
        Label(f1, text='账号:').pack(side=LEFT, pady=5)
        Entry(f1, textvariable=account, width=15, justify=CENTER).pack(side=LEFT, pady=5)

        f2 = Frame(group)
        f2.pack(padx=5, pady=5) 
        Label(f2, text='密码:').pack(side=LEFT, pady=5)
        Entry(f2, textvariable=passwd, width=15, show='*', justify=CENTER).pack(side=LEFT, pady=5)

        btn = Button(self, text='确定', bg='#90c0c0', command=lambda : print(account.get(), passwd.get()))
        btn.pack(fill=X, padx=20, pady=10)

if __name__ == '__main__':
    app = MyApp()
    app.mainloop()

这段代码还有同时演示了带标签的窗格控件LabelFrame的用法。代码运行界面如下图所示。

在这里插入图片描述

4.3 单选框Radiobutton

单选框通常是成组使用的,每个Radiobutton都关联同一个整型对象,该整型对象的值就是单选框选中选项的索引号。

from tkinter import *

class MyApp(Tk):
    """继承Tk,创建自己的桌面应用程序类"""
    
    def __init__(self):
        """构造函数"""
        
        super().__init__()
        
        self.title('单选框:Radiobutton')
        self.iconbitmap('res/Tk.ico')
        self.init_ui()
    
    def init_ui(self):
        """初始化界面"""
        
        f0 = Frame(self)
        f0.pack(padx=5, pady=5)
         
        f1 = Frame(f0) 
        f1.pack(side=LEFT, padx=5, pady=5)

        g1 = LabelFrame(f1, text="你最擅长哪一个?", padx=5, pady=5)
        g1.pack(padx=5, pady=5)

        self.rb_v1 = IntVar()
        self.rb_v1.set(0)
        rb_11 = Radiobutton(g1, variable=self.rb_v1, text='Tkinter', value=0, command=self.on_radio_1)
        rb_12 = Radiobutton(g1, variable=self.rb_v1, text='wxPython', value=1, command=self.on_radio_1)
        rb_13 = Radiobutton(g1, variable=self.rb_v1, text='PyQt5', value=2, command=self.on_radio_1)
        rb_11.pack(ancho='w', padx=5, pady=5)
        rb_12.pack(ancho='w', padx=5, pady=5)
        rb_13.pack(ancho='w', padx=5, pady=5)

        f2 = Frame(f0) 
        f2.pack(side=LEFT, padx=5, pady=5)

        g2 = LabelFrame(f2, text="你最擅长哪一个?", padx=5, pady=5)
        g2.pack(padx=5, pady=5)

        self.rb_v2 = IntVar()
        self.rb_v2.set(0)
        rb_21 = Radiobutton(g2, variable=self.rb_v2, text='Tkinter', value=0, indicatoron=False, command=self.on_radio_2)
        rb_22 = Radiobutton(g2, variable=self.rb_v2, text='wxPython', value=1, indicatoron=False, command=self.on_radio_2)
        rb_23 = Radiobutton(g2, variable=self.rb_v2, text='PyQt5', value=2, indicatoron=False, command=self.on_radio_2)
        rb_21.pack(fill=X, padx=5, pady=5)
        rb_22.pack(fill=X, padx=5, pady=5)
        rb_23.pack(fill=X, padx=5, pady=5)

        self.info = StringVar()
        self.info.set('')
        label = Label(self, textvariable=self.info, bg='#ffffff')
        label.pack(expand='yes', fill='x', padx=5, pady=10)
    
    def on_radio_1(self):
        """响应第1组单选框事件的函数"""
        
        selected = ['Tkinter', 'wxPython', 'PyQt5'][self.rb_v1.get()]
        self.info.set('你选择了第1组的%s'%selected)

    def on_radio_2(self):
        """响应第2组单选框事件的函数"""
        
        selected = ['Tkinter', 'wxPython', 'PyQt5'][self.rb_v2.get()]
        self.info.set('你选择了第2组的%s'%selected)

if __name__ == '__main__':
    app = MyApp()
    app.mainloop()

这段代码演示了两种不同风格的单选框控件。代码运行界面如下图所示。

在这里插入图片描述

4.4 复选框Checkbutton

复选框的每一项都需要关联一个整型对象,每当有选项被点击时,逐一检查每一个整型对象的值,就可以获得当前选中的选项。

from tkinter import *

class MyApp(Tk):
    """继承Tk,创建自己的桌面应用程序类"""
    
    def __init__(self):
        """构造函数"""
        
        super().__init__()
        
        self.title('复选框:Checkbox')
        self.iconbitmap('res/Tk.ico')
        self.init_ui()
    
    def init_ui(self):
        """初始化界面"""
        
        group = LabelFrame(self, text="你擅长哪个?", padx=20, pady=5)
        group.pack(padx=30, pady=5)

        self.cb_v1 = IntVar()
        self.cb_v2 = IntVar()
        self.cb_v3 = IntVar()
        self.cb_v1.set(0)
        self.cb_v2.set(0)
        self.cb_v3.set(0)

        cb_1 = Checkbutton(group, variable=self.cb_v1, text='Tkinter', onvalue=1, offvalue=0, command=self.on_cb).pack(ancho='w', padx=5, pady=5)
        cb_2 = Checkbutton(group, variable=self.cb_v2, text='wxPython', onvalue=1, offvalue=0, command=self.on_cb).pack(ancho='w', padx=5, pady=5)
        cb_3 = Checkbutton(group, variable=self.cb_v3, text='PyQt5', onvalue=1, offvalue=0, command=self.on_cb).pack(ancho='w', padx=5, pady=5)

        self.info = StringVar()
        self.info.set('')
        label = Label(self, textvariable=self.info, bg='#ffffff')
        label.pack(expand='yes', fill='x', padx=5, pady=5)
    
    def on_cb(self):
        """响应复选框事件的函数"""
        
        selected = list()
        if self.cb_v1.get():
           selected.append('Tkinter') 
        if self.cb_v2.get():
           selected.append('wxPython') 
        if self.cb_v3.get():
           selected.append('PyQt5') 
        
        self.info.set(', '.join(selected))

if __name__ == '__main__':
    app = MyApp()
    app.mainloop()

运行界面如下图所示。

在这里插入图片描述

4.5 计数器Spinbox

计数器Spinbox既可以向Entry那样接受键盘输入,也可以点击上下的箭头实现数值的增加,适用于小幅度连续调整的场合。

from tkinter import *

def on_spin():
    """响应可调输入框事件的函数"""
    
    info.set(str(spin_v.get()))

root = Tk()
root.title('可调输入框:Spinbox')

spin_v = IntVar()
spin_v.set(5)
entry = Spinbox(root, textvariable=spin_v, from_=0, to=9, bg='#ffffff', command=on_spin).pack(fill=X, padx=5, pady=5)

info = StringVar()
info.set(str(spin_v.get()))
label = Label(root, textvariable=info, bg='#ffffff')
label.pack(expand=YES, fill=X, padx=5, pady=5)

root.mainloop()

在这段代码中,Spinbox只绑定了鼠标事件没有绑定键盘事件,因此信息显式区不能显示键盘输入信息,只响应鼠标操作。代码运行界面如下图所示。

在这里插入图片描述

4.6 滑块Scale

和其他控件相比,滑块控件Scale在应用上有一点点怪异:如果用command参数绑定事件函数,则要求该函数接收一个事件对象作为参数。类似的情况还出现在控件命名上,比如,Radiobutton的第2个单词首字母小写,LabelFrame的第2个单词首字母却是大写。特例破坏了一致性的美感,这也是Tkinter为人诟病的一个突出问题。

from tkinter import *

class MyApp(Tk):
    """继承Tk,创建自己的桌面应用程序类"""
    
    def __init__(self):
        """构造函数"""
        
        super().__init__()
        
        self.title('滑块:Scale')
        self.geometry('240x100')
        self.iconbitmap('res/Tk.ico')
        self.init_ui()
    
    def init_ui(self):
        """初始化界面"""
        
        self.scale_v = DoubleVar()
        self.scale_v.set(50)
        scale = Scale(self, variable=self.scale_v, from_=0, to=100, orient=HORIZONTAL, command=self.on_scale)
        scale.pack(fill=X, padx=5, pady=5)

        self.info = StringVar()
        self.info.set(str(self.scale_v.get()))
        label = Label(self, textvariable=self.info, bg='#ffffff')
        label.pack(expand=YES, fill=X, padx=5, pady=5)
    
    def on_scale(self, evt):
        """响应滑块事件的函数"""
        
        self.info.set(str(self.scale_v.get()))

if __name__ == '__main__':
    app = MyApp()
    app.mainloop()

代码运行界面如下图所示。

在这里插入图片描述

4.7 菜单按钮Menubutton

下面的代码给出了一个完整的菜单例子。

class MyApp(Tk):
    """继承Tk,创建自己的桌面应用程序类"""
    
    def __init__(self):
        """构造函数"""
        
        super().__init__()
        
        self.title('菜单按钮:Menubutton')
        self.geometry('300x100')
        self.iconbitmap('res/Tk.ico')
        self.init_ui()
    
    def init_ui(self):
        """初始化界面"""
        
        frame_menu = Frame(self)
        frame_menu.pack(anchor=NW) # 菜单位于窗口左上角(North_West)

        mb_file = Menubutton(frame_menu, text='文件', relief=RAISED)
        mb_file.pack(side='left')
        file_menu = Menu(mb_file, tearoff=False)
        file_menu.add_command(label='打开', command=lambda :print('打开文件'))
        file_menu.add_command(label='保存', command=lambda :print('保存文件'))
        file_menu.add_separator()
        file_menu.add_command(label='退出', command=self.destroy)
        mb_file.config(menu=file_menu)

        mb_help = Menubutton(frame_menu, text='帮助', relief=RAISED)
        mb_help.pack(side='left')
        help_menu = Menu(mb_help, tearoff=False)
        help_menu.add_command(label='关于...', command=lambda :print('帮助文档'))
        mb_help.config(menu=help_menu)

if __name__ == '__main__':
    app = MyApp()
    app.mainloop()

为了让代码看起来更清晰,这里用lambda函数代替了菜单按钮的事件函数。其实,lambda函数就是匿名函数,它以lambda关键字开始,以半角冒号分隔函数参数和函数体。代码运行界面如下图所示。

在这里插入图片描述

4.8 消息对话框

Tkinter的消息对话框子模块messagebox提供了多种对话框,以适应不同的应用需求,下面的代码演示了其中常用的七个对话框。前文已经提到过,子模块messagebox必须要显式导入才能使用。

from tkinter import *
from tkinter import messagebox as mb

root = Tk()
root.title('消息对话框')

info = StringVar()
info.set('')

f = Frame(root)
f.pack(padx=5, pady=10)

Button(f, text='提示信息', command=lambda :info.set(mb.showinfo(title='提示信息', message='对手认负,比赛结束。'))).pack(side=LEFT, padx=5)
Button(f, text='警告信息', command=lambda :info.set(mb.showwarning(title='警告信息', message='不能连续提和!'))).pack(side=LEFT, padx=5)
Button(f, text='错误信息', command=lambda :info.set(mb.showerror(title='错误信息', message='着法错误!'))).pack(side=LEFT, padx=5)
Button(f, text='Yes/No', command=lambda :info.set(mb.askyesno(title='操作提示', message='对手提和,接受吗?'))).pack(side=LEFT, padx=5)
Button(f, text='Ok/Cancel', command=lambda :info.set(mb.askokcancel(title='操作提示', message='再来一局?'))).pack(side=LEFT, padx=5)
Button(f, text='Retry/Cancel', command=lambda :info.set(mb.askretrycancel(title='操作提示', message='消息发送失败!'))).pack(side=LEFT, padx=5)
Button(f, text='Yes/No/Cancel', command=lambda :info.set(mb.askyesnocancel(title='操作提示', message='是否保存对局记录?'))).pack(side=LEFT, padx=5)

label = Label(root, textvariable=info, bg='#ffffff')
label.pack(expand='yes', fill='x', padx=5, pady=20)

root.mainloop()

代码运行界面如下图所示。

在这里插入图片描述

点击按钮后弹出的各个对话框如下图所示。

在这里插入图片描述

4.9 文件对话框

Tkinter的文件对话框子模块filedialog提供了多种对话框,以适应不同的应用需求,下面的代码演示了其中常用的文件选择、目录选择和文件保存等三个对话框。同样的,子模块filedialog也必须要显式导入才能使用。

from tkinter import *
from tkinter import filedialog as fd

class MyApp(Tk):
    """继承Tk,创建自己的桌面应用程序类"""
    
    def __init__(self):
        """构造函数"""
        
        super().__init__()
        
        self.title('文件对话框')
        self.iconbitmap('res/Tk.ico')
        self.init_ui()
    
    def init_ui(self):
        """初始化界面"""
        
        info = StringVar()
        info.set('')

        f = Frame(self)
        f.pack(padx=20, pady=10)

        Button(f, text='选择文件', command=lambda :info.set(fd.askopenfilename(title='选择文件'))).pack(side=LEFT, padx=10)
        Button(f, text='选择目录', command=lambda :info.set(fd.askdirectory(title='选择目录'))).pack(side=LEFT, padx=10)
        Button(f, text='保存文件', command=lambda :info.set(fd.asksaveasfilename(title='保存文件', defaultextension='.png'))).pack(side=LEFT, padx=10)

        label = Label(self, textvariable=info, bg='#ffffff')
        label.pack(expand='yes', fill='x', padx=5, pady=20)

if __name__ == '__main__':
    app = MyApp()
    app.mainloop()

代码运行界面如下图所示。

在这里插入图片描述

下图是选择打开文件的对话窗口,选择路径和保存文件与之类似。

在这里插入图片描述

4.10 可选主题的控件包ttk

Tk的研发始于1989 年,第一个版本于1991年问世,彼时还是一个重实力轻颜值的年代。相比于后来的wx和Qt,Tk的控件更注重实用,卖相自然不会太好。好在Tkinter与时俱进,后期推出了可选主题的控件包ttk,算是对其控件颜值的补救吧。

可选主题的控件包ttk包含了18个控件,其中Button、Checkbutton、Entry、Frame、Label, LabelFrame、Menubutton、PanedWindow、Radiobutton、Scale、Scrollbar和Spinbox等12个和已有的控件重合,只是用法上有些差异,6个新增的控件是Combobox、Notebook、Progressbar、Separator、Sizegrip和Treeview。

之所以称其为可选主题的控件包,是因为ttk提供了Style类,可统一定制所有ttk控件的风格。在Python的IDLE中可以方便地查看ttk包含的可用主题。

>>> from tkinter import ttk
>>> style = ttk.Style()
>>> style.theme_names()
('winnative', 'clam', 'alt', 'default', 'classic', 'vista', 'xpnative')

让我们先来看看这些主题和原来的控件有什么不同。

from tkinter import *
from tkinter import ttk

class MyApp(Tk):
    """继承Tk,创建自己的桌面应用程序类"""
    
    def __init__(self):
        """构造函数"""
        
        super().__init__()
        
        self.title('主题控件')
        self.iconbitmap('res/Tk.ico')
        self.init_ui()
    
    def init_ui(self):
        """初始化界面"""
        
        self.style = ttk.Style()
        
        self.theme = StringVar() 
        self.theme.set(self.style.theme_use())
        
        ttk.Button(self, text='切换主题按钮', command=self.on_style).pack(padx=30, pady=20)
        ttk.Entry(self, textvariable=self.theme, justify=CENTER, width=20).pack(padx=30, pady=0)
        ttk.Combobox(self, value=('Tkinter', 'wxPython', 'PyQt5')).pack(padx=30, pady=20)
    
    def on_style(self):
        """更换主题"""
        
        items = self.style.theme_names()
        new_theme = items[(items.index(self.theme.get())+1)%len(items)]
        self.theme.set(new_theme)
        self.style.theme_use(new_theme)

if __name__ == '__main__':
    app = MyApp()
    app.mainloop()

代码运行界面如下图所示。点击按钮可在ttk可用的主题之间循环切换,截图为Windows原生主题。

在这里插入图片描述


5. 示例和技巧

5.1 窗口居中

本文开始的快速体验环节,已经介绍过用窗体的geometry方法设置窗口大小,其实,它也被用来设置窗口位置。

from tkinter import *

class MyApp(Tk):
    """继承Tk,创建自己的桌面应用程序类"""
    
    def __init__(self):
        """构造函数"""
        
        super().__init__()
        
        self.title('窗口居中')
        self.iconbitmap('res/Tk.ico')
        self.init_ui()
        self.center()
    
    def init_ui(self):
        """初始化界面"""
        
        Label(self, text='Hello World', font=("Arial Bold", 50)).pack(expand=YES, fill='both')
    
    def center(self):
        """窗口居中"""
        
        self.update() # 更新显示以获取最新的窗口尺寸
        scr_w = self.winfo_screenwidth() # 获取屏幕宽度
        scr_h = self.winfo_screenheight() # 获取屏幕宽度
        w = self.winfo_width() # 窗口宽度
        h = self.winfo_height() # 窗口高度
        x = (scr_w-w)//2 # 窗口左上角x坐标
        y = (scr_h-h)//2 # 窗口左上角y坐标

        self.geometry('%dx%d+%d+%d'%(w,h,x,y)) # 设置窗口大小和位置

if __name__ == '__main__':
    app = MyApp()
    app.mainloop()

运行这段代码,显示的仍然是最初的Hello World,但是不管设置了多大的字号,窗口总是位于屏幕的中央。

6.2 相册

Tinkter的很多控件都可以作为图像显示的容器,或者用图片来提升颜值,只是Tinkter的图像处理能力有点弱,比如,BitmapImage类只能处理灰度图像,PhotoImage只能打开.gif格式和部分.png格式的图像。幸好pillow模块提供了可用于Tinkter的PhotoImage对象,使得Tinkter也可以非常方便地使用图像了。下面的例子使用标签控件Label作为图像容器,点击前翻后翻按钮可在多张照片之间循环切换。

from tkinter import *
from tkinter import ttk
from PIL import Image, ImageTk

class MyApp(Tk):
    """继承Tk,创建自己的桌面应用程序类"""
    
    def __init__(self):
        """构造函数"""
        
        super().__init__()
        
        self.title('相册')
        self.iconbitmap('res/Tk.ico')
        self.init_ui()
        self.center()
    
    def init_ui(self):
        """初始化界面"""
        
        self.curr = 0
        self.photos = ('res/DSC03363.jpg', 'res/DSC03394.jpg', 'res/DSC03402.jpg')
        self.img = ImageTk.PhotoImage(Image.open(self.photos[self.curr]))

        self.album = Label(self, image=self.img)
        self.album.pack(expand=YES, fill='both', padx=5, pady=5)

        f = Frame(self)
        f.pack(padx=10, pady=20)

        style = ttk.Style()
        style.theme_use('vista')

        ttk.Button(f, text='<', width=10, command=self.on_prev).pack(side=LEFT, padx=10)
        ttk.Button(f, text='>', width=10, command=self.on_next).pack(side=LEFT, padx=10)
    
    def center(self):
        """窗口居中"""
        
        self.update() # 更新显示以获取最新的窗口尺寸
        scr_w = self.winfo_screenwidth() # 获取屏幕宽度
        scr_h = self.winfo_screenheight() # 获取屏幕宽度
        w = self.winfo_width() # 窗口宽度
        h = self.winfo_height() # 窗口高度
        x = (scr_w-w)//2 # 窗口左上角x坐标
        y = (scr_h-h)//2 # 窗口左上角y坐标

        self.geometry('%dx%d+%d+%d'%(w,h,x,y)) # 设置窗口大小和位置
    
    def on_prev(self):
        """前一张照片"""
        
        self.curr = (self.curr-1)%3
        img = ImageTk.PhotoImage(Image.open(self.photos[self.curr]))
        self.album.configure(image=img)
        self.album.image = img

    def on_next(self):
        """后一张照片"""
        
        self.curr = (self.curr+1)%3
        img = ImageTk.PhotoImage(Image.open(self.photos[self.curr]))
        self.album.configure(image=img)
        self.album.image = img

if __name__ == '__main__':
    app = MyApp()
    app.mainloop()

代码运行界面如下图所示。点击前翻后翻按钮可在多张照片之间循环切换。

在这里插入图片描述

6.3 计算器

几乎所有的GUI课程都会用计算器作为例子,Tkinter怎能缺席呢?这个例子除了演示如何使用grid方法布局外,还演示了在一个控件类的所有实例上绑定事件和事件函数,即bind_class的用法。

from tkinter import *

class MyApp(Tk):
    """继承Tk,创建自己的桌面应用程序类"""
    
    def __init__(self):
        """构造函数"""
        
        super().__init__()
        
        self.title('计算器')
        self.iconbitmap('res/Tk.ico')
        self.init_ui()
        self.center()
    
    def init_ui(self):
        """初始化界面"""
        
        self.screen = StringVar()
        self.screen.set('')
        Label(self, textvariable=self.screen, anchor=E, bg='#000030', fg='#30ff30', font=("Arial Bold", 16)).pack(fill=X, padx=10, pady=10)

        keys = [
            ['(', ')', 'Back', 'Clear'],
            ['7',  '8',  '9',  '/'], 
            ['4',  '5',  '6',  '*'], 
            ['1',  '2',  '3',  '-'], 
            ['0',  '.',  '=',  '+']
        ]

        f = Frame(self)
        f.pack(padx=10, pady=10)

        for i in range(5):
            for j in range(4):
                if i == 0 or j == 3:
                    Button(f, text=keys[i][j], width=8, bg='#f0e0d0', fg='red').grid(row=i, column=j, padx=3, pady=3)
                elif i == 4 and j == 2:
                    Button(f, text=keys[i][j], width=8, bg='#f0e0a0').grid(row=i, column=j, padx=3, pady=3)
                else:
                    Button(f, text=keys[i][j], width=8, bg='#d9e4f1').grid(row=i, column=j, padx=3, pady=3)

        self.bind_class("Button", "<ButtonRelease-1>", self.on_button)
    
    def center(self):
        """窗口居中"""
        
        self.update() # 更新显示以获取最新的窗口尺寸
        scr_w = self.winfo_screenwidth() # 获取屏幕宽度
        scr_h = self.winfo_screenheight() # 获取屏幕宽度
        
        w = self.winfo_width() # 窗口宽度
        h = self.winfo_height() # 窗口高度
        x = (scr_w-w)//2 # 窗口左上角x坐标
        y = (scr_h-h)//2 # 窗口左上角y坐标

        self.geometry('%dx%d+%d+%d'%(w,h,x,y)) # 设置窗口大小和位置
    
    def on_button(self, evt):
        """响应按键"""
        
        if self.screen.get() == 'Error':
            self.screen.set('')
        
        ch = evt.widget.cget('text')
        if ch == 'Clear':
            self.screen.set('')
        elif ch == 'Back':
            self.screen.set(self.screen.get()[:-1])
        elif ch == '=':
            try:
                result = str(eval(self.screen.get()))
            except:
                result = 'Error'
            self.screen.set(result)
        else:
            self.screen.set(self.screen.get() + ch)

if __name__ == '__main__':
    app = MyApp()
    app.mainloop()

代码运行界面如下图所示。

在这里插入图片描述

6.4 秒表

以百分之一秒的频率刷新显示,对于任何一款GUI库来说,都是不容小觑的负担。不过,由于Tkinter采用了独树一帜的类型对象关联控件机制,在计时线程中高速刷新标签显示内容却是从容不迫、游刃有余。

import time
import threading
from tkinter import *

def on_btn():
    """点击按钮"""
    
    global t0
    
    if btn_name.get() == '开始':
        lcd.set('0.00')
        t0 = time.time()
        btn_name.set('停止')
    else:
        btn_name.set('开始')

def watch():
    """秒表计时线程函数"""
    
    while True:
        if btn_name.get() == '停止':
            lcd.set('%.2f'%(time.time()-t0))
        else:
            time.sleep(0.01)

root = Tk()
root.title('秒表')

btn_name = StringVar() # 按钮名
btn_name.set('开始')

t0 = 0 # 计时开始的时间戳
lcd = StringVar() # 液晶显示值
lcd.set('0:00')

f = Frame(root)
f.pack(padx=20, pady=10)

Label(f, textvariable=lcd, width=10, bg='#000030', fg='#30ff30', font=("Arial Bold", 24)).pack(pady=10)
Button(f, textvariable=btn_name, bg='#f0e0d0', command=on_btn).pack(fill=X, pady=10)

threading.Thread(target=watch).start()

root.mainloop()

点击开始按钮,秒表自动清零并启动计时,计时精度高达百分之一秒。代码运行界面如下图所示。

在这里插入图片描述

6.5 画板

Canvas组件为Tkinter的图形绘制提供了基础。Canvas是一个高度灵活的组件,可以用来展示图片,也可以用来绘制图形和图表,创建图形编辑器,并实现各种自定义的小部件,比如弧形、线条、椭圆形、多边形和矩形等。

from tkinter import *
import tkinter.colorchooser as tc

class MyApp(Tk):
    """继承Tk,创建自己的桌面应用程序类"""
    
    def __init__(self):
        """构造函数"""
        
        super().__init__()
        
        self.title('画板')
        self.iconbitmap('res/Tk.ico')
        self.init_ui()
    
    def init_ui(self):
        """初始化界面"""
        
        self.color = '#90f010' # 当前颜色
        self.pen = 3 # 当前画笔
        self.pos = None # 鼠标当前位置
        
        self.rbv = IntVar() # 当前画笔
        self.rbv.set(self.pen)
        
        self.cav = Canvas(self, bg='#ffffff', width=480, height=320)
        self.cav.pack(side=LEFT, padx=5, pady=5)
        
        self.cav.bind('<Button-1>', self.on_down)
        self.cav.bind('<ButtonRelease-1>', self.on_up)
        self.cav.bind('<B1-Motion>', self.on_motion)
        
        frame = Frame(self)
        frame.pack(side=LEFT, anchor=N, padx=5, pady=20)
        
        Radiobutton(frame, variable=self.rbv, text='1pix', value=1, command=self.on_radio).pack(ancho='w', padx=5, pady=5)
        Radiobutton(frame, variable=self.rbv, text='3pix', value=3, command=self.on_radio).pack(ancho='w', padx=5, pady=5)
        Radiobutton(frame, variable=self.rbv, text='5pix', value=5, command=self.on_radio).pack(ancho='w', padx=5, pady=5)
        Radiobutton(frame, variable=self.rbv, text='7pix', value=7, command=self.on_radio).pack(ancho='w', padx=5, pady=5)
        Radiobutton(frame, variable=self.rbv, text='9pix', value=9, command=self.on_radio).pack(ancho='w', padx=5, pady=5)
        
        self.btn = Button(frame, text='', width=6, bg=self.color, command=self.on_btn)
        self.btn.pack(padx=5, pady=10)
    
    def on_radio(self):
        """选择画笔"""
        
        self.pen = self.rbv.get()
    
    def on_btn(self):
        """选择颜色"""
        
        color = tc.askcolor()[1]
        if color:
            self.color = color
            self.btn.configure(bg=self.color)
    
    def on_down(self, evt):
        """左键按下"""
        
        self.pos = evt.x, evt.y
    
    def on_up(self, evt):
        """左键弹起"""
        
        self.pos = None
    
    def on_motion(self, evt):
        """鼠标移动"""
        
        if not self.pos is None:
            line = (*self.pos, evt.x, evt.y)
            self.pos = evt.x, evt.y
            self.cav.create_line(line, fill=self.color, width=self.pen)

if __name__ == '__main__':
    app = MyApp()
    app.mainloop()

这段代码实现了一个简易的画板,提供画笔粗细和颜色选择,拖拽鼠标在画板上移动即可绘制线条。代码运行界面如下图所示。

在这里插入图片描述


6. 集成Matplotlib

在Tkinter中使用Matplotlib绘图库的关键在于,Matplotlib的后端子模块可以生成Tkinter的canvas控件,同时Matplotlib也可以在其上绘图。

import numpy as np
import matplotlib

matplotlib.use('TkAgg')
matplotlib.rcParams['font.sans-serif'] = ['FangSong']
matplotlib.rcParams['axes.unicode_minus'] = False

from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
from matplotlib.figure import Figure
from tkinter import *

class MyApp(Tk):
    """继承Tk,创建自己的桌面应用程序类"""
    
    def __init__(self):
        """构造函数"""
        
        Tk.__init__(self)
        
        self.title('集成Matplotlib')
        self.iconbitmap('res/Tk.ico')
        self.init_ui()
        self.center()
    
    def init_ui(self):
        """初始化界面"""
        
        self.fig = Figure(dpi=150)
        self.cv = FigureCanvasTkAgg(self.fig, self)
        self.cv.get_tk_widget().pack(fill=BOTH, expand=1, padx=5, pady=5)
        
        f = Frame(self)
        f.pack(pady=10)
        Button(f, text='散点图', width=12, bg='#f0e0d0', command=self.on_scatter).pack(side=LEFT, padx=20)
        Button(f, text='等值线图', width=12, bg='#f0e0d0', command=self.on_contour).pack(side=LEFT, padx=20)
    
    def center(self):
        """窗口居中"""
        
        self.update() # 更新显示以获取最新的窗口尺寸
        scr_w = self.winfo_screenwidth() # 获取屏幕宽度
        scr_h = self.winfo_screenheight() # 获取屏幕宽度
        
        w = self.winfo_width() # 窗口宽度
        h = self.winfo_height() # 窗口高度
        x = (scr_w-w)//2 # 窗口左上角x坐标
        y = (scr_h-h)//2 # 窗口左上角y坐标

        self.geometry('%dx%d+%d+%d'%(w,h,x,y)) # 设置窗口大小和位置
    
    def on_scatter(self):
        """散点图"""
        
        x = np.random.randn(50) # 随机生成50个符合标准正态分布的点(x坐标)
        y = np.random.randn(50) # 随机生成50个符合标准正态分布的点(y坐标)
        color = 10 * np.random.rand(50) # 随即数,用于映射颜色
        area = np.square(30*np.random.rand(50)) # 随机数表示点的面积
        
        self.fig.clear()
        ax = self.fig.add_subplot(111)
        ax.scatter(x, y, c=color, s=area, cmap='hsv', marker='o', edgecolor='r', alpha=0.5)
        self.cv.draw()
    
    def on_contour(self):
        """等值线图"""
        
        y, x = np.mgrid[-3:3:60j, -4:4:80j]
        z = (1-y**5+x**5)*np.exp(-x**2-y**2)
        
        self.fig.clear()
        ax = self.fig.add_subplot(111)
        ax.set_title('有填充的等值线图')
        c = ax.contourf(x, y, z, levels=8, cmap='jet')
        self.fig.colorbar(c, ax=ax)
        self.cv.draw()

if __name__ == '__main__':
    app = MyApp()
    app.mainloop()

代码运行界面如下图所示。

在这里插入图片描述

资料来源 Tkinter:实用至上主义的经典之作
博客作者 xufive
前往答题
我的笔记