【Tkinter】 Focuser 错误调试文档

Focuser 错误调试文档


Focuser 在使用过程中会出现一些问题,表现为程序未响应。该现象仅在使用 pyinstaller 打包为 exe 文件后出现,因此未能在开发阶段发现该问题。

pyinstaller -F -w -i Threat.contrast-black.ico Focuser.py

尝试从现象来分析代码的错误。

首先初步观测现象:在完成时长大于 60s 时会出现未响应。

于是猜测是计时模块在计算 60s 以上的时间会发生错误。经过静态分析,未发现存在逻辑漏洞。后经过尝试,存在时长大于 60s 但仍正常运行的情况,该猜测不正确。

再对代码静态分析,发现雷霆致命笔误:
在这里插入图片描述

第 170 行的 f.close 缺失了括号。

补上括号后,经过两局游戏的测试,未有未响应的情况。但是数日后开发者发现,程序依旧会发生未响应的情况。

本人观测到的情况为:连续两次或三次写入记录文件,则大概率发生未响应。

于是猜测是记录历史记录相关部分出现问题。

class GAME:

    def get_history(self):
        f = open(PATH + "\\FOCUSER_HISTORY.fcs", 'a', encoding = 'UTF-8')
        f.close()
        with open(PATH + "\\FOCUSER_HISTORY.fcs", 'r', encoding = 'UTF-8') as ff:
            hishis = ff.readlines()
            ff.close()
        self.his_cont['state'] = 'normal'
        for e in hishis:
            self.his_cont.insert(INSERT, e)
        self.his_cont['state'] = 'disabled'


    def save_history(self, ths):
        f = open(PATH + "\\FOCUSER_HISTORY.fcs", 'a', encoding = 'UTF-8')
        f.write(ths)
        f.close()

get_history() 中,首先使用追加模式 a 打开历史记录文件 FOCUSER_HISTORY.fcs ,在该文件不存在时创建该文件。随后关闭,再以只读模式 r 读入历史记录,再次关闭。

随后启用窗口中显示显示历史记录的 Tkinter.Text() 控件,向其中尾插历史记录,最后禁用它,完成历史记录的初始导入。

在这里插入图片描述

save_history() 中,是非常简单的追加写入内容 ths

save_history() 的调用在 judge() 函数中。judge() 闭包保存参数,返回一个函数 bibao() 作为 Tkinter.Button() 绑定的函数。

其中, 第 178 到 183 行是为每个按钮刷新激活时的背景颜色,后经断点调试,没有问题,只是存在无需被刷新的按钮重复被刷新的效率低的问题。没办法太懒了,而且复杂度也只是一个小小的常数,能用就好。

随后在一次调试中,发现程序卡在了第 184 到 190 行之间(下图红框部分) :

在这里插入图片描述

由于基本可以确定第 192 到 195 行是不会有问题的,所以目标锁定为下面两者之一:

self.timer.stop()
self.save_history()

上面的第二个基本可以排除,非常简单的文件操作不会引起偶然的未响应。

一次尝试中,在未完成一局游戏时,我点击了重新开始,发现也出现了未响应的情况。定位到相关函数:
class GAME:
    def new_game(self):
        self.state = 2
        self.timer.stop()

        self.tns = [i for i in range(1, 37)]
        random.shuffle(self.tns)
        self.tns = [0] + self.tns

        for i in range(1, 37):
            self.conts[i].num = self.tns[i]
            self.conts[i].config()
            self.conts[i].button1.config(command = self.judge(self.conts[i].num))

        self.num_now = 0
        st1 = self.judge(0)
        st1()

        self.timer.begin()

发现共有片段 self.timer.stop() !基本实锤是 self.timer 的问题。self.timer 是自定义的 Timer() 类。

class Timer:

    def __init__(self, tLabel):
        self.running = False
        self.tLabel = tLabel

    def get_time(self):
        start_time = datetime.now()
        while True and self.running:
            current_time = datetime.now()
            time_left = current_time - start_time
            m_seconds_left = time_left.microseconds
            seconds_left = time_left.seconds
            self.tLabel.config(text = f'{int(seconds_left)}.{str(m_seconds_left)[:3]}s ')
            time.sleep(0.005)

    def stop(self):
        self.running = False
        try:
            self.th.join()
        except:
            pass

    def begin(self):
        self.running = True
        self.th = threading.Thread(target = self.get_time, name = 'thread_time')
        self.th.setDaemon(True)
        self.th.start()
