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换一个注释掉。
185

被折叠的 条评论
为什么被折叠?