class GAME:
    def __init__(self, state = 1, num_now=1):
        self.root = Tk()
	...

    def config_win(self):
	...
        self.time_label = Label(self.root, text = '0.00s ', justify = 'right', anchor = 'e', fg = 'white', bg = '#183338', font = ('consolas', 10) )
        self.timer = Timer(self.time_label)
	...

有经验的人能一眼发现问题:我们开启了单独的线程来进行较高精度的计时,但是在这个线程中,我们修改了 GAME.time_label 中的文本。这是非常危险的,这涉及到 Tkinter 的线程不安全性。

Tkinter 是非线程安全的
Tkinter 基于 Tcl/Tk 解释器构建。这个雷霆解释器在设计之初就没有考虑多线程同时访问的情况。

我们只应让主线程来管理窗口,严格禁止其他工作线程直接操作界面,否则就会有“混乱”的情况发生。正确的方式是使用队列等结构传递信息,通知主线程去完成任务。

那么在本软件中,要实现每 0.005 秒刷新一次计时器,将会是非常大的负担。倘若在主线程中直接使用递归来实现计时,将会消耗巨量的资源,且极易引起栈溢出等问题。这也是为什么本人铤而走险使用了单独线程来完成计时任务。

那么,又不能递归,又不能单走一个线程,该怎么办呢?本人知道 Tkinter 提供了 after() 方法来帮助我们,但在此之前,本人一直觉得, after 不也是递归吗?本人再去深入了解了这个方法。

我们在 Tkinter 的 __init__.py 中找到相关部分:

class Misc:
    def after(self, ms, func=None, *args):
        """Call function once after given time.

        MS specifies the time in milliseconds. FUNC gives the
        function which shall be called. Additional parameters
        are given as parameters to the function call.  Return
        identifier to cancel scheduling with after_cancel."""
        if not func:
            # I'd rather use time.sleep(ms*0.001)
            self.tk.call('after', ms)
            return None
        else:
            def callit():
                try:
                    func(*args)
                finally:
                    try:
                        self.deletecommand(name)
                    except TclError:
                        pass
            callit.__name__ = func.__name__
            name = self._register(callit)
            return self.tk.call('after', ms, name)

最后依旧是 self.tk.call() 起手,没法继续查下去了。不过我们可以发现,单单在这段定义里面,并没有出现入栈或者延时的行为。说明 after() 的实现可能不是单纯的压栈出栈?

来看一下 DeepSeek 给出的解答:

特性递归调用Tkinter 的 after
调用方式函数直接调用自身(func()向事件循环注册一个定时器(after(ms, func)
调用栈不断堆叠,每次调用都占用栈内存每次调用完成后就退出了,栈是干净的
执行时机立即执行,同步阻塞延迟执行,异步非阻塞
控制权函数自己控制mainloop() 事件循环控制

所以并不需要担心会有爆栈或过于浪费资源的情况!

开始修复。

首先重写 Timer 类,使用 after 向主线程报告刷新:

class Timer:

    def __init__(self, tLabel):

        self.tLabel = tLabel
        self.running = False
        self.start_time = None


    def _flash(self):

        if not self.running:
            return

        current_time = datetime.now()
        time_left = current_time - self.start_time
        m_seconds_left = time_left.microseconds
        seconds_left = time_left.seconds

        self.tLabel.config(text = f'{seconds_left}.{str(m_seconds_left)[:3]}s ')
        self.tLabel.after(50, self._flash)


    def stop(self):
        self.running = False


    def begin(self):
        self.running = True
        self.start_time = datetime.now()
        self._flash()

随后重写 new_game() 方法,主要是优化了一下初始化的过程,跟 timer 关系其实不大:

class GAME:
    def new_game(self):
        self.timer.stop()

        self.tns = [i for i in range(1, 37)]
        random.shuffle(self.tns)
        self.tns = [0] + self.tns

        for i in range(1, 37):
            self.conts[i].num = self.tns[i]
            self.conts[i].config()
            self.conts[i].button1.config(command = self.judge(self.conts[i].num))

        self.num_now = 1
        for i in range(1, 37):
            if self.conts[i].num == 1:
                self.conts[i].button1.config(activebackground = '#276235', activeforeground = 'white')
            else:
                self.conts[i].button1.config(activebackground = '#8D1D2C', activeforeground = 'white')


        self.timer.begin()

另外发现 GAME.state 其实没有用上,删掉。

剩下就是一些细小的改进:

class GAME:
    def judge(self, given):
        def bibao():
            if given == self.num_now:
                self.num_now += 1

                for i in range(1, 37):
                    if self.conts[i].num == self.num_now:
                        self.conts[i].button1.config(activebackground = '#276235', activeforeground = 'white')
                    #elif self.conts[i].num == self.num_now:
                    else:
                        self.conts[i].button1.config(activebackground = '#8D1D2C', activeforeground = 'white')
                if self.num_now == 37:
                    self.timer.stop()
                    msg = '[' + str(datetime.now())[:19] + ']\t' + self.time_label.cget('text') + '\n'
                    self.his_cont['state'] = 'normal'
                    self.his_cont.insert(INSERT, msg)
                    self.his_cont.see(END) # 在有新记录时自动滚动到底部
                    self.his_cont['state'] = 'disabled'
                    self.save_history(msg)

            else:
                pass
        return bibao

至此,本次漏洞修复完毕。

附全部代码:

import os
import random
import time

from tkinter import *
from tkinter.messagebox import *
from datetime import datetime


PATH = os.path.dirname(os.path.realpath(__file__))   #编写端
#PATH = os.getcwd()                                  #应用端


class Container:
    def __init__(self, app, iid, num, x, y):
        self.app = app
        self.iid = iid
        self.num = num
        self.x = x
        self.y = y
        self.button1 = Button(master = self.app,
                                font = ('consolas', 16),
                                borderwidth = 0,
                                bg = '#2F3133',
                                fg = 'white'
                            )


    def config(self):
        self.button1.config(text = f'{self.num}')


    def adapt(self):
        self.button1.place(x = self.x, y = self.y, width = 32, height = 32)


class Timer:

    def __init__(self, tLabel):

        self.tLabel = tLabel
        self.running = False
        self.start_time = None


    def _flash(self):

        if not self.running:
            return

        current_time = datetime.now()
        time_left = current_time - self.start_time
        m_seconds_left = time_left.microseconds
        seconds_left = time_left.seconds

        self.tLabel.config(text = f'{seconds_left}.{str(m_seconds_left)[:3]}s ')
        self.tLabel.after(50, self._flash)


    def stop(self):
        self.running = False


    def begin(self):
        self.running = True
        self.start_time = datetime.now()
        self._flash()



class ScrolledText(Text):
    def __init__(self, master = None, **kw):
        self.frame = Frame(master)
        self.vbar = Scrollbar(self.frame)
        #self.vbar.pack(side = RIGHT, fill = Y)

        kw.update({'yscrollcommand': self.vbar.set})
        Text.__init__(self, self.frame, borderwidth = 0, bg = '#222222', fg = 'lime', font = ('consolas', 10), **kw)
        self.config(selectbackground = '#222222', selectforeground = 'lime')
        self.pack(side = LEFT, fill = BOTH, expand = True)
        self.vbar['command'] = self.yview

        text_meths = vars(Text).keys()
        methods = vars(Pack).keys() | vars(Grid).keys() | vars(Place).keys()
        methods = methods.difference(text_meths)

        for m in methods:
            if m[0] != '_' and m != 'config' and m != 'configure':
                setattr(self, m, getattr(self.frame, m))

    def __str__(self):
        return str(self.frame)

    def stop(self):
        self['state'] = 'disabled'


class GAME:
    "Main thread of FOCUSER."

    def __init__(self, num_now=1):
        """
        The main window of the software.
        num_now : the number should be clicked.
        """

        self.root = Tk()
        self.num_now = num_now
        self.conts = ["List of Container"]


    def config_win(self):
        self.root.title('Focus!')
        self.root.geometry('248x400')
        self.root.resizable(False, False)
        self.root.iconbitmap(PATH + '\\Threat.contrast-black.ico')
        self.root.config(bg = '#181818')

        self.time_label = Label(self.root, text = '0.00s ',
                                justify = 'right',
                                anchor = 'e',
                                fg = 'white',
                                bg = '#183338',
                                font = ('consolas', 10)
                            )

        self.reset_but = Button(self.root,
                                relief = GROOVE,
                                borderwidth = 0,
                                text = '开始 / 重新开始',
                                font = ('微软雅黑', 11),
                                bg = '#0078D4',
                                fg = 'white',
                                activebackground = '#6CACCE',
                                activeforeground = 'white',
                                command = None
                            )

        self.timer = Timer(self.time_label)

        self.his_cont = ScrolledText(self.root)


    def get_history(self):
        f = open(PATH + "\\FOCUSER_HISTORY.fcs", 'a', encoding = 'UTF-8')
        f.close()
        with open(PATH + "\\FOCUSER_HISTORY.fcs", 'r', encoding = 'UTF-8') as ff:
            hishis = ff.readlines()
            ff.close()
        self.his_cont['state'] = 'normal'
        for e in hishis:
            self.his_cont.insert(INSERT, e)
        self.his_cont['state'] = 'disabled'


    def save_history(self, ths):
        f = open(PATH + "\\FOCUSER_HISTORY.fcs", 'a', encoding = 'UTF-8')
        f.write(ths)
        f.close()


    def judge(self, given):
        def bibao():
            if given == self.num_now:
                self.num_now += 1

                for i in range(1, 37):
                    if self.conts[i].num == self.num_now:
                        self.conts[i].button1.config(activebackground = '#276235', activeforeground = 'white')
                    #elif self.conts[i].num == self.num_now:
                    else:
                        self.conts[i].button1.config(activebackground = '#8D1D2C', activeforeground = 'white')
                if self.num_now == 37:
                    self.timer.stop()
                    msg = '[' + str(datetime.now())[:19] + ']\t' + self.time_label.cget('text') + '\n'
                    self.his_cont['state'] = 'normal'
                    self.his_cont.insert(INSERT, msg)
                    self.his_cont.see(END)
                    self.his_cont['state'] = 'disabled'
                    self.save_history(msg)

            else:
                pass
        return bibao


    def new_game(self):
        self.timer.stop()

        self.tns = [i for i in range(1, 37)]
        random.shuffle(self.tns)
        self.tns = [0] + self.tns

        for i in range(1, 37):
            self.conts[i].num = self.tns[i]
            self.conts[i].config()
            self.conts[i].button1.config(command = self.judge(self.conts[i].num))

        self.num_now = 1
        for i in range(1, 37):
            if self.conts[i].num == 1:
                self.conts[i].button1.config(activebackground = '#276235', activeforeground = 'white')
            else:
                self.conts[i].button1.config(activebackground = '#8D1D2C', activeforeground = 'white')


        self.timer.begin()


    def pack_buts(self):
        "Add Buttons to Main window."

        for i in range(1, 37):
            X = 8 + ((i-1) // 6) * 40
            Y = 8 + ((i-1) % 6) * 40
            self.conts.append(Container(self.root, iid = i, num = 0, x = X, y = Y))
            self.conts[-1].adapt()

        self.time_label.place(x = 240, y = 248, height = 30, width = 72, anchor = 'ne')
        self.reset_but.config(command = self.new_game)
        self.reset_but.place(x = 8, y = 248, width = 152, height = 30)
        self.his_cont.place(x = 8, y = 286, width = 232, height = 90)
        self.his_cont['state'] = 'disabled'


    def run(self):
        self.root.mainloop()



a = GAME()
a.config_win()
a.pack_buts()
a.get_history()
a.run()

依旧不是直接复制粘贴就可以,记得把那个图标那句注释掉。如果要打包成exe,把最前面那两个PATH换一个注释掉。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值